From ce38fd67a8672688143db86bcad51edda365de19 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Sun, 17 May 2026 19:01:36 +0200 Subject: [PATCH 001/289] agent_ui: Show title edit for terminals in agent panel (#57005) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/agent_ui/src/agent_panel.rs | 79 ++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 20 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index a511b365632..4cab8aee371 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -3953,6 +3953,14 @@ impl AgentPanel { } } + fn should_show_title_edit(&self, window: &Window, cx: &Context) -> bool { + matches!( + self.visible_surface(), + VisibleSurface::AgentThread(_) | VisibleSurface::Terminal(_) + ) && self.has_open_project(cx) + && !self.is_title_editor_focused(window, cx) + } + fn render_title_view(&self, window: &mut Window, cx: &Context) -> AnyElement { let content = match self.visible_surface() { VisibleSurface::AgentThread(conversation_view) => { @@ -4096,26 +4104,21 @@ impl AgentPanel { .max_w_full() .overflow_x_hidden() .child(content) - .when( - matches!(self.visible_surface(), VisibleSurface::AgentThread(_)) - && self.has_open_project(cx) - && !self.is_title_editor_focused(window, cx), - |this| { - this.child(gradient_overlay).child( - h_flex() - .visible_on_hover("title_editor") - .absolute() - .right_0() - .h_full() - .bg(cx.theme().colors().tab_bar_background) - .child( - IconButton::new("edit_tile", IconName::Pencil) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Edit Thread Title")), - ), - ) - }, - ) + .when(self.should_show_title_edit(window, cx), |this| { + this.child(gradient_overlay).child( + h_flex() + .visible_on_hover("title_editor") + .absolute() + .right_0() + .h_full() + .bg(cx.theme().colors().tab_bar_background) + .child( + IconButton::new("edit_tile", IconName::Pencil) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Edit Thread Title")), + ), + ) + }) .into_any() } @@ -6824,6 +6827,42 @@ mod tests { }); } + #[gpui::test] + async fn test_title_edit_affordance_matches_threads_and_terminals(cx: &mut TestAppContext) { + let (panel, mut cx) = setup_panel(cx).await; + + panel.update_in(&mut cx, |panel, window, cx| { + panel.activate_draft(false, AgentThreadSource::AgentPanel, window, cx); + }); + cx.run_until_parked(); + + panel.update_in(&mut cx, |panel, window, cx| { + assert!(matches!( + panel.visible_surface(), + VisibleSurface::AgentThread(_) + )); + assert!(panel.should_show_title_edit(window, cx)); + }); + + let terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + panel.update_in(&mut cx, |panel, window, cx| { + assert!(matches!( + panel.visible_surface(), + VisibleSurface::Terminal(_) + )); + assert!(panel.should_show_title_edit(window, cx)); + + panel.edit_terminal_title(terminal_id, window, cx); + assert!(!panel.should_show_title_edit(window, cx)); + }); + } + #[gpui::test] async fn test_terminal_working_directory_uses_active_workspace_while_workspace_is_updating( cx: &mut TestAppContext, From 23231879cd40f27eb3574278e7e13a38e9807c5e Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Sun, 17 May 2026 19:03:17 +0200 Subject: [PATCH 002/289] acp: Add ACP session deletion support (#57004) Still behind a flag until RFD progresses. But also fixes one area where we would have called delete even if we didn't have support. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- Cargo.lock | 36 +++-- Cargo.toml | 2 +- crates/acp_thread/src/connection.rs | 2 +- crates/agent/src/agent.rs | 2 +- crates/agent_servers/src/acp.rs | 161 +++++++++++++++++++- crates/agent_ui/src/threads_archive_view.rs | 6 +- 6 files changed, 190 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fcd7c96fa82..c90f3ef4f7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -224,13 +224,14 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af62fb84df2af0f933d8f5fd78b843fa5eb0ec5a48fa1b528c41951d0bbe36c" +checksum = "1084cabbc2b00d353bad7e54750b0ef0f0bba9204c5884240c83a628704db86c" dependencies = [ "agent-client-protocol-derive", "agent-client-protocol-schema", - "anyhow", + "async-process", + "blocking", "futures 0.3.32", "futures-concurrency", "jsonrpcmsg", @@ -239,7 +240,7 @@ dependencies = [ "schemars 1.0.4", "serde", "serde_json", - "thiserror 2.0.17", + "shell-words", "tokio", "tokio-util", "tracing", @@ -248,20 +249,19 @@ dependencies = [ [[package]] name = "agent-client-protocol-derive" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce42c2d3c048c12897eef2e577dfff1e3355c632c9f1625cc953b9df48b44631" +checksum = "cabdc9d845d08ec7ed2d0c9de1ae4a1b198301407d55855261572761be90ec9f" dependencies = [ - "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "agent-client-protocol-schema" -version = "0.12.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49bae57dad1c28a362fbdcf7bab0583316a02b45a70792109fced55780a3b63c" +checksum = "2984583e634f3f4d479b585aaa76de4a633255dcdf2be6489c6a8486f758af04" dependencies = [ "anyhow", "derive_more", @@ -2254,6 +2254,15 @@ dependencies = [ "utf8-chars", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.12.1" @@ -16042,11 +16051,12 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.18.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ "base64 0.22.1", + "bs58", "chrono", "hex", "indexmap 1.9.3", @@ -16061,9 +16071,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.18.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" dependencies = [ "darling 0.23.0", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 07c94aa33ee..7303d4ebe0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -500,7 +500,7 @@ ztracing_macro = { path = "crates/ztracing_macro" } # External crates # -agent-client-protocol = { version = "=0.11.1", features = ["unstable"] } +agent-client-protocol = { version = "=0.12.0", features = ["unstable"] } aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "9d9640d4" } any_vec = "0.14" diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 41cdd1250b3..b2bd00f61a3 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -310,7 +310,7 @@ pub trait AgentSessionList { cx: &mut App, ) -> Task>; - fn supports_delete(&self) -> bool { + fn supports_delete(&self, _cx: &App) -> bool { false } diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index a8b70c80b47..eda50ab5637 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -2428,7 +2428,7 @@ impl AgentSessionList for NativeAgentSessionList { Task::ready(Ok(AgentSessionListResponse::new(sessions))) } - fn supports_delete(&self) -> bool { + fn supports_delete(&self, _cx: &App) -> bool { true } diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 166a75ea250..f0a036ca4da 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -473,15 +473,17 @@ pub struct AcpSession { pub struct AcpSessionList { connection: ConnectionTo, + supports_delete: bool, updates_tx: async_channel::Sender, updates_rx: async_channel::Receiver, } impl AcpSessionList { - fn new(connection: ConnectionTo) -> Self { + fn new(connection: ConnectionTo, supports_delete: bool) -> Self { let (tx, rx) = async_channel::unbounded(); Self { connection, + supports_delete, updates_tx: tx, updates_rx: rx, } @@ -537,6 +539,29 @@ impl AgentSessionList for AcpSessionList { }) } + fn supports_delete(&self, cx: &App) -> bool { + self.supports_delete && cx.has_flag::() + } + + fn delete_session(&self, session_id: &acp::SessionId, cx: &mut App) -> Task> { + if !self.supports_delete(cx) { + return Task::ready(Err(anyhow::anyhow!("delete_session not supported"))); + } + + let conn = self.connection.clone(); + let updates_tx = self.updates_tx.clone(); + let session_id = session_id.clone(); + cx.foreground_executor().spawn(async move { + into_foreground_future(conn.send_request(acp::DeleteSessionRequest::new(session_id))) + .await + .map_err(map_acp_error)?; + updates_tx + .try_send(acp_thread::SessionListUpdate::Refresh) + .log_err(); + Ok(()) + }) + } + fn watch( &self, _cx: &mut App, @@ -927,6 +952,11 @@ impl AcpConnection { .unwrap_or_else(|| agent_id.0.clone()); let agent_version = agent_info .and_then(|info| (!info.version.is_empty()).then(|| SharedString::from(info.version))); + let agent_supports_delete = response + .agent_capabilities + .session_capabilities + .delete + .is_some(); let session_list = if response .agent_capabilities @@ -934,7 +964,10 @@ impl AcpConnection { .list .is_some() { - let list = Rc::new(AcpSessionList::new(connection.clone())); + let list = Rc::new(AcpSessionList::new( + connection.clone(), + agent_supports_delete, + )); *client_session_list.borrow_mut() = Some(list.clone()); Some(list) } else { @@ -2337,6 +2370,8 @@ pub mod test_support { mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; + use feature_flags::FeatureFlag as _; + use super::*; #[test] @@ -2484,6 +2519,128 @@ mod tests { ); } + #[gpui::test] + async fn session_delete_support_requires_beta_flag_and_capability( + cx: &mut gpui::TestAppContext, + ) { + let deleted_sessions = Arc::new(std::sync::Mutex::new(Vec::new())); + let connection = connect_session_delete_test_agent(deleted_sessions, cx).await; + let session_list = AcpSessionList::new(connection.clone(), true); + let missing_capability = AcpSessionList::new(connection, false); + + cx.update(|cx| { + let store = settings::SettingsStore::test(cx); + cx.set_global(store); + + assert_eq!( + session_list.supports_delete(cx), + cx.has_flag::() + ); + assert!(!missing_capability.supports_delete(cx)); + + cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]); + assert!(session_list.supports_delete(cx)); + assert!(!missing_capability.supports_delete(cx)); + }); + } + + async fn connect_session_delete_test_agent( + deleted_sessions: Arc>>, + cx: &mut gpui::TestAppContext, + ) -> ConnectionTo { + let (client_transport, agent_transport) = agent_client_protocol::Channel::duplex(); + + cx.background_spawn( + Agent + .builder() + .name("delete-test-agent") + .on_receive_request( + { + let deleted_sessions = deleted_sessions.clone(); + async move |request: acp::DeleteSessionRequest, responder, _cx| { + deleted_sessions + .lock() + .expect("deleted sessions lock should not be poisoned") + .push(request.session_id); + responder.respond(acp::DeleteSessionResponse::default()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .connect_to(agent_transport), + ) + .detach(); + + let (connection_tx, connection_rx) = futures::channel::oneshot::channel(); + cx.background_spawn(Client.builder().name("delete-test-client").connect_with( + client_transport, + move |connection: ConnectionTo| async move { + connection_tx.send(connection).ok(); + futures::future::pending::>().await + }, + )) + .detach(); + + connection_rx + .await + .expect("failed to receive ACP connection") + } + + #[gpui::test] + async fn session_list_delete_sends_session_delete_when_supported( + cx: &mut gpui::TestAppContext, + ) { + let deleted_sessions = Arc::new(std::sync::Mutex::new(Vec::new())); + let connection = connect_session_delete_test_agent(deleted_sessions.clone(), cx).await; + let session_list = AcpSessionList::new(connection, true); + let session_id = acp::SessionId::new("session-to-delete"); + + cx.update(|cx| { + let store = settings::SettingsStore::test(cx); + cx.set_global(store); + cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]); + }); + cx.update(|cx| session_list.delete_session(&session_id, cx)) + .await + .expect("delete_session failed"); + + assert_eq!( + *deleted_sessions + .lock() + .expect("deleted sessions lock should not be poisoned"), + vec![session_id] + ); + } + + #[gpui::test] + async fn session_list_delete_does_not_send_when_unsupported(cx: &mut gpui::TestAppContext) { + let deleted_sessions = Arc::new(std::sync::Mutex::new(Vec::new())); + let connection = connect_session_delete_test_agent(deleted_sessions.clone(), cx).await; + let session_list = AcpSessionList::new(connection, false); + let session_id = acp::SessionId::new("session-to-delete"); + + cx.update(|cx| { + let store = settings::SettingsStore::test(cx); + cx.set_global(store); + cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]); + }); + let error = cx + .update(|cx| session_list.delete_session(&session_id, cx)) + .await + .expect_err("delete_session should fail when unsupported"); + + assert!( + error.to_string().contains("delete_session not supported"), + "unexpected error: {error}" + ); + assert!( + deleted_sessions + .lock() + .expect("deleted sessions lock should not be poisoned") + .is_empty() + ); + } + #[cfg(not(windows))] #[gpui::test] async fn startup_returns_error_when_agent_exits_before_initialization( diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index f751c9ca1d6..d53a8d473e9 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -821,7 +821,11 @@ impl ThreadsArchiveView { let state = task.await?; let task = cx.update(|cx| { if let Some(session_id) = &session_id { - if let Some(list) = state.connection.session_list(cx) { + if let Some(list) = state + .connection + .session_list(cx) + .filter(|list| list.supports_delete(cx)) + { list.delete_session(session_id, cx) } else { Task::ready(Ok(())) From 43585afc29ea95e0c879309e08436bc25f5fab16 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Sun, 17 May 2026 19:15:09 +0200 Subject: [PATCH 003/289] agent_ui: Paste dropped paths into agent terminals (#56686) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/agent_ui/Cargo.toml | 1 + crates/agent_ui/src/agent_panel.rs | 293 ++++++++++++++++++++-- crates/terminal_view/src/terminal_view.rs | 2 +- 3 files changed, 269 insertions(+), 27 deletions(-) diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index fcb23fd17f0..03cb331334f 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -145,3 +145,4 @@ tempfile.workspace = true vim.workspace = true tree-sitter-md.workspace = true unindent.workspace = true +terminal = { workspace = true, features = ["test-support"] } diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 4cab8aee371..88c5baa2b22 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -4905,32 +4905,79 @@ impl AgentPanel { }), ) .on_drop(cx.listener(move |this, paths: &ExternalPaths, window, cx| { - let tasks = paths - .paths() - .iter() - .map(|path| { - Workspace::project_path_for_path(this.project.clone(), path, false, cx) - }) - .collect::>(); - cx.spawn_in(window, async move |this, cx| { - let mut paths = vec![]; - let mut added_worktrees = vec![]; - let opened_paths = futures::future::join_all(tasks).await; - for entry in opened_paths { - if let Some((worktree, project_path)) = entry.log_err() { - added_worktrees.push(worktree); - paths.push(project_path); - } - } - this.update_in(cx, |this, window, cx| { - this.handle_drop(paths, added_worktrees, window, cx); - }) - .ok(); - }) - .detach(); + this.handle_external_paths_drop(paths, window, cx); })) } + fn handle_external_paths_drop( + &mut self, + paths: &ExternalPaths, + window: &mut Window, + cx: &mut Context, + ) { + if matches!(&self.base_view, BaseView::Terminal { .. }) { + // Terminal drops should match normal terminal views by pasting raw OS paths. + // The agent-thread path below converts paths to project paths, which can add + // worktrees and is only needed when attaching files to a conversation. + self.paste_external_paths_into_active_terminal(paths, window, cx); + return; + } + + let BaseView::AgentThread { conversation_view } = &self.base_view else { + return; + }; + let conversation_view = conversation_view.clone(); + let tasks = paths + .paths() + .iter() + .map(|path| Workspace::project_path_for_path(self.project.clone(), path, false, cx)) + .collect::>(); + cx.spawn_in(window, async move |_this, cx| { + let mut paths = vec![]; + let mut added_worktrees = vec![]; + let opened_paths = futures::future::join_all(tasks).await; + for entry in opened_paths { + if let Some((worktree, project_path)) = entry.log_err() { + added_worktrees.push(worktree); + paths.push(project_path); + } + } + conversation_view + .update_in(cx, |conversation_view, window, cx| { + conversation_view.insert_dragged_files(paths, added_worktrees, window, cx); + }) + .log_err(); + }) + .detach(); + } + + fn paste_external_paths_into_active_terminal( + &mut self, + paths: &ExternalPaths, + window: &mut Window, + cx: &mut Context, + ) { + let BaseView::Terminal { terminal_id } = &self.base_view else { + return; + }; + + if !self.project.read(cx).is_local() { + return; + } + + let Some(terminal_view) = self + .terminals + .get(terminal_id) + .map(|terminal| terminal.view.clone()) + else { + return; + }; + + terminal_view.update(cx, |terminal_view, cx| { + terminal_view.add_paths_to_terminal(paths.paths(), window, cx); + }); + } + fn handle_drop( &mut self, paths: Vec, @@ -4944,7 +4991,30 @@ impl AgentPanel { conversation_view.insert_dragged_files(paths, added_worktrees, window, cx); }); } - BaseView::Terminal { .. } | BaseView::Uninitialized => {} + BaseView::Terminal { terminal_id } => { + let paths = { + let project = self.project.read(cx); + paths + .iter() + .filter_map(|project_path| project.absolute_path(project_path, cx)) + .collect::>() + }; + + if paths.is_empty() { + return; + } + + if let Some(terminal_view) = self + .terminals + .get(terminal_id) + .map(|terminal| terminal.view.clone()) + { + terminal_view.update(cx, |terminal_view, cx| { + terminal_view.add_paths_to_terminal(&paths, window, cx); + }); + } + } + BaseView::Uninitialized => {} } } @@ -5003,7 +5073,9 @@ impl Render for AgentPanel { VisibleSurface::AgentThread(conversation_view) => parent .child(conversation_view.clone()) .child(self.render_drag_target(cx)), - VisibleSurface::Terminal(terminal_view) => parent.child(terminal_view.clone()), + VisibleSurface::Terminal(terminal_view) => parent + .child(terminal_view.clone()) + .child(self.render_drag_target(cx)), VisibleSurface::Configuration(configuration) => { parent.children(configuration.cloned()) } @@ -5279,7 +5351,7 @@ mod tests { use std::any::Any; use serde_json::json; - use std::path::Path; + use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Instant; @@ -6669,6 +6741,175 @@ mod tests { (panel, cx) } + fn expected_terminal_drop_text(paths: &[PathBuf]) -> String { + let mut text = String::new(); + for path in paths { + text.push(' '); + text.push_str(&format!("{path:?}")); + } + text.push(' '); + text + } + + #[gpui::test] + async fn test_terminal_external_image_drop_writes_path(cx: &mut TestAppContext) { + let (panel, mut cx) = setup_panel(cx).await; + cx.update(|_, cx| { + cx.update_flags(true, vec!["agent-panel-terminal".to_string()]); + }); + + let terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Image Upload", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + let terminal = panel.read_with(&cx, |panel, cx| { + panel + .terminals + .get(&terminal_id) + .expect("terminal should remain in the panel") + .view + .read(cx) + .terminal() + .clone() + }); + terminal.update(&mut cx, |terminal, _cx| terminal.take_input_log()); + + let image_path = PathBuf::from("/tmp/dropped-image.png"); + panel.update_in(&mut cx, |panel, window, cx| { + let external_paths = ExternalPaths(vec![image_path.clone()].into()); + panel.paste_external_paths_into_active_terminal(&external_paths, window, cx); + }); + + let mut input_log = terminal.update(&mut cx, |terminal, _cx| terminal.take_input_log()); + assert_eq!(input_log.len(), 1, "expected one write to the terminal"); + let written = + String::from_utf8(input_log.remove(0)).expect("terminal write should be valid UTF-8"); + assert_eq!( + written, + expected_terminal_drop_text(std::slice::from_ref(&image_path)) + ); + } + + #[gpui::test] + async fn test_terminal_external_paths_drop_handler_writes_image_path(cx: &mut TestAppContext) { + let (panel, mut cx) = setup_panel(cx).await; + cx.update(|_, cx| { + cx.update_flags(true, vec!["agent-panel-terminal".to_string()]); + }); + + let terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Image Upload", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + let terminal = panel.read_with(&cx, |panel, cx| { + panel + .terminals + .get(&terminal_id) + .expect("terminal should remain in the panel") + .view + .read(cx) + .terminal() + .clone() + }); + terminal.update(&mut cx, |terminal, _cx| terminal.take_input_log()); + + let image_path = PathBuf::from("/tmp/dropped-image.png"); + panel.update_in(&mut cx, |panel, window, cx| { + let external_paths = ExternalPaths(vec![image_path.clone()].into()); + panel.handle_external_paths_drop(&external_paths, window, cx); + }); + + let mut input_log = terminal.update(&mut cx, |terminal, _cx| terminal.take_input_log()); + assert_eq!(input_log.len(), 1, "expected one write to the terminal"); + let written = + String::from_utf8(input_log.remove(0)).expect("terminal write should be valid UTF-8"); + assert_eq!( + written, + expected_terminal_drop_text(std::slice::from_ref(&image_path)) + ); + } + + #[gpui::test] + async fn test_external_file_drop_on_thread_does_not_paste_into_later_terminal( + cx: &mut TestAppContext, + ) { + init_test(cx); + cx.update(|cx| { + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + cx.update_flags(true, vec!["agent-panel-terminal".to_string()]); + }); + + let fs = FakeFs::new(cx.executor()); + cx.update(|cx| ::set_global(fs.clone(), cx)); + fs.insert_tree("/project", json!({ "file.txt": "content" })) + .await; + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx); + + let panel = workspace.update_in(&mut cx, |workspace, window, cx| { + let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + open_thread_with_connection(&panel, StubAgentConnection::new(), &mut cx); + let thread_id = active_thread_id(&panel, &cx); + + let file_path = PathBuf::from("/project/file.txt"); + panel.update_in(&mut cx, |panel, window, cx| { + let external_paths = ExternalPaths(vec![file_path.clone()].into()); + panel.handle_external_paths_drop(&external_paths, window, cx); + }); + + let terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Drop Target", true, window, cx) + }) + .expect("test terminal should be inserted"); + let terminal = panel.read_with(&cx, |panel, cx| { + panel + .terminals + .get(&terminal_id) + .expect("terminal should remain in the panel") + .view + .read(cx) + .terminal() + .clone() + }); + terminal.update(&mut cx, |terminal, _cx| terminal.take_input_log()); + + cx.run_until_parked(); + + let input_log = terminal.update(&mut cx, |terminal, _cx| terminal.take_input_log()); + assert!( + input_log.is_empty(), + "thread drop completion should not write to the active terminal" + ); + + let expected_uri = MentionUri::File { + abs_path: file_path, + } + .to_uri() + .to_string(); + let expected_text = format!("[@file.txt]({expected_uri}) "); + let actual_text = panel.read_with(&cx, |panel, cx| panel.editor_text(thread_id, cx)); + assert_eq!(actual_text.as_deref(), Some(expected_text.as_str())); + } + #[gpui::test] async fn test_terminal_entry_kind_controls_new_entry(cx: &mut TestAppContext) { let (panel, mut cx) = setup_panel(cx).await; diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 449e1c67d79..55e6929d832 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -855,7 +855,7 @@ impl TerminalView { }); } - fn add_paths_to_terminal(&self, paths: &[PathBuf], window: &mut Window, cx: &mut App) { + pub fn add_paths_to_terminal(&self, paths: &[PathBuf], window: &mut Window, cx: &mut App) { let mut text = paths.iter().map(|path| format!(" {path:?}")).join(""); text.push(' '); window.focus(&self.focus_handle(cx), cx); From 4d13f01c89ef4fbba9ece2ee3fe6a61b1d7f32e3 Mon Sep 17 00:00:00 2001 From: Rio Fujita Date: Mon, 18 May 2026 04:38:37 +0900 Subject: [PATCH 004/289] project: Context server updates on worktree changes (#51244) Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the UI checklist Release Notes: - Fixed context server availability updates when a new worktree is added to or removed from a project. --------- Co-authored-by: Ben Brandt --- crates/project/src/context_server_store.rs | 12 ++- .../tests/integration/context_server_store.rs | 87 +++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/crates/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs index de2e1e3ceff..6e231453e41 100644 --- a/crates/project/src/context_server_store.rs +++ b/crates/project/src/context_server_store.rs @@ -28,7 +28,7 @@ use util::{ResultExt as _, rel_path::RelPath}; use crate::{ DisableAiSettings, Project, project_settings::{ContextServerSettings, ProjectSettings}, - worktree_store::WorktreeStore, + worktree_store::{WorktreeStore, WorktreeStoreEvent}, }; /// Maximum timeout for context server requests @@ -454,6 +454,16 @@ impl ContextServerStore { this.available_context_servers_changed(cx); } })); + subscriptions.push(cx.subscribe(&worktree_store, |this, _store, event, cx| { + if matches!( + event, + WorktreeStoreEvent::WorktreeAdded(_) + | WorktreeStoreEvent::WorktreeRemoved(_, _) + ) && !DisableAiSettings::get_global(cx).disable_ai + { + this.available_context_servers_changed(cx); + } + })); } let ai_disabled = DisableAiSettings::get_global(cx).disable_ai; diff --git a/crates/project/tests/integration/context_server_store.rs b/crates/project/tests/integration/context_server_store.rs index 5b68e11bb95..f9dbce84a17 100644 --- a/crates/project/tests/integration/context_server_store.rs +++ b/crates/project/tests/integration/context_server_store.rs @@ -664,6 +664,93 @@ async fn test_context_server_respects_disable_ai(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_context_server_refreshed_when_worktree_added(cx: &mut TestAppContext) { + const SERVER_1_ID: &str = "mcp-1"; + + let server_1_id = ContextServerId(SERVER_1_ID.into()); + + let (fs, project) = setup_context_server_test(cx, json!({"code.rs": ""}), vec![]).await; + fs.insert_tree(path!("/second"), json!({"other.rs": ""})) + .await; + + let executor = cx.executor(); + let store = project.read_with(cx, |project, _| project.context_server_store()); + store.update(cx, |store, _| { + store.set_context_server_factory(Box::new(move |id, _| { + Arc::new(ContextServer::new( + id.clone(), + Arc::new(create_fake_transport(id.0.to_string(), executor.clone())), + )) + })); + }); + + set_context_server_configuration( + vec![( + server_1_id.0.clone(), + settings::ContextServerSettingsContent::Stdio { + enabled: true, + remote: false, + command: ContextServerCommand { + path: "somebinary".into(), + args: vec!["arg".to_string()], + env: None, + timeout: None, + }, + }, + )], + cx, + ); + + { + let _server_events = assert_server_events( + &store, + vec![ + (server_1_id.clone(), ContextServerStatus::Starting), + (server_1_id.clone(), ContextServerStatus::Running), + ], + cx, + ); + cx.run_until_parked(); + } + + // Witness that adding a worktree triggers the store to refresh available + // servers (via `cx.notify` after `maintain_servers`). Without the + // `WorktreeStoreEvent::WorktreeAdded` subscription in `ContextServerStore`, + // this counter would remain zero. + let notify_count = Rc::new(RefCell::new(0usize)); + let _notify_subscription = cx.update(|cx| { + let count = notify_count.clone(); + cx.observe(&store, move |_, _| { + *count.borrow_mut() += 1; + }) + }); + + { + let _server_events = assert_server_events(&store, vec![], cx); + let _ = project.update(cx, |project, cx| { + project.find_or_create_worktree(path!("/second"), true, cx) + }); + cx.run_until_parked(); + } + + cx.update(|cx| { + assert!( + *notify_count.borrow() > 0, + "Adding a worktree should trigger the context server store to refresh" + ); + assert!( + store.read(cx).server_ids().contains(&server_1_id), + "Configured server list should still include the server after a worktree is added" + ); + assert_eq!( + store.read(cx).status_for_server(&server_1_id), + Some(ContextServerStatus::Running), + "Server should still be running after a worktree is added" + ); + }); +} + #[gpui::test] async fn test_server_ids_includes_disabled_servers(cx: &mut TestAppContext) { const ENABLED_SERVER_ID: &str = "enabled-server"; From 53c910982cafd492bedeb58b1c8185328cd5737b Mon Sep 17 00:00:00 2001 From: marius851000 Date: Sun, 17 May 2026 21:50:14 +0200 Subject: [PATCH 005/289] open_ai: Fix parsing response if token use info is unspecified (#55919) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I tried to use google cloud to test gemma4 and compare with the result of ollama. it had response such as ```json {"choices":[{"delta":{"content":"Hello","reasoning_content":null,"role":null,"tool_calls":null},"finish_reason":null,"index":0,"logprobs":null,"matched_stop":null}],"created":1778081610,"id":"KV_7adz7Ov20xN8Py-angQ8","model":"google/gemma-4-26b-a4b-it-maas","object":"chat.completion.chunk","usage":{"extra_properties":{"google":{"traffic_type":"ON_DEMAND"}}}} ``` (notice that, while "usage" is present, it does not have any of the usual value) Eventually, I had some more issue when parsing the response (unrelated to this), so I decided to try the google ai endpoint, with its own set of issue. Those simple change should only loosen the accepted format, so no new compatibility error are expected (but I haven’t tried with other provider) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) (no change) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Improved open-ai compatibility when token usage info is absent --- crates/open_ai/src/completion.rs | 9 ++++++--- crates/open_ai/src/open_ai.rs | 6 +++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/open_ai/src/completion.rs b/crates/open_ai/src/completion.rs index 3b62391678e..aac33387df1 100644 --- a/crates/open_ai/src/completion.rs +++ b/crates/open_ai/src/completion.rs @@ -552,10 +552,13 @@ impl OpenAiEventMapper { event: ResponseStreamEvent, ) -> Vec> { let mut events = Vec::new(); - if let Some(usage) = event.usage { + if let Some(usage) = event.usage + && let Some(prompt_tokens) = usage.prompt_tokens + && let Some(completion_tokens) = usage.completion_tokens + { events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage { - input_tokens: usage.prompt_tokens, - output_tokens: usage.completion_tokens, + input_tokens: prompt_tokens, + output_tokens: completion_tokens, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, }))); diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index b756e8a6122..1b4b4958f21 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -622,9 +622,9 @@ pub struct FunctionChunk { #[derive(Clone, Serialize, Deserialize, Debug)] pub struct Usage { - pub prompt_tokens: u64, - pub completion_tokens: u64, - pub total_tokens: u64, + pub prompt_tokens: Option, + pub completion_tokens: Option, + pub total_tokens: Option, } #[derive(Serialize, Deserialize, Debug)] From 2bd7f35157bb07dff36c27ac9f6188c476f3cf65 Mon Sep 17 00:00:00 2001 From: Xiaobo Liu Date: Mon, 18 May 2026 04:35:45 +0800 Subject: [PATCH 006/289] project: Remove stale registry agent archive caches (#55290) Registry archive agents install each version into a versioned cache directory, but older extracted archives were left behind after updates and could accumulate disk usage. After resolving the current archive cache directory, remove other versioned cache directories for the same registry agent while preserving the current version. Release Notes: - Fix certain ACP registry agents not cleaning up old versions --------- Co-authored-by: Ben Brandt --- crates/project/src/agent_server_store.rs | 157 ++++++++++++++++++++++- 1 file changed, 154 insertions(+), 3 deletions(-) diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index e0e044e5638..f43f045c5e0 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -7,8 +7,12 @@ use std::{ use anyhow::{Context as _, Result, bail}; use collections::HashMap; -use fs::Fs; -use gpui::{AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task, TaskExt}; +use fs::{Fs, RemoveOptions}; +use futures::StreamExt; +use gpui::{ + AppContext as _, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task, + TaskExt, +}; use http_client::{HttpClient, github::AssetKind}; use node_runtime::NodeRuntime; use percent_encoding::percent_decode_str; @@ -1126,6 +1130,72 @@ fn versioned_archive_cache_dir( )) } +// The `v_` prefix here must stay in sync with `versioned_archive_cache_dir`, +// so we only ever remove directories that we created ourselves. +const VERSIONED_ARCHIVE_CACHE_DIR_PREFIX: &str = "v_"; + +async fn remove_stale_versioned_archive_cache_dirs( + fs: Arc, + base_dir: &Path, + current_version_dir: &Path, +) -> Result<()> { + let Some(current_dir_name) = current_version_dir.file_name() else { + return Ok(()); + }; + + let current_mtime = fs + .metadata(current_version_dir) + .await + .with_context(|| format!("reading metadata for {current_version_dir:?}"))? + .with_context(|| format!("missing metadata for {current_version_dir:?}"))? + .mtime; + + let mut entries = fs + .read_dir(base_dir) + .await + .with_context(|| format!("reading archive cache directory {base_dir:?}"))?; + + while let Some(entry) = entries.next().await { + let entry = entry.with_context(|| format!("reading entry in {base_dir:?}"))?; + let Some(entry_name) = entry.file_name() else { + continue; + }; + + if entry_name == current_dir_name + || !entry_name + .to_string_lossy() + .starts_with(VERSIONED_ARCHIVE_CACHE_DIR_PREFIX) + { + continue; + } + + let Some(entry_metadata) = fs.metadata(&entry).await.log_err().flatten() else { + continue; + }; + if !entry_metadata.is_dir { + continue; + } + // Only remove directories that predate the current version's directory. + // This avoids racing with a concurrent extraction of a different version + // that finished after we cached the current version's mtime. + if !current_mtime.bad_is_greater_than(entry_metadata.mtime) { + continue; + } + + fs.remove_dir( + &entry, + RemoveOptions { + recursive: true, + ignore_if_not_exists: true, + }, + ) + .await + .with_context(|| format!("removing stale archive cache directory {entry:?}"))?; + } + + Ok(()) +} + pub struct LocalExtensionArchiveAgent { pub fs: Arc, pub http_client: Arc, @@ -1299,6 +1369,18 @@ impl ExternalAgentServer for LocalExtensionArchiveAgent { } }; + cx.background_spawn({ + let fs = fs.clone(); + let dir = dir.clone(); + let version_dir = version_dir.clone(); + async move { + remove_stale_versioned_archive_cache_dirs(fs, &dir, &version_dir) + .await + .log_err(); + } + }) + .detach(); + let mut args = target_config.args.clone(); args.extend(extra_args); @@ -1478,6 +1560,18 @@ impl ExternalAgentServer for LocalRegistryArchiveAgent { } }; + cx.background_spawn({ + let fs = fs.clone(); + let dir = dir.clone(); + let version_dir = version_dir.clone(); + async move { + remove_stale_versioned_archive_cache_dirs(fs, &dir, &version_dir) + .await + .log_err(); + } + }) + .detach(); + let mut args = target_config.args.clone(); args.extend(extra_args); @@ -1943,7 +2037,7 @@ mod tests { AgentRegistryStore, RegistryAgent, RegistryAgentMetadata, RegistryNpxAgent, }; use crate::worktree_store::{WorktreeIdCounter, WorktreeStore}; - use gpui::{AppContext as _, TestAppContext}; + use gpui::TestAppContext; use node_runtime::NodeRuntime; use settings::Settings as _; @@ -2137,6 +2231,63 @@ mod tests { assert_ne!(slash_version_dir, colon_version_dir); } + #[gpui::test] + async fn test_remove_stale_versioned_archive_cache_dirs(cx: &mut TestAppContext) { + let fs = fs::FakeFs::new(cx.executor()); + let base_dir = Path::new("/cache"); + + // FakeFs increments mtime on every create, so creation order is + // ascending mtime: v_old_1 < v_old_2 < other < v_not_a_dir < v_current < v_newer. + fs.insert_tree( + base_dir, + serde_json::json!({ + "v_old_1": {}, + "v_old_2": {}, + "other": {}, + }), + ) + .await; + fs.insert_file(base_dir.join("v_not_a_dir"), b"keep me".to_vec()) + .await; + let current_version_dir = base_dir.join("v_current"); + fs.create_dir(¤t_version_dir).await.unwrap(); + // Sibling that "finished extracting" after the current dir was cached. + fs.create_dir(&base_dir.join("v_newer")).await.unwrap(); + + remove_stale_versioned_archive_cache_dirs( + fs.clone() as Arc, + base_dir, + ¤t_version_dir, + ) + .await + .unwrap(); + + let mut remaining = fs + .read_dir(base_dir) + .await + .unwrap() + .filter_map(|entry| async move { entry.ok() }) + .map(|path| { + path.file_name() + .expect("entry has a name") + .to_string_lossy() + .into_owned() + }) + .collect::>() + .await; + remaining.sort(); + + assert_eq!( + remaining, + vec![ + "other".to_string(), + "v_current".to_string(), + "v_newer".to_string(), + "v_not_a_dir".to_string(), + ] + ); + } + #[gpui::test] fn test_version_change_sends_notification(cx: &mut TestAppContext) { init_test_settings(cx); From f2df3f9e18fa3bbbdab20086bd98395c97a46116 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Sun, 17 May 2026 22:50:16 +0200 Subject: [PATCH 007/289] acp: Add logout support for ACP agents (#56959) Behind feature flag until we move RFD forward, but already working great on codex Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --------- Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> --- crates/acp_thread/src/connection.rs | 8 ++ crates/agent_servers/src/acp.rs | 108 +++++++++++++++++++++++ crates/agent_ui/src/agent_panel.rs | 19 +++- crates/agent_ui/src/conversation_view.rs | 90 ++++++++++++++++++- crates/zed_actions/src/lib.rs | 2 + 5 files changed, 223 insertions(+), 4 deletions(-) diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index b2bd00f61a3..87c8ccf65c1 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -127,6 +127,14 @@ pub trait AgentConnection { fn authenticate(&self, method: acp::AuthMethodId, cx: &mut App) -> Task>; + fn supports_logout(&self, _cx: &App) -> bool { + false + } + + fn logout(&self, _cx: &mut App) -> Task> { + Task::ready(Err(anyhow::Error::msg("Logout is not supported"))) + } + fn prompt( &self, user_message_id: UserMessageId, diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index f0a036ca4da..b3328790a5d 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -1759,6 +1759,22 @@ impl AgentConnection for AcpConnection { }) } + fn supports_logout(&self, cx: &App) -> bool { + cx.has_flag::() && self.agent_capabilities.auth.logout.is_some() + } + + fn logout(&self, cx: &mut App) -> Task> { + if !self.supports_logout(cx) { + return Task::ready(Err(anyhow!("Logout is not supported by this agent."))); + } + + let conn = self.connection.clone(); + cx.foreground_executor().spawn(async move { + into_foreground_future(conn.send_request(acp::LogoutRequest::new())).await?; + Ok(()) + }) + } + fn prompt( &self, _id: acp_thread::UserMessageId, @@ -2026,6 +2042,7 @@ pub mod test_support { pub connection: Rc, pub load_session_count: Arc, pub close_session_count: Arc, + pub logout_count: Arc, pub keep_agent_alive: Task>, } @@ -2119,6 +2136,14 @@ pub mod test_support { self.inner.authenticate(method, cx) } + fn supports_logout(&self, cx: &App) -> bool { + self.inner.supports_logout(cx) + } + + fn logout(&self, cx: &mut App) -> Task> { + self.inner.logout(cx) + } + fn prompt( &self, user_message_id: UserMessageId, @@ -2201,6 +2226,7 @@ pub mod test_support { ) -> Result { let (client_transport, agent_transport) = agent_client_protocol::Channel::duplex(); + let logout_count = Arc::new(AtomicUsize::new(0)); let sessions: Rc>> = Rc::new(RefCell::new(HashMap::default())); let client_session_list: Rc>>> = @@ -2269,6 +2295,16 @@ pub mod test_support { }, agent_client_protocol::on_receive_request!(), ) + .on_receive_request( + { + let logout_count = logout_count.clone(); + async move |_req: acp::LogoutRequest, responder, _cx| { + logout_count.fetch_add(1, Ordering::SeqCst); + responder.respond(acp::LogoutResponse::new()) + } + }, + agent_client_protocol::on_receive_request!(), + ) .on_receive_notification( async move |_notif: acp::CancelNotification, _cx| Ok(()), agent_client_protocol::on_receive_notification!(), @@ -2341,6 +2377,7 @@ pub mod test_support { connection: Rc::new(connection), load_session_count, close_session_count, + logout_count, keep_agent_alive, }) } @@ -2373,6 +2410,7 @@ mod tests { use feature_flags::FeatureFlag as _; use super::*; + use gpui::UpdateGlobal as _; #[test] fn terminal_auth_task_builds_spawn_from_prebuilt_command() { @@ -2641,6 +2679,76 @@ mod tests { ); } + #[gpui::test] + async fn logout_is_gated_by_beta_flag_and_agent_capability(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + let store = settings::SettingsStore::test(cx); + cx.set_global(store); + settings::SettingsStore::update_global(cx, |store, _| { + store.register_setting::(); + }); + feature_flags::FeatureFlagStore::init(cx); + }); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree("/", serde_json::json!({ "a": {} })).await; + let project = project::Project::test(fs, [std::path::Path::new("/a")], cx).await; + let mut harness = test_support::connect_fake_acp_connection(project, cx).await; + cx.update(|cx| { + settings::SettingsStore::update_global(cx, |store, _| { + store.register_setting::(); + }); + feature_flags::FeatureFlagStore::init(cx); + }); + + assert!(!cx.update(|cx| harness.connection.supports_logout(cx))); + let unsupported_logout = cx.update(|cx| harness.connection.logout(cx)); + let error = unsupported_logout + .await + .expect_err("logout should be rejected when the agent does not advertise support"); + assert_eq!(error.to_string(), "Logout is not supported by this agent."); + assert_eq!(harness.logout_count.load(Ordering::SeqCst), 0); + + Rc::get_mut(&mut harness.connection) + .expect("test harness should own the only ACP connection handle") + .agent_capabilities + .auth = acp::AgentAuthCapabilities::new().logout(acp::LogoutCapabilities::new()); + + cx.update(|cx| { + settings::SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |content| { + content + .feature_flags + .get_or_insert_default() + .insert("acp-beta".to_string(), "off".to_string()); + }); + }); + }); + assert!(!cx.update(|cx| harness.connection.supports_logout(cx))); + let disabled_logout = cx.update(|cx| harness.connection.logout(cx)); + let error = disabled_logout + .await + .expect_err("logout should be rejected when acp-beta is disabled"); + assert_eq!(error.to_string(), "Logout is not supported by this agent."); + assert_eq!(harness.logout_count.load(Ordering::SeqCst), 0); + + cx.update(|cx| { + settings::SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |content| { + content + .feature_flags + .get_or_insert_default() + .insert("acp-beta".to_string(), "on".to_string()); + }); + }); + }); + assert!(cx.update(|cx| harness.connection.supports_logout(cx))); + cx.update(|cx| harness.connection.logout(cx)) + .await + .expect("logout should be sent when the agent advertises support"); + assert_eq!(harness.logout_count.load(Ordering::SeqCst), 1); + } + #[cfg(not(windows))] #[gpui::test] async fn startup_returns_error_when_agent_exits_before_initialization( diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 88c5baa2b22..635ad042b84 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -23,9 +23,9 @@ use settings::{LanguageModelProviderSetting, LanguageModelSelection}; use zed_actions::{ DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize, agent::{ - AddSelectionToThread, ConflictContent, OpenSettings, ReauthenticateAgent, ResetAgentZoom, - ResetOnboarding, ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent, - ReviewBranchDiff, + AddSelectionToThread, ConflictContent, LogoutAgent, OpenSettings, ReauthenticateAgent, + ResetAgentZoom, ResetOnboarding, ResolveConflictedFilesWithAgent, + ResolveConflictsWithAgent, ReviewBranchDiff, }, assistant::{FocusAgent, OpenRulesLibrary, Toggle, ToggleFocus}, }; @@ -4161,6 +4161,9 @@ impl AgentPanel { } _ => false, }; + let supports_logout = self + .active_conversation_view() + .is_some_and(|conversation_view| conversation_view.read(cx).supports_logout(cx)); PopoverMenu::new("agent-options-menu") .trigger_with_tooltip( @@ -4239,6 +4242,9 @@ impl AgentPanel { if has_auth_methods { menu = menu.action("Reauthenticate", Box::new(ReauthenticateAgent)) } + if supports_logout { + menu = menu.action("Log Out", Box::new(LogoutAgent)) + } menu })) @@ -5063,6 +5069,13 @@ impl Render for AgentPanel { }) } })) + .on_action(cx.listener(|this, _: &LogoutAgent, window, cx| { + if let Some(conversation_view) = this.active_conversation_view() { + conversation_view.update(cx, |conversation_view, cx| { + conversation_view.logout(window, cx) + }) + } + })) .child(self.render_toolbar(window, cx)) .children(self.render_new_user_onboarding(window, cx)) .map(|parent| match self.visible_surface() { diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 394281abad5..6ecb85267a7 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -519,6 +519,12 @@ impl ConversationView { }) } + pub fn supports_logout(&self, cx: &App) -> bool { + self.as_connected().is_some_and(|connected| { + connected.auth_state.is_ok() && connected.connection.supports_logout(cx) + }) + } + pub fn active_thread(&self) -> Option<&Entity> { match &self.server_state { ServerState::Connected(connected) => connected.active_view(), @@ -2910,6 +2916,54 @@ impl ConversationView { Self::handle_auth_required(this, AuthRequired::new(), agent_id, connection, window, cx); }) } + + pub(crate) fn logout(&mut self, window: &mut Window, cx: &mut Context) { + if !self.supports_logout(cx) { + return; + } + + if let Some(active) = self.root_thread_view() { + active.update(cx, |active, cx| active.clear_thread_error(cx)); + } + let Some(connection) = self + .as_connected() + .map(|connected| connected.connection.clone()) + else { + return; + }; + let logout = connection.logout(cx); + self.auth_task = Some(cx.spawn_in(window, { + async move |this, cx| { + let result = logout.await; + this.update_in(cx, |this, window, cx| { + if let Err(err) = result { + if let Some(active) = this.root_thread_view() { + active.update(cx, |active, cx| active.handle_thread_error(err, cx)); + } + } else if let Some(connected) = this.as_connected_mut() { + connected.auth_state = AuthState::Unauthenticated { + description: None, + configuration_view: None, + pending_auth_method: None, + _subscription: None, + }; + if let Some(view) = connected.active_view() + && view + .read(cx) + .message_editor + .focus_handle(cx) + .is_focused(window) + { + this.focus_handle.focus(window, cx) + } + cx.notify(); + } + drop(this.auth_task.take()); + }) + .ok(); + } + })); + } } fn loading_contents_spinner(size: IconSize) -> AnyElement { @@ -3788,7 +3842,7 @@ pub(crate) mod tests { // When new_session returns AuthRequired, the server should transition // to Connected + Unauthenticated rather than getting stuck in Loading. - conversation_view.read_with(cx, |view, _cx| { + conversation_view.read_with(cx, |view, cx| { let connected = view .as_connected() .expect("Should be in Connected state even though auth is required"); @@ -3796,6 +3850,10 @@ pub(crate) mod tests { !connected.auth_state.is_ok(), "Auth state should be Unauthenticated" ); + assert!( + !view.supports_logout(cx), + "Logout should be hidden while unauthenticated" + ); assert!( connected.active_id.is_none(), "There should be no active thread since no session was created" @@ -3831,6 +3889,10 @@ pub(crate) mod tests { .as_connected() .expect("Should still be in Connected state after auth"); assert!(connected.auth_state.is_ok(), "Auth state should be Ok"); + assert!( + view.supports_logout(cx), + "Logout should be available after authentication" + ); assert!( connected.active_id.is_some(), "There should be an active thread after successful auth" @@ -3849,6 +3911,23 @@ pub(crate) mod tests { "The new thread should have no errors" ); }); + + conversation_view.update_in(cx, |view, window, cx| view.logout(window, cx)); + cx.run_until_parked(); + + conversation_view.read_with(cx, |view, cx| { + let connected = view + .as_connected() + .expect("Should still be in Connected state after logout"); + assert!( + !connected.auth_state.is_ok(), + "Auth state should be Unauthenticated after logout" + ); + assert!( + !view.supports_logout(cx), + "Logout should be hidden after logout" + ); + }); } #[gpui::test] @@ -4910,6 +4989,15 @@ pub(crate) mod tests { } } + fn supports_logout(&self, _cx: &App) -> bool { + true + } + + fn logout(&self, _cx: &mut App) -> Task> { + *self.authenticated.lock() = false; + Task::ready(Ok(())) + } + fn prompt( &self, _id: acp_thread::UserMessageId, diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 0803f9edd94..bcfaad5e8ab 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -505,6 +505,8 @@ pub mod agent { ToggleModelSelector, /// Triggers re-authentication on Gemini ReauthenticateAgent, + /// Logs out of the current external agent + LogoutAgent, /// Add the current selection as context for threads in the agent panel. #[action(deprecated_aliases = ["assistant::QuoteSelection", "agent::QuoteSelection"])] AddSelectionToThread, From bfe914beec96ca0c509f6ffb03f74c5a737b4620 Mon Sep 17 00:00:00 2001 From: Xiaobo Liu Date: Mon, 18 May 2026 15:45:02 +0800 Subject: [PATCH 008/289] editor: Remove redundant allocation when merging highlight ranges (#57034) Reduce one Vec clone + collect allocation per frame Release Notes: - N/A or Added/Fixed/Improved ... --------- Signed-off-by: Xiaobo Liu --- crates/editor/src/element.rs | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index bca778b79be..e04161b6c8a 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -3682,7 +3682,7 @@ impl EditorElement { fn bg_segments_per_row( rows: Range, selections: &[(PlayerColor, Vec)], - highlight_ranges: &[(Range, Hsla)], + highlight_ranges: impl IntoIterator, Hsla)>, base_background: Hsla, ) -> Vec, Hsla)>> { if rows.start >= rows.end { @@ -3692,7 +3692,7 @@ impl EditorElement { // We don't actually know what color is behind this editor. return Vec::new(); } - let highlight_iter = highlight_ranges.iter().cloned(); + let highlight_iter = highlight_ranges.into_iter(); let selection_iter = selections.iter().flat_map(|(player_color, layouts)| { let color = player_color.selection; layouts.iter().filter_map(move |selection_layout| { @@ -10410,20 +10410,14 @@ impl Element for EditorElement { cx, ); - let merged_highlighted_ranges = - if let Some((_, colors)) = document_colors.as_ref() { - &highlighted_ranges - .clone() - .into_iter() - .chain(colors.clone()) - .collect() - } else { - &highlighted_ranges - }; let bg_segments_per_row = Self::bg_segments_per_row( start_row..end_row, &selections, - &merged_highlighted_ranges, + highlighted_ranges.iter().cloned().chain( + document_colors + .iter() + .flat_map(|(_, colors)| colors.iter().cloned()), + ), self.style.background, ); @@ -13849,7 +13843,7 @@ mod tests { let result = EditorElement::bg_segments_per_row( DisplayRow(0)..DisplayRow(5), &selections, - &[], + [].into_iter(), base_bg, ); @@ -13898,7 +13892,7 @@ mod tests { let result = EditorElement::bg_segments_per_row( DisplayRow(0)..DisplayRow(4), &selections, - &[], + [].into_iter(), base_bg, ); From 5aeb8a7e0fabfe27f1518e01874d093b9bbce618 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 18 May 2026 10:41:02 +0200 Subject: [PATCH 009/289] eval_cli: Wait for model discovery (#57038) Given the model list is dynamic now, we need a wait Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/eval_cli/src/main.rs | 227 +++++++++++++++++++++--- crates/eval_cli/zed_eval/pyproject.toml | 2 +- 2 files changed, 203 insertions(+), 26 deletions(-) diff --git a/crates/eval_cli/src/main.rs b/crates/eval_cli/src/main.rs index e77e75bc879..3a323776ef4 100644 --- a/crates/eval_cli/src/main.rs +++ b/crates/eval_cli/src/main.rs @@ -47,7 +47,10 @@ use feature_flags::FeatureFlagAppExt as _; use futures::{FutureExt, select_biased}; use gpui::{AppContext as _, AsyncApp, Entity, UpdateGlobal}; -use language_model::{LanguageModelRegistry, SelectedModel}; +use language_model::{ + ANTHROPIC_PROVIDER_ID, LanguageModel, LanguageModelId, LanguageModelProviderId, + LanguageModelRegistry, SelectedModel, +}; use project::Project; use settings::SettingsStore; use util::path_list::PathList; @@ -128,6 +131,8 @@ const EXIT_OK: i32 = 0; const EXIT_ERROR: i32 = 1; const EXIT_TIMEOUT: i32 = 2; const EXIT_INTERRUPTED: i32 = 3; +const MODEL_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(30); +const MODEL_DISCOVERY_POLL_INTERVAL: Duration = Duration::from_millis(100); static TERMINATED: AtomicBool = AtomicBool::new(false); @@ -268,6 +273,182 @@ fn read_instruction(args: &Args) -> Result { Ok(text) } +async fn wait_for_model(selected: &SelectedModel, cx: &mut AsyncApp) -> Result<()> { + let started_at = Instant::now(); + + loop { + let found = cx.update(|cx| find_available_model(selected, cx).is_some()); + if found { + return Ok(()); + } + + cx.update(|cx| ensure_provider_authenticated(selected, cx))?; + + let selected_provider_has_models = cx.update(|cx| { + LanguageModelRegistry::global(cx) + .read(cx) + .available_models(cx) + .any(|model| model.provider_id() == selected.provider) + }); + let should_wait_for_discovery = + selected.provider == ANTHROPIC_PROVIDER_ID || !selected_provider_has_models; + + if !should_wait_for_discovery || started_at.elapsed() >= MODEL_DISCOVERY_TIMEOUT { + return Err(cx.update(|cx| model_not_found_error(&selected_model_name(selected), cx))); + } + + cx.background_executor() + .timer(MODEL_DISCOVERY_POLL_INTERVAL) + .await; + } +} + +fn ensure_provider_authenticated(selected: &SelectedModel, cx: &gpui::App) -> Result<()> { + let registry = LanguageModelRegistry::global(cx); + let provider = registry + .read(cx) + .provider(&selected.provider) + .ok_or_else(|| anyhow::anyhow!("Provider {} not found", selected.provider.0))?; + + anyhow::ensure!( + provider.is_authenticated(cx), + "Provider {} is not authenticated", + selected.provider.0 + ); + + Ok(()) +} + +fn find_available_model( + selected: &SelectedModel, + cx: &gpui::App, +) -> Option> { + let registry = LanguageModelRegistry::global(cx); + let models = registry.read(cx).available_models(cx).collect::>(); + + if let Some(model) = models + .iter() + .find(|model| model.provider_id() == selected.provider && model.id() == selected.model) + { + return Some(model.clone()); + } + + models + .into_iter() + .filter(|model| { + model.provider_id() == selected.provider + && model_id_matches_selected(&model.provider_id(), &model.id(), &selected.model) + }) + .max_by(|left, right| left.id().0.to_string().cmp(&right.id().0.to_string())) +} + +fn model_id_matches_selected( + provider_id: &LanguageModelProviderId, + available: &LanguageModelId, + selected: &LanguageModelId, +) -> bool { + if available == selected { + return true; + } + + if provider_id != &ANTHROPIC_PROVIDER_ID { + return false; + } + + anthropic_model_ids_match(available.0.as_ref(), selected.0.as_ref()) +} + +fn anthropic_model_ids_match(available: &str, selected: &str) -> bool { + let available = anthropic_model_alias_base(available); + let selected = anthropic_model_alias_base(selected); + + available == selected || anthropic_dated_model_id_matches_base(available, selected) +} + +fn anthropic_model_alias_base(mut model_id: &str) -> &str { + if let Some(stripped) = model_id.strip_suffix("-latest") { + model_id = stripped; + } + if let Some(stripped) = model_id.strip_suffix("-thinking") { + model_id = stripped; + } + if let Some(stripped) = model_id.strip_suffix("-1m-context") { + model_id = stripped; + } + model_id +} + +fn anthropic_dated_model_id_matches_base(available: &str, selected: &str) -> bool { + let Some(suffix) = available.strip_prefix(selected) else { + return false; + }; + let Some(date) = suffix.strip_prefix('-') else { + return false; + }; + + date.len() == 8 && date.chars().all(|character| character.is_ascii_digit()) +} + +fn selected_model_name(selected: &SelectedModel) -> String { + format!("{}/{}", selected.provider.0, selected.model.0) +} + +fn model_not_found_error(model_name: &str, cx: &gpui::App) -> anyhow::Error { + let available = LanguageModelRegistry::global(cx) + .read(cx) + .available_models(cx) + .map(|model| format!("{}/{}", model.provider_id().0, model.id().0)) + .collect::>(); + let available = if available.is_empty() { + "(none)".to_string() + } else { + available.join(", ") + }; + + anyhow::anyhow!("Model {model_name} not found. Available: {available}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn anthropic_latest_alias_matches_listed_base_model() { + assert!(model_id_matches_selected( + &ANTHROPIC_PROVIDER_ID, + &LanguageModelId("claude-sonnet-4-6".into()), + &LanguageModelId("claude-sonnet-4-6-latest".into()), + )); + } + + #[test] + fn anthropic_thinking_alias_matches_listed_base_model() { + assert!(model_id_matches_selected( + &ANTHROPIC_PROVIDER_ID, + &LanguageModelId("claude-sonnet-4-6".into()), + &LanguageModelId("claude-sonnet-4-6-1m-context-thinking-latest".into()), + )); + } + + #[test] + fn anthropic_latest_alias_matches_listed_dated_model() { + assert!(model_id_matches_selected( + &ANTHROPIC_PROVIDER_ID, + &LanguageModelId("claude-sonnet-4-6-20260518".into()), + &LanguageModelId("claude-sonnet-4-6-latest".into()), + )); + } + + #[test] + fn non_anthropic_models_require_exact_ids() { + assert!(!model_id_matches_selected( + &LanguageModelProviderId("other".into()), + &LanguageModelId("claude-sonnet-4-6".into()), + &LanguageModelId("claude-sonnet-4-6-latest".into()), + )); + } +} + async fn run_agent( app_state: &Arc, workdir: &std::path::Path, @@ -279,37 +460,33 @@ async fn run_agent( output_dir: Option<&std::path::Path>, cx: &mut AsyncApp, ) -> (Result, Option) { + let selected = match SelectedModel::from_str(model_name).map_err(|e| anyhow::anyhow!("{e}")) { + Ok(selected) => selected, + Err(e) => return (Err(e), None), + }; + + if let Err(e) = wait_for_model(&selected, cx).await { + return (Err(e), None); + } + let setup_result: Result<()> = cx.update(|cx| { - let selected = SelectedModel::from_str(model_name).map_err(|e| anyhow::anyhow!("{e}"))?; let registry = LanguageModelRegistry::global(cx); - let model = registry + let model = find_available_model(&selected, cx) + .ok_or_else(|| model_not_found_error(model_name, cx))?; + let provider = registry .read(cx) - .available_models(cx) - .find(|m| m.id() == selected.model && m.provider_id() == selected.provider) - .ok_or_else(|| { - let available = registry - .read(cx) - .available_models(cx) - .map(|m| format!("{}/{}", m.provider_id().0, m.id().0)) - .collect::>() - .join(", "); - anyhow::anyhow!("Model {model_name} not found. Available: {available}") - })?; + .provider(&model.provider_id()) + .context("Provider not found")?; let supports_thinking = model.supports_thinking(); + let model_id = model.id().0.to_string(); registry.update(cx, |registry, cx| { registry.set_default_model( - Some(language_model::ConfiguredModel { - provider: registry - .provider(&model.provider_id()) - .context("Provider not found")?, - model, - }), + Some(language_model::ConfiguredModel { provider, model }), cx, ); - anyhow::Ok(()) - })?; + }); let enable_thinking = thinking_override.unwrap_or(supports_thinking); let effort = if enable_thinking { @@ -321,7 +498,6 @@ async fn run_agent( "null".to_string() }; let provider_id = selected.provider.0.to_string(); - let model_id = selected.model.0.to_string(); SettingsStore::update_global(cx, |store, cx| { let settings = format!( r#"{{ @@ -339,8 +515,9 @@ async fn run_agent( }}" "# ); - store.set_user_settings(&settings, cx).ok(); - }); + store.set_user_settings(&settings, cx).result() + }) + .context("updating agent settings")?; anyhow::Ok(()) }); diff --git a/crates/eval_cli/zed_eval/pyproject.toml b/crates/eval_cli/zed_eval/pyproject.toml index 10e72028a5e..07a61d7a13b 100644 --- a/crates/eval_cli/zed_eval/pyproject.toml +++ b/crates/eval_cli/zed_eval/pyproject.toml @@ -3,7 +3,7 @@ name = "zed-eval" version = "0.1.0" description = "Harbor agent wrapper for Zed's eval-cli" requires-python = ">=3.12" -dependencies = ["harbor==0.6.4"] +dependencies = ["harbor==0.7.0"] [build-system] requires = ["setuptools"] From 1dc07b40b99be515c3133882ab387b4f21d717ba Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 18 May 2026 11:57:08 +0200 Subject: [PATCH 010/289] copilot: Fix issue when switching between OpenAI and Anthropic models (#56655) We were storing reasoning output inside `RedactedThinking` which causes issues when switching mid-turn from an OpenAI to an Anthropic model. This implementation fixes this by storing it inside `reasoning_details`, which matches our responses implementation in `open_ai.rs` See https://github.com/microsoft/vscode-copilot-chat/blob/main/src/platform/endpoint/node/responsesApi.ts For whatever reason the copilot chat extension sets `summary: []`, this is what our implementation does too Closes #56385 Release Notes: - Fixed an issue where the agent would error when using Copilot as a provider and switching between OpenAI and Anthropic models --- crates/copilot_chat/src/responses.rs | 19 +- .../src/provider/copilot_chat.rs | 251 ++++++++++++++++-- 2 files changed, 245 insertions(+), 25 deletions(-) diff --git a/crates/copilot_chat/src/responses.rs b/crates/copilot_chat/src/responses.rs index 1241a76fb14..8e32e272d88 100644 --- a/crates/copilot_chat/src/responses.rs +++ b/crates/copilot_chat/src/responses.rs @@ -139,12 +139,7 @@ pub enum ResponseInputItem { #[serde(skip_serializing_if = "Option::is_none")] status: Option, }, - Reasoning { - #[serde(skip_serializing_if = "Option::is_none")] - id: Option, - summary: Vec, - encrypted_content: String, - }, + Reasoning(ResponseReasoningInputItem), } #[derive(Deserialize, Debug, Clone)] @@ -162,7 +157,17 @@ pub struct IncompleteDetails { pub reason: Option, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct ResponseReasoningInputItem { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(default)] + pub summary: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encrypted_content: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct ResponseReasoningItem { #[serde(rename = "type")] pub kind: String, diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index cf8f11a5bb0..d9db887e12d 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -659,12 +659,14 @@ pub fn map_to_language_model_completion_events( pub struct CopilotResponsesEventMapper { pending_stop_reason: Option, + reasoning_items: Vec, } impl CopilotResponsesEventMapper { pub fn new() -> Self { Self { pending_stop_reason: None, + reasoning_items: Vec::new(), } } @@ -740,13 +742,13 @@ impl CopilotResponsesEventMapper { events } copilot_responses::ResponseOutputItem::Reasoning { + id, summary, encrypted_content, - .. } => { let mut events = Vec::new(); - if let Some(blocks) = summary { + if let Some(blocks) = summary.as_ref() { let mut text = String::new(); for block in blocks { text.push_str(&block.text); @@ -759,8 +761,10 @@ impl CopilotResponsesEventMapper { } } - if let Some(data) = encrypted_content { - events.push(Ok(LanguageModelCompletionEvent::RedactedThinking { data })); + if let Some(reasoning_item) = + reasoning_input_item_from_output(&id, encrypted_content) + { + events.extend(self.capture_reasoning_item(reasoning_item)); } events @@ -769,6 +773,7 @@ impl CopilotResponsesEventMapper { copilot_responses::StreamEvent::Completed { response } => { let mut events = Vec::new(); + events.extend(self.capture_reasoning_items_from_output(&response.output)); if let Some(usage) = response.usage { events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage { input_tokens: usage.input_tokens.unwrap_or(0), @@ -800,6 +805,7 @@ impl CopilotResponsesEventMapper { }; let mut events = Vec::new(); + events.extend(self.capture_reasoning_items_from_output(&response.output)); if let Some(usage) = response.usage { events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage { input_tokens: usage.input_tokens.unwrap_or(0), @@ -840,6 +846,116 @@ impl CopilotResponsesEventMapper { | copilot_responses::StreamEvent::Unknown => Vec::new(), } } + + fn capture_reasoning_items_from_output( + &mut self, + output: &[copilot_responses::ResponseOutputItem], + ) -> Vec> { + let mut events = Vec::new(); + for item in output { + if let copilot_responses::ResponseOutputItem::Reasoning { + id, + summary: _, + encrypted_content, + } = item + { + if let Some(reasoning_item) = + reasoning_input_item_from_output(&id, encrypted_content.clone()) + { + events.extend(self.capture_reasoning_item(reasoning_item)); + } + } + } + events + } + + fn capture_reasoning_item( + &mut self, + reasoning_item: copilot_responses::ResponseReasoningInputItem, + ) -> Vec> { + if self.reasoning_items.contains(&reasoning_item) { + return Vec::new(); + } + + if let Some(id) = reasoning_item.id.as_ref() + && let Some(existing_reasoning_item) = self + .reasoning_items + .iter_mut() + .find(|existing_reasoning_item| existing_reasoning_item.id.as_ref() == Some(id)) + { + *existing_reasoning_item = reasoning_item; + } else { + self.reasoning_items.push(reasoning_item); + } + + self.emit_response_message_metadata() + } + + fn emit_response_message_metadata( + &self, + ) -> Vec> { + let details = serde_json::to_value(CopilotResponseMessageMetadata { + reasoning_items: self.reasoning_items.clone(), + }); + + match details { + Ok(details) => vec![Ok(LanguageModelCompletionEvent::ReasoningDetails(details))], + Err(error) => vec![Err(LanguageModelCompletionError::Other(anyhow!(error)))], + } + } +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct CopilotResponseMessageMetadata { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + reasoning_items: Vec, +} + +fn append_reasoning_details_to_response_items( + reasoning_details: Option<&serde_json::Value>, + replayed_reasoning_item_indexes: &mut HashMap, + input_items: &mut Vec, +) { + let Some(reasoning_details) = reasoning_details else { + return; + }; + + let Some(metadata) = + serde_json::from_value::(reasoning_details.clone()).ok() + else { + return; + }; + + for mut reasoning_item in metadata.reasoning_items { + reasoning_item.summary.clear(); + if let Some(id) = reasoning_item.id.as_ref() { + if let Some(index) = replayed_reasoning_item_indexes.get(id) { + input_items[*index] = + copilot_responses::ResponseInputItem::Reasoning(reasoning_item); + return; + } + + replayed_reasoning_item_indexes.insert(id.clone(), input_items.len()); + } + + input_items.push(copilot_responses::ResponseInputItem::Reasoning( + reasoning_item, + )); + } +} + +fn reasoning_input_item_from_output( + id: &str, + encrypted_content: Option, +) -> Option { + if encrypted_content.is_none() { + return None; + } + Some(copilot_responses::ResponseReasoningInputItem { + id: Some(id.to_string()), + summary: Vec::new(), + encrypted_content, + }) } fn into_copilot_chat( @@ -1100,6 +1216,7 @@ fn into_copilot_responses( } = request; let mut input_items: Vec = Vec::new(); + let mut replayed_reasoning_item_indexes = HashMap::default(); for message in messages { match message.role { @@ -1181,6 +1298,12 @@ fn into_copilot_responses( } Role::Assistant => { + append_reasoning_details_to_response_items( + message.reasoning_details.as_ref(), + &mut replayed_reasoning_item_indexes, + &mut input_items, + ); + for content in &message.content { if let MessageContent::ToolUse(tool_use) = content { input_items.push(responses::ResponseInputItem::FunctionCall { @@ -1193,16 +1316,6 @@ fn into_copilot_responses( } } - for content in &message.content { - if let MessageContent::RedactedThinking(data) = content { - input_items.push(responses::ResponseInputItem::Reasoning { - id: None, - summary: Vec::new(), - encrypted_content: data.clone(), - }); - } - } - let mut parts: Vec = Vec::new(); for content in &message.content { match content { @@ -1297,6 +1410,7 @@ mod tests { use super::*; use copilot_chat::responses; use futures::StreamExt; + use serde_json::json; fn map_events(events: Vec) -> Vec { futures::executor::block_on(async { @@ -1310,6 +1424,37 @@ mod tests { }) } + fn test_responses_model() -> CopilotChatModel { + serde_json::from_value(json!({ + "billing": { + "is_premium": false, + "multiplier": 1.0 + }, + "capabilities": { + "family": "test", + "limits": { + "max_context_window_tokens": 128000, + "max_output_tokens": 4096 + }, + "supports": { + "streaming": true, + "tool_calls": true, + "parallel_tool_calls": false, + "vision": false + }, + "type": "chat" + }, + "id": "test-model", + "is_chat_default": false, + "is_chat_fallback": false, + "model_picker_enabled": true, + "name": "Test Model", + "vendor": "OpenAI", + "supported_endpoints": ["/responses"] + })) + .expect("valid test model") + } + #[test] fn responses_stream_maps_text_and_usage() { let events = vec![ @@ -1435,10 +1580,80 @@ mod tests { mapped[0], LanguageModelCompletionEvent::Thinking { ref text, signature: None } if text == "Chain" )); - assert!(matches!( - mapped[1], - LanguageModelCompletionEvent::RedactedThinking { ref data } if data == "ENC" - )); + match &mapped[1] { + LanguageModelCompletionEvent::ReasoningDetails(details) => assert_eq!( + details, + &json!({ + "reasoning_items": [ + { + "id": "r1", + "summary": [], + "encrypted_content": "ENC" + } + ] + }) + ), + other => panic!("expected reasoning details, got {other:?}"), + } + } + + #[test] + fn into_copilot_responses_replays_reasoning_details() { + let model = test_responses_model(); + let request = LanguageModelRequest { + messages: vec![LanguageModelRequestMessage { + role: Role::Assistant, + content: vec![ + MessageContent::RedactedThinking("legacy-redacted".into()), + MessageContent::Text("Done".into()), + ], + cache: false, + reasoning_details: Some(json!({ + "reasoning_items": [ + { + "id": "r1", + "summary": [ + { + "type": "summary_text", + "text": "Chain" + } + ], + "encrypted_content": "ENC" + } + ] + })), + }], + ..Default::default() + }; + + let serialized = serde_json::to_value(into_copilot_responses(&model, request)) + .expect("serialized request"); + let input = serialized["input"].as_array().expect("input items"); + + assert_eq!( + input.first(), + Some(&json!({ + "type": "reasoning", + "id": "r1", + "summary": [], + "encrypted_content": "ENC" + })) + ); + assert_eq!( + input.get(1), + Some(&json!({ + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Done" + } + ], + "status": "completed" + })) + ); + assert!(!serialized.to_string().contains("legacy-redacted")); } #[test] From e1f13a84b7688429e847d9d1fb9ad042a39ba2aa Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 18 May 2026 13:18:05 +0200 Subject: [PATCH 011/289] sidebar: Persist terminal threads in sidebar metadata (#56966) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Persist Terminal Threads across reloads --------- Co-authored-by: Bennet Bo Fenner --- crates/agent_ui/src/agent_panel.rs | 455 +++++- crates/agent_ui/src/agent_ui.rs | 2 + crates/agent_ui/src/conversation_view.rs | 1 + .../src/terminal_thread_metadata_store.rs | 617 ++++++++ crates/agent_ui/src/test_support.rs | 1 + crates/agent_ui/src/thread_metadata_store.rs | 8 +- crates/sidebar/src/sidebar.rs | 1270 +++++++++++++---- crates/sidebar/src/sidebar_tests.rs | 970 ++++++++++++- crates/sidebar/src/thread_switcher.rs | 37 +- 9 files changed, 3007 insertions(+), 354 deletions(-) create mode 100644 crates/agent_ui/src/terminal_thread_metadata_store.rs diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 635ad042b84..8401b521abd 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -34,6 +34,7 @@ use crate::ExpandMessageEditor; use crate::ManageProfiles; use crate::agent_connection_store::AgentConnectionStore; use crate::completion_provider::AgentContextSource; +use crate::terminal_thread_metadata_store::{TerminalThreadMetadata, TerminalThreadMetadataStore}; use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore, ThreadMetadataStoreEvent}; use crate::{ AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, Follow, @@ -110,9 +111,17 @@ impl MaxIdleRetainedThreads { pub struct TerminalId(uuid::Uuid); impl TerminalId { - fn new() -> Self { + pub(crate) fn new() -> Self { Self(uuid::Uuid::new_v4()) } + + pub(crate) fn to_key_string(self) -> String { + self.0.hyphenated().to_string() + } + + pub(crate) fn from_key_string(key: &str) -> anyhow::Result { + Ok(Self(uuid::Uuid::parse_str(key)?)) + } } impl fmt::Display for TerminalId { @@ -127,6 +136,8 @@ pub struct AgentPanelTerminalInfo { pub title: SharedString, pub created_at: DateTime, pub has_notification: bool, + pub custom_title: Option, + pub working_directory: Option, } #[derive(Serialize, Deserialize)] @@ -701,6 +712,7 @@ struct AgentTerminal { title_editor_initial_title: Option, title_editor_subscription: Option, last_known_title: String, + working_directory: Option, created_at: DateTime, has_notification: bool, notification_windows: Vec>, @@ -711,7 +723,7 @@ struct AgentTerminal { impl AgentTerminal { fn title(&self, cx: &App) -> SharedString { let view = self.view.read(cx); - if let Some(custom_title) = view.custom_title() { + let title = if let Some(custom_title) = view.custom_title() { SharedString::from(custom_title) } else { let terminal = view.terminal().read(cx); @@ -725,6 +737,12 @@ impl AgentTerminal { } else { terminal.breadcrumb_text.clone().into() } + }; + + if title.is_empty() && !self.last_known_title.is_empty() { + SharedString::from(self.last_known_title.clone()) + } else { + title } } @@ -736,6 +754,22 @@ impl AgentTerminal { } changed } + + fn refresh_metadata(&mut self, cx: &mut App) -> bool { + let title_changed = self.refresh_title(cx); + let current_working_directory = self.view.read(cx).terminal().read(cx).working_directory(); + let working_directory_changed = current_working_directory + .as_ref() + .is_some_and(|current| self.working_directory.as_ref() != Some(current)); + if working_directory_changed { + self.working_directory = current_working_directory; + } + title_changed || working_directory_changed + } + + fn custom_title(&self, cx: &App) -> Option { + self.view.read(cx).custom_title().map(SharedString::from) + } } enum BaseView { @@ -1105,9 +1139,11 @@ impl AgentPanel { cx.subscribe(&project, |this, _project, event, cx| match event { project::Event::WorktreeAdded(_) | project::Event::WorktreeRemoved(_) - | project::Event::WorktreeOrderChanged => { + | project::Event::WorktreeOrderChanged + | project::Event::WorktreePathsChanged { .. } => { this.ensure_native_agent_connection(cx); this.update_thread_work_dirs(cx); + this.persist_all_terminal_metadata(cx); cx.notify(); } _ => {} @@ -1513,6 +1549,10 @@ impl AgentPanel { self.spawn_terminal( TerminalId::new(), working_directory, + None, + None, + None, + true, true, source, window, @@ -1554,11 +1594,16 @@ impl AgentPanel { &mut self, terminal_id: TerminalId, working_directory: Option, + custom_title: Option, + initial_title: Option, + created_at: Option>, + select: bool, focus: bool, source: AgentThreadSource, window: &mut Window, cx: &mut Context, ) { + let terminal_working_directory = working_directory.clone(); let terminal_task = self.project.update(cx, |project, cx| { project.create_terminal_shell(working_directory, cx) }); @@ -1581,7 +1626,19 @@ impl AgentPanel { let terminal_view = cx.new(|cx| { TerminalView::new(terminal, workspace, workspace_id, project, window, cx) }); - this.insert_terminal(terminal_id, terminal_view, focus, source, window, cx); + this.insert_terminal( + terminal_id, + terminal_view, + terminal_working_directory, + custom_title, + initial_title, + created_at, + select, + focus, + source, + window, + cx, + ); })?; anyhow::Ok(()) }) @@ -1592,17 +1649,27 @@ impl AgentPanel { &mut self, terminal_id: TerminalId, terminal_view: Entity, + working_directory: Option, + custom_title: Option, + initial_title: Option, + created_at: Option>, + select: bool, focus: bool, source: AgentThreadSource, window: &mut Window, cx: &mut Context, ) { + if let Some(custom_title) = custom_title { + terminal_view.update(cx, |terminal_view, cx| { + terminal_view.set_custom_title(Some(custom_title.to_string()), cx); + }); + } let terminal_entity = terminal_view.read(cx).terminal().clone(); let view_subscription = cx.subscribe( &terminal_view, move |this, _terminal_view, event: &ItemEvent, cx| match event { ItemEvent::UpdateTab | ItemEvent::UpdateBreadcrumbs => { - this.refresh_terminal_title(terminal_id, cx); + this.refresh_terminal_metadata(terminal_id, cx); } ItemEvent::CloseItem | ItemEvent::Edit => {} }, @@ -1616,11 +1683,11 @@ impl AgentPanel { TerminalEvent::TitleChanged | TerminalEvent::Wakeup | TerminalEvent::BreadcrumbsChanged => { - this.refresh_terminal_title(terminal_id, cx); + this.refresh_terminal_metadata(terminal_id, cx); } TerminalEvent::Bell => this.mark_terminal_notification(terminal_id, window, cx), TerminalEvent::CloseTerminal => { - this.close_terminal(terminal_id, window, cx); + this.close_terminal_from_terminal_event(terminal_id, window, cx); } TerminalEvent::BlinkChanged(_) | TerminalEvent::SelectionsChanged @@ -1634,19 +1701,23 @@ impl AgentPanel { title_editor: None, title_editor_initial_title: None, title_editor_subscription: None, - last_known_title: String::new(), - created_at: Utc::now(), + last_known_title: initial_title + .map(|title| title.to_string()) + .unwrap_or_default(), + working_directory, + created_at: created_at.unwrap_or_else(Utc::now), has_notification: false, notification_windows: Vec::new(), notification_subscriptions: Vec::new(), _subscriptions: vec![view_subscription, terminal_subscription], }; self.set_last_created_entry_kind(AgentPanelEntryKind::Terminal, cx); - terminal.refresh_title(cx); + terminal.refresh_metadata(cx); self.terminals.insert(terminal_id, terminal); + self.persist_terminal_metadata(terminal_id, cx); self.emit_terminal_thread_started(source, cx); - if focus { - self.set_base_view(BaseView::Terminal { terminal_id }, true, window, cx); + if select { + self.set_base_view(BaseView::Terminal { terminal_id }, focus, window, cx); } cx.emit(AgentPanelEvent::EntryChanged); cx.notify(); @@ -1679,6 +1750,26 @@ impl AgentPanel { terminal_id: TerminalId, window: &mut Window, cx: &mut Context, + ) { + self.close_terminal_internal(terminal_id, true, None, window, cx); + } + + pub fn close_terminal_without_activating_draft( + &mut self, + terminal_id: TerminalId, + window: &mut Window, + cx: &mut Context, + ) { + self.close_terminal_internal(terminal_id, false, None, window, cx); + } + + fn close_terminal_internal( + &mut self, + terminal_id: TerminalId, + activate_draft_after_close: bool, + terminal_closed_metadata: Option, + window: &mut Window, + cx: &mut Context, ) { let was_active = self.active_terminal_id() == Some(terminal_id); @@ -1686,16 +1777,36 @@ impl AgentPanel { if self.terminals.remove(&terminal_id).is_none() { return; } + if let Some(store) = TerminalThreadMetadataStore::try_global(cx) { + store.update(cx, |store, cx| { + store.delete(terminal_id, cx); + }); + } if was_active { self.base_view = BaseView::Uninitialized; self.refresh_base_view_subscriptions(window, cx); - self.activate_draft(false, AgentThreadSource::AgentPanel, window, cx); + if activate_draft_after_close { + self.activate_draft(false, AgentThreadSource::AgentPanel, window, cx); + } } + if let Some(metadata) = terminal_closed_metadata { + cx.emit(AgentPanelEvent::TerminalClosed { metadata }); + } cx.emit(AgentPanelEvent::EntryChanged); cx.notify(); } + fn close_terminal_from_terminal_event( + &mut self, + terminal_id: TerminalId, + window: &mut Window, + cx: &mut Context, + ) { + let metadata = self.terminal_metadata(terminal_id, cx); + self.close_terminal_internal(terminal_id, false, metadata, window, cx); + } + fn emit_terminal_thread_started(&self, source: AgentThreadSource, cx: &App) { telemetry::event!( "Agent Thread Started", @@ -1706,15 +1817,110 @@ impl AgentPanel { ); } - fn refresh_terminal_title(&mut self, terminal_id: TerminalId, cx: &mut Context) { + fn refresh_terminal_metadata(&mut self, terminal_id: TerminalId, cx: &mut Context) { if let Some(terminal) = self.terminals.get_mut(&terminal_id) - && terminal.refresh_title(cx) + && terminal.refresh_metadata(cx) { + self.persist_terminal_metadata(terminal_id, cx); cx.emit(AgentPanelEvent::EntryChanged); cx.notify(); } } + fn persist_all_terminal_metadata(&self, cx: &mut Context) { + let terminal_ids = self.terminals.keys().copied().collect::>(); + for terminal_id in terminal_ids { + self.persist_terminal_metadata(terminal_id, cx); + } + } + + fn persist_terminal_metadata(&self, terminal_id: TerminalId, cx: &mut Context) { + let Some(store) = TerminalThreadMetadataStore::try_global(cx) else { + return; + }; + let Some(metadata) = self.terminal_metadata(terminal_id, cx) else { + return; + }; + store.update(cx, |store, cx| { + store.save(metadata, cx); + }); + } + + fn terminal_metadata( + &self, + terminal_id: TerminalId, + cx: &App, + ) -> Option { + let terminal = self.terminals.get(&terminal_id)?; + let project = self.project.read(cx); + Some(TerminalThreadMetadata { + terminal_id, + title: terminal.title(cx), + custom_title: terminal.custom_title(cx), + created_at: terminal.created_at, + worktree_paths: project.worktree_paths(cx), + remote_connection: project.remote_connection_options(cx), + working_directory: terminal.working_directory.clone(), + }) + } + + pub fn restore_terminal( + &mut self, + metadata: TerminalThreadMetadata, + focus: bool, + source: AgentThreadSource, + workspace: Option<&Workspace>, + window: &mut Window, + cx: &mut Context, + ) { + if self.has_terminal(metadata.terminal_id) { + self.activate_terminal(metadata.terminal_id, focus, window, cx); + return; + } + + if !self.supports_terminal(cx) { + return; + } + + let working_directory = self.terminal_restore_working_directory(&metadata, workspace, cx); + let initial_title = Self::terminal_restore_initial_title(&metadata); + self.spawn_terminal( + metadata.terminal_id, + working_directory, + metadata.custom_title.clone(), + initial_title, + Some(metadata.created_at), + true, + focus, + source, + window, + cx, + ); + } + + fn terminal_restore_working_directory( + &self, + metadata: &TerminalThreadMetadata, + workspace: Option<&Workspace>, + cx: &App, + ) -> Option { + metadata + .working_directory + .clone() + .or_else(|| { + workspace + .and_then(|workspace| terminal_view::default_working_directory(workspace, cx)) + }) + .or_else(|| self.default_terminal_working_directory(cx)) + } + + fn terminal_restore_initial_title(metadata: &TerminalThreadMetadata) -> Option { + metadata + .custom_title + .clone() + .or_else(|| (!metadata.title.is_empty()).then(|| metadata.title.clone())) + } + fn edit_terminal_title( &mut self, terminal_id: TerminalId, @@ -2430,6 +2636,8 @@ impl AgentPanel { title: terminal.title(cx), created_at: terminal.created_at, has_notification: terminal.has_notification, + custom_title: terminal.custom_title(cx), + working_directory: terminal.working_directory.clone(), }) .collect() } @@ -3693,6 +3901,7 @@ pub enum AgentPanelEvent { ActiveViewChanged, ActiveViewFocused, EntryChanged, + TerminalClosed { metadata: TerminalThreadMetadata }, ThreadInteracted { thread_id: ThreadId }, } @@ -5294,6 +5503,70 @@ impl AgentPanel { cx: &mut Context, ) -> Result { let terminal_id = TerminalId::new(); + self.insert_display_only_terminal( + terminal_id, + None, + Some(SharedString::from(title.into())), + None, + None, + focus, + focus, + AgentThreadSource::AgentPanel, + window, + cx, + )?; + Ok(terminal_id) + } + + #[cfg(any(test, feature = "test-support"))] + pub fn restore_test_terminal( + &mut self, + metadata: TerminalThreadMetadata, + focus: bool, + source: AgentThreadSource, + workspace: Option<&Workspace>, + window: &mut Window, + cx: &mut Context, + ) -> Result<()> { + if self.has_terminal(metadata.terminal_id) { + self.activate_terminal(metadata.terminal_id, focus, window, cx); + return Ok(()); + } + + if !self.supports_terminal(cx) { + return Ok(()); + } + + let working_directory = self.terminal_restore_working_directory(&metadata, workspace, cx); + let initial_title = Self::terminal_restore_initial_title(&metadata); + self.insert_display_only_terminal( + metadata.terminal_id, + working_directory, + metadata.custom_title.clone(), + initial_title, + Some(metadata.created_at), + true, + focus, + source, + window, + cx, + ) + } + + #[cfg(any(test, feature = "test-support"))] + fn insert_display_only_terminal( + &mut self, + terminal_id: TerminalId, + working_directory: Option, + custom_title: Option, + initial_title: Option, + created_at: Option>, + select: bool, + focus: bool, + source: AgentThreadSource, + window: &mut Window, + cx: &mut Context, + ) -> Result<()> { let settings = TerminalSettings::get_global(cx).clone(); let path_style = self.project.read(cx).path_style(cx); let builder = terminal::TerminalBuilder::new_display_only( @@ -5315,18 +5588,20 @@ impl AgentPanel { cx, ) }); - terminal_view.update(cx, |terminal_view, cx| { - terminal_view.set_custom_title(Some(title.into()), cx); - }); self.insert_terminal( terminal_id, terminal_view, + working_directory, + custom_title, + initial_title, + created_at, + select, focus, - AgentThreadSource::AgentPanel, + source, window, cx, ); - Ok(terminal_id) + Ok(()) } #[cfg(any(test, feature = "test-support"))] @@ -5342,6 +5617,20 @@ impl AgentPanel { cx.emit(TerminalEvent::Bell); }); } + + #[cfg(any(test, feature = "test-support"))] + pub fn emit_test_terminal_close(&mut self, terminal_id: TerminalId, cx: &mut Context) { + let Some(terminal_entity) = self + .terminals + .get(&terminal_id) + .map(|terminal| terminal.view.read(cx).terminal().clone()) + else { + return; + }; + terminal_entity.update(cx, |_terminal, cx| { + cx.emit(TerminalEvent::CloseTerminal); + }); + } } #[cfg(test)] @@ -5360,7 +5649,7 @@ mod tests { use fs::FakeFs; use gpui::{App, TestAppContext, VisualTestContext}; use parking_lot::Mutex; - use project::Project; + use project::{Project, WorktreePaths}; use std::any::Any; use serde_json::json; @@ -6959,6 +7248,39 @@ mod tests { }); } + #[gpui::test] + async fn test_terminal_close_event_closes_without_sidebar(cx: &mut TestAppContext) { + let (panel, mut cx) = setup_panel(cx).await; + cx.update(|_, cx| { + TerminalThreadMetadataStore::init_global(cx); + }); + + let terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + panel.update(&mut cx, |panel, cx| { + panel.emit_test_terminal_close(terminal_id, cx); + }); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, _cx| { + assert!(!panel.has_terminal(terminal_id)); + }); + cx.update(|_, cx| { + assert!( + TerminalThreadMetadataStore::global(cx) + .read(cx) + .entry(terminal_id) + .is_none(), + "terminal metadata should be deleted by the fallback close" + ); + }); + } + #[gpui::test] async fn test_new_thread_dismisses_settings_overlay(cx: &mut TestAppContext) { let (panel, mut cx) = setup_panel(cx).await; @@ -7117,6 +7439,95 @@ mod tests { }); } + #[gpui::test] + async fn test_restored_terminal_uses_metadata_title_until_shell_title_arrives( + cx: &mut TestAppContext, + ) { + let (panel, mut cx) = setup_panel(cx).await; + let terminal_id = TerminalId::new(); + let now = Utc::now(); + let metadata = TerminalThreadMetadata { + terminal_id, + title: "Persisted Shell Title".into(), + custom_title: None, + created_at: now, + worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from( + "/project", + )])), + remote_connection: None, + working_directory: None, + }; + + panel.update_in(&mut cx, |panel, window, cx| { + panel + .restore_test_terminal(metadata, true, AgentThreadSource::Sidebar, None, window, cx) + .expect("test terminal should be restored"); + }); + cx.run_until_parked(); + + let terminal_view = panel.read_with(&cx, |panel, cx| { + let terminals = panel.terminals(cx); + assert_eq!(terminals.len(), 1); + assert_eq!(terminals[0].title.as_ref(), "Persisted Shell Title"); + panel + .terminals + .get(&terminal_id) + .expect("terminal should be restored") + .view + .clone() + }); + + let terminal_entity = + terminal_view.read_with(&cx, |terminal_view, _cx| terminal_view.terminal().clone()); + terminal_entity.update(&mut cx, |terminal, cx| { + terminal.breadcrumb_text = "Fresh Shell Title".to_string(); + cx.emit(TerminalEvent::BreadcrumbsChanged); + }); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, cx| { + let terminals = panel.terminals(cx); + assert_eq!(terminals.len(), 1); + assert_eq!(terminals[0].title.as_ref(), "Fresh Shell Title"); + }); + } + + #[gpui::test] + async fn test_restored_terminal_selects_without_focusing(cx: &mut TestAppContext) { + let (panel, mut cx) = setup_panel(cx).await; + let terminal_id = TerminalId::new(); + let now = Utc::now(); + let metadata = TerminalThreadMetadata { + terminal_id, + title: "Persisted Shell Title".into(), + custom_title: None, + created_at: now, + worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from( + "/project", + )])), + remote_connection: None, + working_directory: None, + }; + + panel.update_in(&mut cx, |panel, window, cx| { + panel + .restore_test_terminal( + metadata, + false, + AgentThreadSource::Sidebar, + None, + window, + cx, + ) + .expect("test terminal should be restored"); + }); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, _cx| { + assert_eq!(panel.active_terminal_id(), Some(terminal_id)); + }); + } + #[gpui::test] async fn test_terminal_working_directory_uses_active_workspace_while_workspace_is_updating( cx: &mut TestAppContext, @@ -7169,7 +7580,7 @@ mod tests { }); panel.update(&mut cx, |panel, cx| { - panel.refresh_terminal_title(terminal_id, cx); + panel.refresh_terminal_metadata(terminal_id, cx); }); cx.run_until_parked(); diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 99297e20a70..f67a1d11aaf 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -26,6 +26,7 @@ mod model_selector_popover; mod profile_selector; mod terminal_codegen; mod terminal_inline_assistant; +pub mod terminal_thread_metadata_store; #[cfg(any(test, feature = "test-support"))] pub mod test_support; mod thread_import; @@ -513,6 +514,7 @@ pub fn init( agent_panel::init(cx); context_server_configuration::init(language_registry.clone(), fs.clone(), cx); thread_metadata_store::init(cx); + terminal_thread_metadata_store::init(cx); inline_assistant::init(fs.clone(), prompt_builder.clone(), cx); terminal_inline_assistant::init(fs.clone(), prompt_builder, cx); diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 6ecb85267a7..5e5f6af52b3 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -2793,6 +2793,7 @@ impl ConversationView { dismiss_if_visible(this, window, cx); } AgentPanelEvent::EntryChanged + | AgentPanelEvent::TerminalClosed { .. } | AgentPanelEvent::ThreadInteracted { .. } => {} }, )); diff --git a/crates/agent_ui/src/terminal_thread_metadata_store.rs b/crates/agent_ui/src/terminal_thread_metadata_store.rs new file mode 100644 index 00000000000..e3d5c9fdef8 --- /dev/null +++ b/crates/agent_ui/src/terminal_thread_metadata_store.rs @@ -0,0 +1,617 @@ +use std::path::{Path, PathBuf}; + +use anyhow::Context as _; +use chrono::{DateTime, Utc}; +use collections::{HashMap, HashSet}; +use db::{ + sqlez::{ + bindable::Column, domain::Domain, statement::Statement, + thread_safe_connection::ThreadSafeConnection, + }, + sqlez_macros::sql, +}; +use gpui::{AppContext as _, Entity, Global, Task}; +use remote::{RemoteConnectionOptions, same_remote_connection_identity}; +use ui::{App, Context, SharedString}; +use util::ResultExt as _; +use workspace::PathList; + +use crate::{TerminalId, thread_metadata_store::WorktreePaths}; + +pub fn init(cx: &mut App) { + TerminalThreadMetadataStore::init_global(cx); +} + +struct GlobalTerminalThreadMetadataStore(Entity); +impl Global for GlobalTerminalThreadMetadataStore {} + +#[cfg(any(test, feature = "test-support"))] +pub struct TestTerminalMetadataDbName(pub String); +#[cfg(any(test, feature = "test-support"))] +impl Global for TestTerminalMetadataDbName {} + +#[cfg(any(test, feature = "test-support"))] +impl TestTerminalMetadataDbName { + pub fn global(cx: &App) -> String { + cx.try_global::() + .map(|global| global.0.clone()) + .unwrap_or_else(|| { + let thread = std::thread::current(); + let test_name = thread.name().unwrap_or("unknown_test"); + format!("TERMINAL_THREAD_METADATA_DB_{}", test_name) + }) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct TerminalThreadMetadata { + pub terminal_id: TerminalId, + pub title: SharedString, + pub custom_title: Option, + pub created_at: DateTime, + pub worktree_paths: WorktreePaths, + pub remote_connection: Option, + pub working_directory: Option, +} + +impl TerminalThreadMetadata { + pub fn folder_paths(&self) -> &PathList { + self.worktree_paths.folder_path_list() + } + + pub fn main_worktree_paths(&self) -> &PathList { + self.worktree_paths.main_worktree_path_list() + } +} + +pub struct TerminalThreadMetadataStore { + db: TerminalThreadMetadataDb, + terminals: HashMap, + terminals_by_paths: HashMap>, + terminals_by_main_paths: HashMap>, + pending_terminal_ops_tx: async_channel::Sender, + _db_operations_task: Task<()>, +} + +#[derive(Debug, PartialEq)] +enum DbOperation { + Upsert(TerminalThreadMetadata), + Delete(TerminalId), +} + +impl DbOperation { + fn id(&self) -> TerminalId { + match self { + DbOperation::Upsert(metadata) => metadata.terminal_id, + DbOperation::Delete(terminal_id) => *terminal_id, + } + } +} + +impl TerminalThreadMetadataStore { + #[cfg(not(any(test, feature = "test-support")))] + pub fn init_global(cx: &mut App) { + if cx.has_global::() { + return; + } + + let db = TerminalThreadMetadataDb::global(cx); + let terminal_store = cx.new(|cx| Self::new(db, cx)); + cx.set_global(GlobalTerminalThreadMetadataStore(terminal_store)); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn init_global(cx: &mut App) { + let db_name = TestTerminalMetadataDbName::global(cx); + let db = gpui::block_on(db::open_test_db::(&db_name)); + let terminal_store = cx.new(|cx| Self::new(TerminalThreadMetadataDb(db), cx)); + cx.set_global(GlobalTerminalThreadMetadataStore(terminal_store)); + } + + pub fn try_global(cx: &App) -> Option> { + cx.try_global::() + .map(|store| store.0.clone()) + } + + pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } + + pub fn entry(&self, terminal_id: TerminalId) -> Option<&TerminalThreadMetadata> { + self.terminals.get(&terminal_id) + } + + pub fn entries(&self) -> impl Iterator + '_ { + self.terminals.values() + } + + pub fn entries_for_path<'a>( + &'a self, + path_list: &PathList, + remote_connection: Option<&'a RemoteConnectionOptions>, + ) -> impl Iterator + 'a { + self.terminals_by_paths + .get(path_list) + .into_iter() + .flatten() + .filter_map(|id| self.terminals.get(id)) + .filter(move |terminal| { + same_remote_connection_identity( + terminal.remote_connection.as_ref(), + remote_connection, + ) + }) + } + + pub fn entries_for_main_worktree_path<'a>( + &'a self, + path_list: &PathList, + remote_connection: Option<&'a RemoteConnectionOptions>, + ) -> impl Iterator + 'a { + self.terminals_by_main_paths + .get(path_list) + .into_iter() + .flatten() + .filter_map(|id| self.terminals.get(id)) + .filter(move |terminal| { + same_remote_connection_identity( + terminal.remote_connection.as_ref(), + remote_connection, + ) + }) + } + + pub fn path_is_referenced_by_terminal( + &self, + terminal_id: Option, + path: &Path, + remote_connection: Option<&RemoteConnectionOptions>, + ) -> bool { + self.entries().any(|terminal| { + Some(terminal.terminal_id) != terminal_id + && same_remote_connection_identity( + terminal.remote_connection.as_ref(), + remote_connection, + ) + && terminal + .folder_paths() + .paths() + .iter() + .any(|folder_path| folder_path.as_path() == path) + }) + } + + pub fn save(&mut self, metadata: TerminalThreadMetadata, cx: &mut Context) { + self.save_internal(metadata); + cx.notify(); + } + + pub fn change_worktree_paths( + &mut self, + current_folder_paths: &PathList, + remote_connection: Option<&RemoteConnectionOptions>, + mutate: impl Fn(&mut WorktreePaths), + cx: &mut Context, + ) { + let terminal_ids: Vec<_> = self + .terminals_by_paths + .get(current_folder_paths) + .into_iter() + .flatten() + .filter(|id| { + self.terminals.get(id).is_some_and(|terminal| { + same_remote_connection_identity( + terminal.remote_connection.as_ref(), + remote_connection, + ) + }) + }) + .copied() + .collect(); + + if terminal_ids.is_empty() { + return; + } + + for terminal_id in terminal_ids { + if let Some(mut terminal) = self.terminals.get(&terminal_id).cloned() { + mutate(&mut terminal.worktree_paths); + self.save_internal(terminal); + } + } + + cx.notify(); + } + + fn save_internal(&mut self, metadata: TerminalThreadMetadata) { + if let Some(existing) = self.terminals.get(&metadata.terminal_id) { + if existing.folder_paths() != metadata.folder_paths() + && let Some(ids) = self.terminals_by_paths.get_mut(existing.folder_paths()) + { + ids.remove(&metadata.terminal_id); + } + + if existing.main_worktree_paths() != metadata.main_worktree_paths() + && let Some(ids) = self + .terminals_by_main_paths + .get_mut(existing.main_worktree_paths()) + { + ids.remove(&metadata.terminal_id); + } + } + + self.cache_terminal_metadata(metadata.clone()); + self.pending_terminal_ops_tx + .try_send(DbOperation::Upsert(metadata)) + .log_err(); + } + + fn cache_terminal_metadata(&mut self, metadata: TerminalThreadMetadata) { + self.terminals + .insert(metadata.terminal_id, metadata.clone()); + + self.terminals_by_paths + .entry(metadata.folder_paths().clone()) + .or_default() + .insert(metadata.terminal_id); + + if !metadata.main_worktree_paths().is_empty() { + self.terminals_by_main_paths + .entry(metadata.main_worktree_paths().clone()) + .or_default() + .insert(metadata.terminal_id); + } + } + + pub fn delete(&mut self, terminal_id: TerminalId, cx: &mut Context) { + if let Some(terminal) = self.terminals.remove(&terminal_id) { + if let Some(ids) = self.terminals_by_paths.get_mut(terminal.folder_paths()) { + ids.remove(&terminal_id); + } + if !terminal.main_worktree_paths().is_empty() + && let Some(ids) = self + .terminals_by_main_paths + .get_mut(terminal.main_worktree_paths()) + { + ids.remove(&terminal_id); + } + } + self.pending_terminal_ops_tx + .try_send(DbOperation::Delete(terminal_id)) + .log_err(); + cx.notify(); + } + + fn new(db: TerminalThreadMetadataDb, cx: &mut Context) -> Self { + let (tx, rx) = async_channel::unbounded(); + let _db_operations_task = cx.background_spawn({ + let db = db.clone(); + async move { + while let Ok(first_update) = rx.recv().await { + let mut updates = vec![first_update]; + while let Ok(update) = rx.try_recv() { + updates.push(update); + } + let updates = Self::dedup_db_operations(updates); + for operation in updates { + match operation { + DbOperation::Upsert(metadata) => { + db.save(metadata).await.log_err(); + } + DbOperation::Delete(terminal_id) => { + db.delete(terminal_id).await.log_err(); + } + } + } + } + } + }); + + let mut this = Self { + db, + terminals: HashMap::default(), + terminals_by_paths: HashMap::default(), + terminals_by_main_paths: HashMap::default(), + pending_terminal_ops_tx: tx, + _db_operations_task, + }; + this.reload(cx); + this + } + + fn dedup_db_operations(operations: Vec) -> Vec { + let mut ops = HashMap::default(); + for operation in operations.into_iter().rev() { + if ops.contains_key(&operation.id()) { + continue; + } + ops.insert(operation.id(), operation); + } + ops.into_values().collect() + } + + fn reload(&mut self, cx: &mut Context) { + let db = self.db.clone(); + cx.spawn(async move |this, cx| { + let rows = cx + .background_spawn(async move { + db.list() + .context("Failed to fetch terminal thread metadata") + }) + .await + .log_err() + .unwrap_or_default(); + + this.update(cx, |this, cx| { + this.terminals.clear(); + this.terminals_by_paths.clear(); + this.terminals_by_main_paths.clear(); + + for row in rows { + this.cache_terminal_metadata(row); + } + + cx.notify(); + }) + .ok(); + }) + .detach(); + } +} + +struct TerminalThreadMetadataDb(ThreadSafeConnection); + +impl Domain for TerminalThreadMetadataDb { + const NAME: &str = stringify!(TerminalThreadMetadataDb); + + const MIGRATIONS: &[&str] = &[sql!( + CREATE TABLE IF NOT EXISTS sidebar_terminal_threads( + terminal_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + custom_title TEXT, + created_at TEXT NOT NULL, + working_directory TEXT, + folder_paths TEXT, + folder_paths_order TEXT, + main_worktree_paths TEXT, + main_worktree_paths_order TEXT, + remote_connection TEXT + ) STRICT; + )]; +} + +db::static_connection!(TerminalThreadMetadataDb, []); + +impl TerminalThreadMetadataDb { + pub fn list(&self) -> anyhow::Result> { + self.select::( + "SELECT terminal_id, title, custom_title, created_at, \ + working_directory, folder_paths, folder_paths_order, main_worktree_paths, \ + main_worktree_paths_order, remote_connection \ + FROM sidebar_terminal_threads \ + ORDER BY created_at DESC", + )?() + } + + pub async fn save(&self, row: TerminalThreadMetadata) -> anyhow::Result<()> { + let terminal_id = row.terminal_id.to_key_string(); + let title = row.title.to_string(); + let custom_title = row.custom_title.as_ref().map(ToString::to_string); + let created_at = row.created_at.to_rfc3339(); + let working_directory = row + .working_directory + .as_ref() + .map(|path| path.to_string_lossy().into_owned()); + let serialized = row.folder_paths().serialize(); + let (folder_paths, folder_paths_order) = if row.folder_paths().is_empty() { + (None, None) + } else { + (Some(serialized.paths), Some(serialized.order)) + }; + let main_serialized = row.main_worktree_paths().serialize(); + let (main_worktree_paths, main_worktree_paths_order) = + if row.main_worktree_paths().is_empty() { + (None, None) + } else { + (Some(main_serialized.paths), Some(main_serialized.order)) + }; + let remote_connection = row + .remote_connection + .as_ref() + .map(serde_json::to_string) + .transpose() + .context("serialize terminal thread remote connection")?; + + self.write(move |conn| { + let sql = "INSERT INTO sidebar_terminal_threads(terminal_id, title, custom_title, created_at, working_directory, folder_paths, folder_paths_order, main_worktree_paths, main_worktree_paths_order, remote_connection) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) \ + ON CONFLICT(terminal_id) DO UPDATE SET \ + title = excluded.title, \ + custom_title = excluded.custom_title, \ + created_at = excluded.created_at, \ + working_directory = excluded.working_directory, \ + folder_paths = excluded.folder_paths, \ + folder_paths_order = excluded.folder_paths_order, \ + main_worktree_paths = excluded.main_worktree_paths, \ + main_worktree_paths_order = excluded.main_worktree_paths_order, \ + remote_connection = excluded.remote_connection"; + let mut stmt = Statement::prepare(conn, sql)?; + let mut i = stmt.bind(&terminal_id, 1)?; + i = stmt.bind(&title, i)?; + i = stmt.bind(&custom_title, i)?; + i = stmt.bind(&created_at, i)?; + i = stmt.bind(&working_directory, i)?; + i = stmt.bind(&folder_paths, i)?; + i = stmt.bind(&folder_paths_order, i)?; + i = stmt.bind(&main_worktree_paths, i)?; + i = stmt.bind(&main_worktree_paths_order, i)?; + stmt.bind(&remote_connection, i)?; + stmt.exec() + }) + .await + } + + pub async fn delete(&self, terminal_id: TerminalId) -> anyhow::Result<()> { + let terminal_id = terminal_id.to_key_string(); + self.write(move |conn| { + let mut stmt = Statement::prepare( + conn, + "DELETE FROM sidebar_terminal_threads WHERE terminal_id = ?", + )?; + stmt.bind(&terminal_id, 1)?; + stmt.exec() + }) + .await + } +} + +impl Column for TerminalThreadMetadata { + fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> { + let (terminal_id, next): (String, i32) = Column::column(statement, start_index)?; + let (title, next): (String, i32) = Column::column(statement, next)?; + let (custom_title, next): (Option, i32) = Column::column(statement, next)?; + let (created_at, next): (String, i32) = Column::column(statement, next)?; + let (working_directory, next): (Option, i32) = Column::column(statement, next)?; + let (folder_paths_str, next): (Option, i32) = Column::column(statement, next)?; + let (folder_paths_order_str, next): (Option, i32) = + Column::column(statement, next)?; + let (main_worktree_paths_str, next): (Option, i32) = + Column::column(statement, next)?; + let (main_worktree_paths_order_str, next): (Option, i32) = + Column::column(statement, next)?; + let (remote_connection_json, next): (Option, i32) = + Column::column(statement, next)?; + + let folder_paths = folder_paths_str + .map(|paths| { + PathList::deserialize(&util::path_list::SerializedPathList { + paths, + order: folder_paths_order_str.unwrap_or_default(), + }) + }) + .unwrap_or_default(); + + let main_worktree_paths = main_worktree_paths_str + .map(|paths| { + PathList::deserialize(&util::path_list::SerializedPathList { + paths, + order: main_worktree_paths_order_str.unwrap_or_default(), + }) + }) + .unwrap_or_default(); + + let remote_connection = remote_connection_json + .as_deref() + .map(serde_json::from_str::) + .transpose() + .context("deserialize terminal thread remote connection")?; + + let worktree_paths = WorktreePaths::from_path_lists(main_worktree_paths, folder_paths) + .unwrap_or_else(|_| WorktreePaths::default()); + + Ok(( + TerminalThreadMetadata { + terminal_id: TerminalId::from_key_string(&terminal_id)?, + title: SharedString::from(title), + custom_title: custom_title + .filter(|title| !title.trim().is_empty()) + .map(SharedString::from), + created_at: DateTime::parse_from_rfc3339(&created_at)?.with_timezone(&Utc), + worktree_paths, + remote_connection, + working_directory: working_directory.map(PathBuf::from), + }, + next, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + use std::path::Path; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + TerminalThreadMetadataStore::init_global(cx); + }); + cx.run_until_parked(); + } + + fn metadata(title: &str, worktree_paths: WorktreePaths) -> TerminalThreadMetadata { + let now = Utc::now(); + TerminalThreadMetadata { + terminal_id: TerminalId::new(), + title: SharedString::from(title.to_string()), + custom_title: None, + created_at: now, + worktree_paths, + remote_connection: None, + working_directory: None, + } + } + + #[gpui::test] + async fn test_change_worktree_paths_reindexes_terminal_metadata(cx: &mut TestAppContext) { + init_test(cx); + + let old_main_paths = PathList::new(&[Path::new("/repo")]); + let old_folder_paths = PathList::new(&[Path::new("/repo-feature")]); + let new_main_path = Path::new("/repo"); + let new_folder_path = Path::new("/repo-feature-renamed"); + let new_folder_paths = PathList::new(&[new_folder_path]); + let metadata = metadata( + "Dev Server", + WorktreePaths::from_path_lists(old_main_paths.clone(), old_folder_paths.clone()) + .unwrap(), + ); + let terminal_id = metadata.terminal_id; + + cx.update(|cx| { + TerminalThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save(metadata, cx); + }); + }); + + cx.update(|cx| { + TerminalThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.change_worktree_paths( + &old_folder_paths, + None, + |paths| { + paths.add_path(new_main_path, new_folder_path); + paths.remove_folder_path(Path::new("/repo-feature")); + }, + cx, + ); + }); + }); + + cx.update(|cx| { + let store = TerminalThreadMetadataStore::global(cx); + let store = store.read(cx); + assert!( + store + .entries_for_path(&old_folder_paths, None) + .next() + .is_none() + ); + assert_eq!( + store + .entries_for_path(&new_folder_paths, None) + .map(|entry| entry.terminal_id) + .collect::>(), + vec![terminal_id] + ); + assert_eq!( + store + .entry(terminal_id) + .unwrap() + .main_worktree_paths() + .paths(), + old_main_paths.paths() + ); + }); + } +} diff --git a/crates/agent_ui/src/test_support.rs b/crates/agent_ui/src/test_support.rs index 7f4cb59a634..509dfdbb559 100644 --- a/crates/agent_ui/src/test_support.rs +++ b/crates/agent_ui/src/test_support.rs @@ -105,6 +105,7 @@ pub fn init_test(cx: &mut TestAppContext) { editor::init(cx); release_channel::init("0.0.0".parse().unwrap(), cx); agent_panel::init(cx); + crate::terminal_thread_metadata_store::TerminalThreadMetadataStore::init_global(cx); }); } diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index 591e65a9d72..9787d3d9d7d 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -844,17 +844,17 @@ impl ThreadMetadataStore { self.in_flight_archives.remove(&thread_id); } - /// Returns `true` if any unarchived thread other than `current_session_id` + /// Returns `true` if any unarchived thread other than `thread_id` /// references `path` in its folder paths. Used to determine whether a /// worktree can safely be removed from disk. - pub fn path_is_referenced_by_other_unarchived_threads( + pub fn path_is_referenced_by_unarchived_threads( &self, - thread_id: ThreadId, + thread_id: Option, path: &Path, remote_connection: Option<&RemoteConnectionOptions>, ) -> bool { self.entries().any(|thread| { - thread.thread_id != thread_id + Some(thread.thread_id) != thread_id && !thread.archived && same_remote_connection_identity( thread.remote_connection.as_ref(), diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 877b37c59e2..735421f858d 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -4,6 +4,9 @@ use acp_thread::ThreadStatus; use action_log::DiffStats; use agent_client_protocol::schema as acp; use agent_settings::AgentSettings; +use agent_ui::terminal_thread_metadata_store::{ + TerminalThreadMetadata, TerminalThreadMetadataStore, +}; use agent_ui::thread_metadata_store::{ ThreadMetadata, ThreadMetadataStore, WorktreePaths, worktree_info_from_thread_paths, }; @@ -13,9 +16,9 @@ use agent_ui::threads_archive_view::{ fuzzy_match_positions, }; use agent_ui::{ - AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, AgentPanelTerminalInfo, - AgentThreadSource, ArchiveSelectedThread, CrossChannelImportOnboarding, DEFAULT_THREAD_TITLE, - NewThread, TerminalId, ThreadId, ThreadImportModal, channels_with_threads, + AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, AgentThreadSource, + ArchiveSelectedThread, CrossChannelImportOnboarding, DEFAULT_THREAD_TITLE, NewThread, + TerminalId, ThreadId, ThreadImportModal, channels_with_threads, import_threads_from_other_channels, }; use chrono::{DateTime, Utc}; @@ -174,7 +177,7 @@ impl ActiveEntry { .is_some_and(|(a, b)| a == b) } (ActiveEntry::Terminal { terminal_id, .. }, ListEntry::Terminal(terminal)) => { - *terminal_id == terminal.id + *terminal_id == terminal.metadata.terminal_id } _ => false, } @@ -197,9 +200,9 @@ struct ActiveThreadInfo { enum ThreadEntryWorkspace { Open(Entity), Closed { - /// The paths this thread uses (may point to linked worktrees). + /// The paths this entry uses (may point to linked worktrees). folder_paths: PathList, - /// The project group this thread belongs to. + /// The project group this entry belongs to. project_group_key: ProjectGroupKey, }, } @@ -235,11 +238,9 @@ struct ThreadEntry { #[derive(Clone)] struct TerminalEntry { - id: TerminalId, - title: SharedString, - workspace: Entity, + metadata: TerminalThreadMetadata, + workspace: ThreadEntryWorkspace, worktrees: Vec, - created_at: DateTime, has_notification: bool, highlight_positions: Vec, } @@ -284,8 +285,8 @@ enum ActivatableEntry { workspace: ThreadEntryWorkspace, }, Terminal { - terminal_id: TerminalId, - workspace: Entity, + metadata: TerminalThreadMetadata, + workspace: ThreadEntryWorkspace, }, } @@ -297,7 +298,7 @@ impl ActivatableEntry { workspace: thread.workspace.clone(), }), ListEntry::Terminal(terminal) => Some(Self::Terminal { - terminal_id: terminal.id, + metadata: terminal.metadata.clone(), workspace: terminal.workspace.clone(), }), ListEntry::ProjectHeader { .. } => None, @@ -309,6 +310,10 @@ impl ActivatableEntry { Self::Thread { workspace: ThreadEntryWorkspace::Open(workspace), .. + } + | Self::Terminal { + workspace: ThreadEntryWorkspace::Open(workspace), + .. } => ( PathList::new(&workspace.read(cx).root_paths(cx)), workspace.read(cx).project_group_key(cx), @@ -320,11 +325,15 @@ impl ActivatableEntry { project_group_key, }, .. + } + | Self::Terminal { + workspace: + ThreadEntryWorkspace::Closed { + folder_paths, + project_group_key, + }, + .. } => (folder_paths.clone(), project_group_key.clone()), - Self::Terminal { workspace, .. } => ( - PathList::new(&workspace.read(cx).root_paths(cx)), - workspace.read(cx).project_group_key(cx), - ), } } } @@ -348,7 +357,10 @@ impl ListEntry { ThreadEntryWorkspace::Open(ws) => vec![ws.clone()], ThreadEntryWorkspace::Closed { .. } => Vec::new(), }, - ListEntry::Terminal(terminal) => vec![terminal.workspace.clone()], + ListEntry::Terminal(terminal) => match &terminal.workspace { + ThreadEntryWorkspace::Open(workspace) => vec![workspace.clone()], + ThreadEntryWorkspace::Closed { .. } => Vec::new(), + }, ListEntry::ProjectHeader { key, .. } => multi_workspace .workspaces_for_project_group(key, cx) .unwrap_or_default(), @@ -413,23 +425,52 @@ fn workspace_path_list(workspace: &Entity, cx: &App) -> PathList { PathList::new(&workspace.read(cx).root_paths(cx)) } -fn workspace_has_agent_panel_terminals(workspace: &Entity, cx: &App) -> bool { - workspace - .read(cx) - .panel::(cx) - .is_some_and(|panel| !panel.read(cx).terminals(cx).is_empty()) +fn linked_worktree_path_lists_for_workspaces( + workspaces: &[Entity], + cx: &App, +) -> Vec { + let mut linked_worktree_paths = HashSet::new(); + for workspace in workspaces { + if workspace.read(cx).visible_worktrees(cx).count() != 1 { + continue; + } + for snapshot in root_repository_snapshots(workspace, cx) { + for linked_worktree in snapshot.linked_worktrees() { + linked_worktree_paths.insert(linked_worktree.path.clone()); + } + } + } + + let mut linked_worktree_paths = linked_worktree_paths.into_iter().collect::>(); + linked_worktree_paths.sort(); + linked_worktree_paths + .into_iter() + .map(|path| PathList::new(std::slice::from_ref(&path))) + .collect() } -fn workspace_contains_worktree_path( +fn workspace_has_terminal_metadata(workspace: &Entity, cx: &App) -> bool { + workspace_has_terminal_metadata_except(workspace, None, cx) +} + +fn workspace_has_terminal_metadata_except( workspace: &Entity, - worktree_path: &Path, + except_terminal_id: Option, cx: &App, ) -> bool { - let project = workspace.read(cx).project().clone(); - project + let Some(store) = TerminalThreadMetadataStore::try_global(cx) else { + return false; + }; + let path_list = workspace_path_list(workspace, cx); + let remote_connection = workspace .read(cx) - .visible_worktrees(cx) - .any(|worktree| worktree.read(cx).abs_path().as_ref() == worktree_path) + .project() + .read(cx) + .remote_connection_options(cx); + store + .read(cx) + .entries_for_path(&path_list, remote_connection.as_ref()) + .any(|terminal| except_terminal_id != Some(terminal.terminal_id)) } #[derive(Clone)] @@ -545,16 +586,6 @@ fn apply_worktree_label_mode( worktrees } -fn terminal_worktree_info( - workspace: &Entity, - branch_by_path: &HashMap, - cx: &App, -) -> Vec { - let project = workspace.read(cx).project().clone(); - let worktree_paths = project.read(cx).worktree_paths(cx); - worktree_info_from_thread_paths(&worktree_paths, branch_by_path) -} - /// Shows a [`RemoteConnectionModal`] on the given workspace and establishes /// an SSH connection. Suitable for passing to /// [`MultiWorkspace::find_or_create_workspace`] as the `connect_remote` @@ -668,10 +699,21 @@ impl Sidebar { }) .detach(); - let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect(); + cx.observe( + &TerminalThreadMetadataStore::global(cx), + |this, _store, cx| { + this.update_entries(cx); + }, + ) + .detach(); + + let deferred_multi_workspace = multi_workspace.downgrade(); cx.defer_in(window, move |this, window, cx| { - for workspace in &workspaces { - this.subscribe_to_workspace(workspace, window, cx); + if let Some(multi_workspace) = deferred_multi_workspace.upgrade() { + let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect(); + for workspace in &workspaces { + this.subscribe_to_workspace(workspace, window, cx); + } } this.update_entries(cx); }); @@ -756,7 +798,7 @@ impl Sidebar { this.update_entries(cx); } ProjectEvent::WorktreePathsChanged { old_worktree_paths } => { - this.move_thread_paths(project, old_worktree_paths, cx); + this.move_entry_paths(project, old_worktree_paths, cx); this.update_entries(cx); } _ => {} @@ -787,10 +829,10 @@ impl Sidebar { cx.subscribe_in( workspace, window, - |this, _workspace, event: &workspace::Event, window, cx| { + move |this, workspace, event: &workspace::Event, window, cx| { if let workspace::Event::PanelAdded(view) = event { if let Ok(agent_panel) = view.clone().downcast::() { - this.subscribe_to_agent_panel(&agent_panel, window, cx); + this.subscribe_to_agent_panel(workspace, &agent_panel, window, cx); this.update_entries(cx); } } @@ -801,11 +843,11 @@ impl Sidebar { self.observe_docks(workspace, cx); if let Some(agent_panel) = workspace.read(cx).panel::(cx) { - self.subscribe_to_agent_panel(&agent_panel, window, cx); + self.subscribe_to_agent_panel(workspace, &agent_panel, window, cx); } } - fn move_thread_paths( + fn move_entry_paths( &mut self, project: &Entity, old_paths: &WorktreePaths, @@ -841,18 +883,27 @@ impl Sidebar { } let remote_connection = project.read(cx).remote_connection_options(cx); + let apply_path_changes = |paths: &mut WorktreePaths| { + for (main_path, folder_path) in &added_pairs { + paths.add_path(main_path, folder_path); + } + for path in &removed_folder_paths { + paths.remove_folder_path(path); + } + }; ThreadMetadataStore::global(cx).update(cx, |store, store_cx| { store.change_worktree_paths( &old_folder_paths, remote_connection.as_ref(), - |paths| { - for (main_path, folder_path) in &added_pairs { - paths.add_path(main_path, folder_path); - } - for path in &removed_folder_paths { - paths.remove_folder_path(path); - } - }, + &apply_path_changes, + store_cx, + ); + }); + TerminalThreadMetadataStore::global(cx).update(cx, |store, store_cx| { + store.change_worktree_paths( + &old_folder_paths, + remote_connection.as_ref(), + &apply_path_changes, store_cx, ); }); @@ -860,20 +911,28 @@ impl Sidebar { fn subscribe_to_agent_panel( &mut self, + workspace: &Entity, agent_panel: &Entity, window: &mut Window, cx: &mut Context, ) { + let workspace = workspace.downgrade(); cx.subscribe_in( agent_panel, window, - |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event { + move |this, agent_panel, event: &AgentPanelEvent, window, cx| match event { AgentPanelEvent::ActiveViewChanged | AgentPanelEvent::ActiveViewFocused | AgentPanelEvent::EntryChanged => { this.sync_active_entry_from_panel(agent_panel, cx); this.update_entries(cx); } + AgentPanelEvent::TerminalClosed { metadata } => { + if let Some(workspace) = workspace.upgrade() { + let workspace = ThreadEntryWorkspace::Open(workspace); + this.close_terminal(metadata, &workspace, window, cx); + } + } AgentPanelEvent::ThreadInteracted { thread_id } => { this.record_thread_interacted(thread_id, cx); this.update_entries(cx); @@ -1136,6 +1195,7 @@ impl Sidebar { let mut current_terminal_ids: HashSet = HashSet::new(); let mut project_header_indices: Vec = Vec::new(); let mut seen_thread_ids: HashSet = HashSet::new(); + let mut seen_terminal_ids: HashSet = HashSet::new(); let has_open_projects = workspaces .iter() @@ -1156,6 +1216,18 @@ impl Sidebar { }; let groups = mw.project_groups(cx); + let mut live_notified_terminal_ids: HashSet = HashSet::new(); + for workspace in &workspaces { + if let Some(agent_panel) = workspace.read(cx).panel::(cx) { + live_notified_terminal_ids.extend( + agent_panel + .read(cx) + .terminals(cx) + .into_iter() + .filter_map(|terminal| terminal.has_notification.then_some(terminal.id)), + ); + } + } let mut all_paths: Vec = groups .iter() @@ -1195,18 +1267,101 @@ impl Sidebar { for group in &groups { let group_key = &group.key; let group_workspaces = &group.workspaces; - let terminals: Vec = group_workspaces + + let workspace_by_path_list: HashMap> = group_workspaces .iter() - .flat_map(|workspace| { - terminal_entries_for_workspace(workspace, &branch_by_path, cx) - }) + .map(|ws| (workspace_path_list(ws, cx), ws)) .collect(); - current_terminal_ids.extend(terminals.iter().map(|terminal| terminal.id)); - notified_terminals.extend( + let resolve_workspace = |folder_paths: &PathList| -> ThreadEntryWorkspace { + workspace_by_path_list + .get(folder_paths) + .map(|ws| ThreadEntryWorkspace::Open((*ws).clone())) + .unwrap_or_else(|| ThreadEntryWorkspace::Closed { + folder_paths: folder_paths.clone(), + project_group_key: group_key.clone(), + }) + }; + let linked_worktree_path_lists = + linked_worktree_path_lists_for_workspaces(group_workspaces, cx); + let make_terminal_entry = + |metadata: TerminalThreadMetadata, workspace: ThreadEntryWorkspace| { + let worktrees = + worktree_info_from_thread_paths(&metadata.worktree_paths, &branch_by_path); + let has_notification = + live_notified_terminal_ids.contains(&metadata.terminal_id); + TerminalEntry { + metadata, + workspace, + worktrees, + has_notification, + highlight_positions: Vec::new(), + } + }; + + let mut terminals = Vec::new(); + let terminal_store = TerminalThreadMetadataStore::global(cx); + let group_host = group_key.host(); + let mut push_terminal_metadata = + |metadata: TerminalThreadMetadata, workspace: ThreadEntryWorkspace| { + if !seen_terminal_ids.insert(metadata.terminal_id) { + return; + } + terminals.push(make_terminal_entry(metadata, workspace)); + }; + for row in terminal_store + .read(cx) + .entries_for_main_worktree_path(group_key.path_list(), group_host.as_ref()) + .cloned() + { + let workspace = resolve_workspace(row.folder_paths()); + push_terminal_metadata(row, workspace); + } + for row in terminal_store + .read(cx) + .entries_for_path(group_key.path_list(), group_host.as_ref()) + .cloned() + { + let workspace = resolve_workspace(row.folder_paths()); + push_terminal_metadata(row, workspace); + } + for ws in group_workspaces { + let ws_paths = workspace_path_list(ws, cx); + if ws_paths.paths().is_empty() { + continue; + } + for row in terminal_store + .read(cx) + .entries_for_path(&ws_paths, group_host.as_ref()) + .cloned() + { + push_terminal_metadata(row, ThreadEntryWorkspace::Open(ws.clone())); + } + } + for worktree_path_list in &linked_worktree_path_lists { + for row in terminal_store + .read(cx) + .entries_for_path(worktree_path_list, group_host.as_ref()) + .cloned() + { + push_terminal_metadata( + row, + ThreadEntryWorkspace::Closed { + folder_paths: worktree_path_list.clone(), + project_group_key: group_key.clone(), + }, + ); + } + } + current_terminal_ids.extend( terminals .iter() - .filter_map(|terminal| terminal.has_notification.then_some(terminal.id)), + .map(|terminal| terminal.metadata.terminal_id), ); + notified_terminals.extend(terminals.iter().filter_map(|terminal| { + terminal + .has_notification + .then_some(terminal.metadata.terminal_id) + })); if group_key.path_list().paths().is_empty() { continue; } @@ -1243,29 +1398,6 @@ impl Sidebar { }) .collect(); - // Build a lookup from workspace root paths to their workspace - // entity, used to assign ThreadEntryWorkspace::Open for threads - // whose folder_paths match an open workspace. - let workspace_by_path_list: HashMap> = - group_workspaces - .iter() - .map(|ws| (workspace_path_list(ws, cx), ws)) - .collect(); - - // Resolve a ThreadEntryWorkspace for a thread row. If any open - // workspace's root paths match the thread's folder_paths, use - // Open; otherwise use Closed. - let resolve_workspace = |row: &ThreadMetadata| -> ThreadEntryWorkspace { - workspace_by_path_list - .get(row.folder_paths()) - .map(|ws| ThreadEntryWorkspace::Open((*ws).clone())) - .unwrap_or_else(|| ThreadEntryWorkspace::Closed { - folder_paths: row.folder_paths().clone(), - project_group_key: group_key.clone(), - }) - }; - - // Build a ThreadEntry from a metadata row. let make_thread_entry = |row: ThreadMetadata, workspace: ThreadEntryWorkspace| -> ThreadEntry { let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id); @@ -1300,7 +1432,7 @@ impl Sidebar { if !seen_thread_ids.insert(row.thread_id) { continue; } - let workspace = resolve_workspace(&row); + let workspace = resolve_workspace(row.folder_paths()); threads.push(make_thread_entry(row, workspace)); } @@ -1316,7 +1448,7 @@ impl Sidebar { if !seen_thread_ids.insert(row.thread_id) { continue; } - let workspace = resolve_workspace(&row); + let workspace = resolve_workspace(row.folder_paths()); threads.push(make_thread_entry(row, workspace)); } @@ -1351,23 +1483,11 @@ impl Sidebar { } } - // Load any legacy threads for any single linked wortree of this project group. - let mut linked_worktree_paths = HashSet::new(); - for workspace in group_workspaces { - if workspace.read(cx).visible_worktrees(cx).count() != 1 { - continue; - } - for snapshot in root_repository_snapshots(workspace, cx) { - for linked_worktree in snapshot.linked_worktrees() { - linked_worktree_paths.insert(linked_worktree.path.clone()); - } - } - } - for path in linked_worktree_paths { - let worktree_path_list = PathList::new(std::slice::from_ref(&path)); + // Load any legacy threads for any single linked worktree of this project group. + for worktree_path_list in &linked_worktree_path_lists { for row in thread_store .read(cx) - .entries_for_path(&worktree_path_list, group_host.as_ref()) + .entries_for_path(worktree_path_list, group_host.as_ref()) .cloned() { if !seen_thread_ids.insert(row.thread_id) { @@ -1512,7 +1632,8 @@ impl Sidebar { let mut matched_terminals: Vec = Vec::new(); for mut terminal in terminals { let mut terminal_matched = false; - if let Some(positions) = fuzzy_match_positions(&query, &terminal.title) { + if let Some(positions) = fuzzy_match_positions(&query, &terminal.metadata.title) + { terminal.highlight_positions = positions; terminal_matched = true; } @@ -2626,8 +2747,9 @@ impl Sidebar { } } ListEntry::Terminal(terminal) => { + let metadata = terminal.metadata.clone(); let workspace = terminal.workspace.clone(); - self.activate_terminal(&workspace, terminal.id, false, window, cx); + self.activate_terminal_entry(metadata, workspace, false, window, cx); } } } @@ -3313,24 +3435,120 @@ impl Sidebar { true } ActivatableEntry::Terminal { - terminal_id, + metadata, workspace, } => { - let Some(workspace) = self - .find_workspace_in_current_window(cx, |candidate, _| candidate == workspace) - else { - return false; - }; - self.activate_terminal(&workspace, *terminal_id, false, window, cx); + self.activate_terminal_entry( + metadata.clone(), + workspace.clone(), + false, + window, + cx, + ); true } } } - fn activate_terminal( + fn activate_terminal_entry( + &mut self, + metadata: TerminalThreadMetadata, + workspace: ThreadEntryWorkspace, + retain: bool, + window: &mut Window, + cx: &mut Context, + ) { + match workspace { + ThreadEntryWorkspace::Open(workspace) => { + self.activate_terminal_in_workspace(&workspace, metadata, retain, window, cx); + } + ThreadEntryWorkspace::Closed { + folder_paths, + project_group_key, + } => { + self.open_workspace_and_activate_terminal( + metadata, + folder_paths, + &project_group_key, + window, + cx, + ); + } + } + } + + fn load_agent_terminal_in_workspace( + workspace: &Entity, + metadata: &TerminalThreadMetadata, + focus: bool, + window: &mut Window, + cx: &mut App, + ) { + let restore_terminal = |agent_panel: Entity, + metadata: &TerminalThreadMetadata, + focus: bool, + workspace: Option<&Workspace>, + window: &mut Window, + cx: &mut App| { + agent_panel.update(cx, |panel, cx| { + panel.restore_terminal( + metadata.clone(), + focus, + AgentThreadSource::Sidebar, + workspace, + window, + cx, + ); + }); + }; + + let mut existing_panel = None; + workspace.update(cx, |workspace, cx| { + if let Some(panel) = workspace.panel::(cx) { + existing_panel = Some(panel); + } + }); + + if let Some(agent_panel) = existing_panel { + restore_terminal(agent_panel, metadata, focus, None, window, cx); + workspace.update(cx, |workspace, cx| { + if focus { + workspace.focus_panel::(window, cx); + } else { + workspace.reveal_panel::(window, cx); + } + }); + return; + } + + let workspace = workspace.downgrade(); + let metadata = metadata.clone(); + let mut async_window_cx = window.to_async(cx); + cx.spawn(async move |_cx| { + let panel = AgentPanel::load(workspace.clone(), async_window_cx.clone()).await?; + + workspace.update_in(&mut async_window_cx, |workspace, window, cx| { + let panel = workspace.panel::(cx).unwrap_or_else(|| { + workspace.add_panel(panel.clone(), window, cx); + panel.clone() + }); + restore_terminal(panel, &metadata, focus, Some(workspace), window, cx); + if focus { + workspace.focus_panel::(window, cx); + } else { + workspace.reveal_panel::(window, cx); + } + })?; + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn activate_terminal_in_workspace( &mut self, workspace: &Entity, - terminal_id: TerminalId, + metadata: TerminalThreadMetadata, retain: bool, window: &mut Window, cx: &mut Context, @@ -3339,6 +3557,7 @@ impl Sidebar { return; }; + let terminal_id = metadata.terminal_id; self.record_terminal_access(terminal_id); self.active_entry = Some(ActiveEntry::Terminal { terminal_id, @@ -3352,25 +3571,239 @@ impl Sidebar { } }); - workspace.update(cx, |workspace, cx| { - if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| { - panel.activate_terminal(terminal_id, true, window, cx); - }); - } - workspace.focus_panel::(window, cx); - }); + Self::load_agent_terminal_in_workspace(workspace, &metadata, true, window, cx); self.update_entries(cx); } - fn close_terminal( + fn open_workspace_and_activate_terminal( &mut self, - workspace: &Entity, - terminal_id: TerminalId, + metadata: TerminalThreadMetadata, + folder_paths: PathList, + project_group_key: &ProjectGroupKey, window: &mut Window, cx: &mut Context, ) { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + + let host = project_group_key.host(); + let provisional_key = Some(project_group_key.clone()); + let active_workspace = multi_workspace.read(cx).workspace().clone(); + let modal_workspace = active_workspace.clone(); + + let open_task = multi_workspace.update(cx, |this, cx| { + this.find_or_create_workspace( + folder_paths, + host, + provisional_key, + |options, window, cx| connect_remote(active_workspace, options, window, cx), + &[], + None, + OpenMode::Activate, + window, + cx, + ) + }); + + cx.spawn_in(window, async move |this, cx| { + let result = open_task.await; + remote_connection::dismiss_connection_modal(&modal_workspace, cx); + let workspace = result?; + this.update_in(cx, |this, window, cx| { + this.activate_terminal_in_workspace(&workspace, metadata, false, window, cx); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn should_load_closed_workspace_for_archive( + folder_paths: &PathList, + project_group_key: &ProjectGroupKey, + remote_connection: Option<&RemoteConnectionOptions>, + except_thread_id: Option, + except_terminal_id: Option, + cx: &App, + ) -> bool { + if folder_paths.is_empty() || folder_paths == project_group_key.path_list() { + return false; + } + + let thread_store = ThreadMetadataStore::global(cx); + let thread_store = thread_store.read(cx); + if folder_paths.ordered_paths().any(|path| { + thread_store.path_is_referenced_by_unarchived_threads( + except_thread_id, + path, + remote_connection, + ) + }) { + return false; + } + + TerminalThreadMetadataStore::try_global(cx).is_none_or(|terminal_store| { + let terminal_store = terminal_store.read(cx); + !folder_paths.ordered_paths().any(|path| { + terminal_store.path_is_referenced_by_terminal( + except_terminal_id, + path, + remote_connection, + ) + }) + }) + } + + async fn wait_for_archive_workspace_metadata( + workspace: &Entity, + cx: &mut gpui::AsyncApp, + ) { + let scans_complete = + workspace.read_with(cx, |workspace, cx| workspace.worktree_scans_complete(cx)); + scans_complete.await; + + let project = workspace.read_with(cx, |workspace, _| workspace.project().clone()); + let barriers = project.update(cx, |project, cx| { + let repositories = project + .repositories(cx) + .values() + .cloned() + .collect::>(); + repositories + .into_iter() + .map(|repository| repository.update(cx, |repository, _| repository.barrier())) + .collect::>() + }); + for barrier in barriers { + let result: anyhow::Result<()> = barrier.await.map_err(|_| { + anyhow::anyhow!("git repository barrier canceled while archiving worktree") + }); + result.log_err(); + } + } + + fn open_workspace_for_archive( + &mut self, + folder_paths: PathList, + project_group_key: ProjectGroupKey, + window: &mut Window, + cx: &mut Context, + ) -> Option<(Task>>, Entity)> { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return None; + }; + + let host = project_group_key.host(); + let active_workspace = multi_workspace.read(cx).workspace().clone(); + let modal_workspace = active_workspace.clone(); + + let open_task = multi_workspace.update(cx, |this, cx| { + this.find_or_create_workspace( + folder_paths, + host, + Some(project_group_key), + |options, window, cx| connect_remote(active_workspace, options, window, cx), + &[], + None, + OpenMode::Add, + window, + cx, + ) + }); + + Some((open_task, modal_workspace)) + } + + fn open_workspace_and_archive_thread( + &mut self, + session_id: acp::SessionId, + folder_paths: PathList, + project_group_key: ProjectGroupKey, + window: &mut Window, + cx: &mut Context, + ) { + let Some((open_task, modal_workspace)) = + self.open_workspace_for_archive(folder_paths, project_group_key, window, cx) + else { + return; + }; + + cx.spawn_in(window, async move |this, cx| { + let result = open_task.await; + remote_connection::dismiss_connection_modal(&modal_workspace, cx); + let workspace = result?; + Self::wait_for_archive_workspace_metadata(&workspace, cx).await; + + this.update_in(cx, |this, window, cx| { + this.update_entries(cx); + this.archive_thread(&session_id, window, cx); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn open_workspace_and_close_terminal( + &mut self, + metadata: TerminalThreadMetadata, + folder_paths: PathList, + project_group_key: ProjectGroupKey, + window: &mut Window, + cx: &mut Context, + ) { + let Some((open_task, modal_workspace)) = + self.open_workspace_for_archive(folder_paths, project_group_key, window, cx) + else { + return; + }; + + cx.spawn_in(window, async move |this, cx| { + let result = open_task.await; + remote_connection::dismiss_connection_modal(&modal_workspace, cx); + let workspace = result?; + Self::wait_for_archive_workspace_metadata(&workspace, cx).await; + + this.update_in(cx, |this, window, cx| { + let workspace = ThreadEntryWorkspace::Open(workspace); + this.close_terminal(&metadata, &workspace, window, cx); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn close_terminal( + &mut self, + metadata: &TerminalThreadMetadata, + workspace: &ThreadEntryWorkspace, + window: &mut Window, + cx: &mut Context, + ) { + if let ThreadEntryWorkspace::Closed { + folder_paths, + project_group_key, + } = workspace + && Self::should_load_closed_workspace_for_archive( + folder_paths, + project_group_key, + metadata.remote_connection.as_ref(), + None, + Some(metadata.terminal_id), + cx, + ) + { + self.open_workspace_and_close_terminal( + metadata.clone(), + folder_paths.clone(), + project_group_key.clone(), + window, + cx, + ); + return; + } + + let terminal_id = metadata.terminal_id; let is_active = self .active_entry .as_ref() @@ -3379,20 +3812,250 @@ impl Sidebar { .contents .entries .iter() - .position(|entry| matches!(entry, ListEntry::Terminal(terminal) if terminal.id == terminal_id)) - .and_then(|position| { - self.neighboring_activatable_entry(position) + .position(|entry| { + matches!( + entry, + ListEntry::Terminal(terminal) + if terminal.metadata.terminal_id == terminal_id + ) + }) + .and_then(|position| self.neighboring_activatable_entry(position)); + + let terminal_folder_paths = metadata.folder_paths().clone(); + let roots_to_archive = { + let mut workspaces = self + .multi_workspace + .upgrade() + .map(|multi_workspace| { + multi_workspace + .read(cx) + .workspaces() + .cloned() + .collect::>() + }) + .unwrap_or_default(); + for workspace in thread_worktree_archive::all_open_workspaces(cx) { + if !workspaces.contains(&workspace) { + workspaces.push(workspace); + } + } + + metadata + .folder_paths() + .ordered_paths() + .filter_map(|path| { + thread_worktree_archive::build_root_plan( + path, + metadata.remote_connection.as_ref(), + &workspaces, + cx, + ) + }) + .filter(|plan| { + let store = ThreadMetadataStore::global(cx); + let store = store.read(cx); + !store.path_is_referenced_by_unarchived_threads( + None, + &plan.root_path, + metadata.remote_connection.as_ref(), + ) + }) + .filter(|root| { + TerminalThreadMetadataStore::try_global(cx).is_none_or(|terminal_store| { + !terminal_store.read(cx).path_is_referenced_by_terminal( + Some(terminal_id), + root.root_path.as_path(), + metadata.remote_connection.as_ref(), + ) + }) + }) + .collect::>() + }; + + let workspace_to_remove = if terminal_folder_paths.is_empty() { + None + } else { + let remaining = ThreadMetadataStore::global(cx) + .read(cx) + .entries_for_path(&terminal_folder_paths, metadata.remote_connection.as_ref()) + .count(); + + if remaining > 0 { + None + } else { + let workspace = self.multi_workspace.upgrade().and_then(|multi_workspace| { + multi_workspace + .read(cx) + .workspace_for_paths(&terminal_folder_paths, None, cx) + }); + + workspace.and_then(|workspace| { + if workspace_has_terminal_metadata_except(&workspace, Some(terminal_id), cx) { + return None; + } + + let group_key = workspace.read(cx).project_group_key(cx); + (group_key.path_list() != &terminal_folder_paths).then_some(workspace) + }) + } + }; + + let mut workspaces_to_remove: Vec> = + workspace_to_remove.into_iter().collect(); + let close_item_tasks = self.close_items_for_archived_worktrees( + &roots_to_archive, + &mut workspaces_to_remove, + window, + cx, + ); + + if !workspaces_to_remove.is_empty() { + let multi_workspace = self.multi_workspace.upgrade().unwrap(); + let terminal_workspace_removed = matches!( + workspace, + ThreadEntryWorkspace::Open(workspace) if workspaces_to_remove.contains(workspace) + ); + let (fallback_paths, project_group_key) = neighbor + .as_ref() + .map(|neighbor| neighbor.project_location(cx)) + .unwrap_or_else(|| { + workspaces_to_remove + .first() + .map(|workspace| { + let key = workspace.read(cx).project_group_key(cx); + (key.path_list().clone(), key) + }) + .unwrap_or_default() + }); + + let excluded = workspaces_to_remove.clone(); + let remove_task = multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.remove( + workspaces_to_remove, + move |this, window, cx| { + let active_workspace = this.workspace().clone(); + this.find_or_create_workspace( + fallback_paths, + project_group_key.host(), + Some(project_group_key), + |options, window, cx| { + connect_remote(active_workspace, options, window, cx) + }, + &excluded, + None, + OpenMode::Activate, + window, + cx, + ) + }, + window, + cx, + ) }); + let metadata = metadata.clone(); + let workspace = workspace.clone(); + cx.spawn_in(window, async move |this, cx| { + if !remove_task.await? { + return anyhow::Ok(()); + } + + for task in close_item_tasks { + let result: anyhow::Result<()> = task.await; + result.log_err(); + } + + this.update_in(cx, |this, window, cx| { + // If the terminal's workspace has already been removed, + // don't synthesize a fallback draft in the detached + // AgentPanel. + this.close_terminal_entry( + &metadata, + &workspace, + is_active, + neighbor.as_ref(), + !terminal_workspace_removed, + roots_to_archive, + window, + cx, + ); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } else if !close_item_tasks.is_empty() { + let metadata = metadata.clone(); + let workspace = workspace.clone(); + cx.spawn_in(window, async move |this, cx| { + for task in close_item_tasks { + let result: anyhow::Result<()> = task.await; + result.log_err(); + } + + this.update_in(cx, |this, window, cx| { + this.close_terminal_entry( + &metadata, + &workspace, + is_active, + neighbor.as_ref(), + true, + roots_to_archive, + window, + cx, + ); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } else { + self.close_terminal_entry( + metadata, + workspace, + is_active, + neighbor.as_ref(), + true, + roots_to_archive, + window, + cx, + ); + } + } + + fn close_terminal_entry( + &mut self, + metadata: &TerminalThreadMetadata, + workspace: &ThreadEntryWorkspace, + is_active: bool, + neighbor: Option<&ActivatableEntry>, + activate_panel_draft: bool, + roots_to_archive: Vec, + window: &mut Window, + cx: &mut Context, + ) { + let terminal_id = metadata.terminal_id; + // Closing from the sidebar must not steal focus, since the row's // workspace may not be the active workspace. - workspace.update(cx, |workspace, cx| { - if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| { - panel.close_terminal(terminal_id, window, cx); - }); - } - }); + if let ThreadEntryWorkspace::Open(workspace) = workspace { + workspace.update(cx, |workspace, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + if activate_panel_draft { + panel.close_terminal(terminal_id, window, cx); + } else { + panel.close_terminal_without_activating_draft(terminal_id, window, cx); + } + }); + } + }); + } + if let Some(store) = TerminalThreadMetadataStore::try_global(cx) { + store.update(cx, |store, cx| { + store.delete(terminal_id, cx); + }); + } + + self.start_detached_archive_worktree_task(roots_to_archive, cx); if is_active { self.active_entry = None; @@ -3407,6 +4070,84 @@ impl Sidebar { self.update_entries(cx); } + fn close_items_for_archived_worktrees( + &self, + roots_to_archive: &[thread_worktree_archive::RootPlan], + workspaces_to_remove: &mut Vec>, + window: &mut Window, + cx: &mut Context, + ) -> Vec>> { + if roots_to_archive.is_empty() { + return Vec::new(); + } + + let archive_paths: HashSet<&Path> = roots_to_archive + .iter() + .map(|root| root.root_path.as_path()) + .collect(); + + let mut mixed_workspaces: Vec<(Entity, Vec)> = Vec::new(); + + if let Some(multi_workspace) = self.multi_workspace.upgrade() { + let all_workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect(); + + for workspace in all_workspaces { + if workspaces_to_remove.contains(&workspace) { + continue; + } + + let project = workspace.read(cx).project().read(cx); + let visible_worktrees: Vec<_> = project + .visible_worktrees(cx) + .map(|worktree| (worktree.read(cx).id(), worktree.read(cx).abs_path())) + .collect(); + + let archived_worktree_ids: Vec = visible_worktrees + .iter() + .filter(|(_, path)| archive_paths.contains(path.as_ref())) + .map(|(id, _)| *id) + .collect(); + + if archived_worktree_ids.is_empty() { + continue; + } + + if visible_worktrees.len() == archived_worktree_ids.len() { + workspaces_to_remove.push(workspace); + } else { + mixed_workspaces.push((workspace, archived_worktree_ids)); + } + } + } + + let mut close_item_tasks = Vec::new(); + for (workspace, archived_worktree_ids) in &mixed_workspaces { + let panes: Vec<_> = workspace.read(cx).panes().to_vec(); + for pane in panes { + let items_to_close: Vec = pane + .read(cx) + .items() + .filter(|item| { + item.project_path(cx) + .is_some_and(|pp| archived_worktree_ids.contains(&pp.worktree_id)) + }) + .map(|item| item.item_id()) + .collect(); + + if !items_to_close.is_empty() { + let task = pane.update(cx, |pane, cx| { + pane.close_items(window, cx, SaveIntent::Close, &|item_id| { + items_to_close.contains(&item_id) + }) + }); + close_item_tasks.push(task); + } + } + } + + close_item_tasks + } + fn archive_thread( &mut self, session_id: &acp::SessionId, @@ -3433,6 +4174,41 @@ impl Sidebar { .as_ref() .map(|workspace| PathList::new(&workspace.read(cx).root_paths(cx))) }); + let thread_entry_workspace = self.contents.entries.iter().find_map(|entry| match entry { + ListEntry::Thread(thread) => thread_id + .map_or_else( + || thread.metadata.session_id.as_ref() == Some(session_id), + |tid| thread.metadata.thread_id == tid, + ) + .then(|| thread.workspace.clone()), + _ => None, + }); + + if let ( + Some(metadata), + Some(ThreadEntryWorkspace::Closed { + folder_paths, + project_group_key, + }), + ) = (metadata.as_ref(), thread_entry_workspace) + && Self::should_load_closed_workspace_for_archive( + &folder_paths, + &project_group_key, + metadata.remote_connection.as_ref(), + Some(metadata.thread_id), + None, + cx, + ) + { + self.open_workspace_and_archive_thread( + session_id.clone(), + folder_paths, + project_group_key, + window, + cx, + ); + return; + } // Compute which linked worktree roots should be archived from disk if // this thread is archived. This must happen before we remove any @@ -3471,23 +4247,20 @@ impl Sidebar { }) .filter(|plan| { thread_id.map_or(true, |tid| { - !store - .read(cx) - .path_is_referenced_by_other_unarchived_threads( - tid, - &plan.root_path, - metadata.remote_connection.as_ref(), - ) + !store.read(cx).path_is_referenced_by_unarchived_threads( + Some(tid), + &plan.root_path, + metadata.remote_connection.as_ref(), + ) }) }) .filter(|root| { - !workspaces.iter().any(|workspace| { - workspace_has_agent_panel_terminals(workspace, cx) - && workspace_contains_worktree_path( - workspace, - root.root_path.as_path(), - cx, - ) + TerminalThreadMetadataStore::try_global(cx).is_none_or(|terminal_store| { + !terminal_store.read(cx).path_is_referenced_by_terminal( + None, + root.root_path.as_path(), + metadata.remote_connection.as_ref(), + ) }) }) .collect::>() @@ -3528,7 +4301,7 @@ impl Sidebar { .read(cx) .workspace_for_paths(folder_paths, None, cx)?; - if workspace_has_agent_panel_terminals(&workspace, cx) { + if workspace_has_terminal_metadata(&workspace, cx) { return None; } @@ -3547,74 +4320,12 @@ impl Sidebar { // dropped without destroying the user's workspace layout. let mut workspaces_to_remove: Vec> = workspace_to_remove.into_iter().collect(); - let mut close_item_tasks: Vec>> = Vec::new(); - - let archive_paths: HashSet<&Path> = roots_to_archive - .iter() - .map(|root| root.root_path.as_path()) - .collect(); - - // Classify workspaces into "exclusive" (all worktrees archived) - // and "mixed" (some worktrees archived, some not). - let mut mixed_workspaces: Vec<(Entity, Vec)> = Vec::new(); - - if let Some(multi_workspace) = self.multi_workspace.upgrade() { - let all_workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect(); - - for workspace in all_workspaces { - if workspaces_to_remove.contains(&workspace) { - continue; - } - - let project = workspace.read(cx).project().read(cx); - let visible_worktrees: Vec<_> = project - .visible_worktrees(cx) - .map(|wt| (wt.read(cx).id(), wt.read(cx).abs_path())) - .collect(); - - let archived_worktree_ids: Vec = visible_worktrees - .iter() - .filter(|(_, path)| archive_paths.contains(path.as_ref())) - .map(|(id, _)| *id) - .collect(); - - if archived_worktree_ids.is_empty() { - continue; - } - - if visible_worktrees.len() == archived_worktree_ids.len() { - workspaces_to_remove.push(workspace); - } else { - mixed_workspaces.push((workspace, archived_worktree_ids)); - } - } - } - - // For mixed workspaces, close only items belonging to the - // worktrees being archived. - for (workspace, archived_worktree_ids) in &mixed_workspaces { - let panes: Vec<_> = workspace.read(cx).panes().to_vec(); - for pane in panes { - let items_to_close: Vec = pane - .read(cx) - .items() - .filter(|item| { - item.project_path(cx) - .is_some_and(|pp| archived_worktree_ids.contains(&pp.worktree_id)) - }) - .map(|item| item.item_id()) - .collect(); - - if !items_to_close.is_empty() { - let task = pane.update(cx, |pane, cx| { - pane.close_items(window, cx, SaveIntent::Close, &|item_id| { - items_to_close.contains(&item_id) - }) - }); - close_item_tasks.push(task); - } - } - } + let close_item_tasks = self.close_items_for_archived_worktrees( + &roots_to_archive, + &mut workspaces_to_remove, + window, + cx, + ); if !workspaces_to_remove.is_empty() { let multi_workspace = self.multi_workspace.upgrade().unwrap(); @@ -3853,6 +4564,29 @@ impl Sidebar { Some((task, cancel_tx)) } + fn start_detached_archive_worktree_task( + &self, + roots: Vec, + cx: &mut Context, + ) { + if roots.is_empty() { + return; + } + + let (cancel_tx, cancel_rx) = async_channel::bounded::<()>(1); + cx.spawn(async move |_this, cx| { + let outcome = Self::archive_worktree_roots(roots, cancel_rx, cx).await; + drop(cancel_tx); + match outcome { + Ok(ArchiveWorktreeOutcome::Success | ArchiveWorktreeOutcome::Cancelled) => {} + Err(error) => { + log::error!("Failed to archive worktree after closing terminal: {error:#}"); + } + } + }) + .detach(); + } + async fn archive_worktree_roots( roots: Vec, cancel_rx: async_channel::Receiver<()>, @@ -3945,9 +4679,9 @@ impl Sidebar { } } Some(ListEntry::Terminal(terminal)) => { + let metadata = terminal.metadata.clone(); let workspace = terminal.workspace.clone(); - let terminal_id = terminal.id; - self.close_terminal(&workspace, terminal_id, window, cx); + self.close_terminal(&metadata, &workspace, window, cx); } _ => {} } @@ -3982,7 +4716,7 @@ impl Sidebar { fn display_time(entry: &ListEntry) -> DateTime { match entry { ListEntry::Thread(thread) => Sidebar::thread_display_time(&thread.metadata), - ListEntry::Terminal(terminal) => terminal.created_at, + ListEntry::Terminal(terminal) => terminal.metadata.created_at, ListEntry::ProjectHeader { .. } => unreachable!(), } } @@ -4019,9 +4753,9 @@ impl Sidebar { .unwrap_or(entry.metadata.updated_at), ThreadSwitcherEntry::Terminal(entry) => self .terminal_last_accessed - .get(&entry.terminal_id) + .get(&entry.metadata.terminal_id) .copied() - .unwrap_or(entry.created_at), + .unwrap_or(entry.metadata.created_at), }; // .reverse() = most recent first @@ -4086,10 +4820,9 @@ impl Sidebar { } ListEntry::Terminal(terminal) => { let timestamp: SharedString = - format_history_entry_timestamp(terminal.created_at).into(); + format_history_entry_timestamp(terminal.metadata.created_at).into(); Some(ThreadSwitcherEntry::Terminal(ThreadSwitcherTerminalEntry { - terminal_id: terminal.id, - title: terminal.title.clone(), + metadata: terminal.metadata.clone(), workspace: terminal.workspace.clone(), project_name: current_header_label.clone(), worktrees: terminal @@ -4101,8 +4834,9 @@ impl Sidebar { wt }) .collect(), - created_at: terminal.created_at, - notified: self.contents.is_terminal_notified(terminal.id), + notified: self + .contents + .is_terminal_notified(terminal.metadata.terminal_id), timestamp, })) } @@ -4158,27 +4892,22 @@ impl Sidebar { Self::load_agent_thread_in_workspace(workspace, metadata, false, window, cx); } ThreadSwitcherSelection::Terminal { - terminal_id, + metadata, workspace, } => { - if let Some(multi_workspace) = self.multi_workspace.upgrade() { - multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.activate(workspace.clone(), None, window, cx); - }); - } - self.active_entry = Some(ActiveEntry::Terminal { - terminal_id: *terminal_id, - workspace: workspace.clone(), - }); - self.update_entries(cx); - workspace.update(cx, |workspace, cx| { - if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| { - panel.activate_terminal(*terminal_id, false, window, cx); + if let ThreadEntryWorkspace::Open(workspace) = workspace { + if let Some(multi_workspace) = self.multi_workspace.upgrade() { + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.activate(workspace.clone(), None, window, cx); }); } - workspace.reveal_panel::(window, cx); - }); + self.active_entry = Some(ActiveEntry::Terminal { + terminal_id: metadata.terminal_id, + workspace: workspace.clone(), + }); + self.update_entries(cx); + Self::load_agent_terminal_in_workspace(workspace, metadata, false, window, cx); + } } } } @@ -4211,11 +4940,11 @@ impl Sidebar { Self::load_agent_thread_in_workspace(workspace, metadata, true, window, cx); } ThreadSwitcherSelection::Terminal { - terminal_id, + metadata, workspace, } => { self.dismiss_thread_switcher(cx); - self.activate_terminal(workspace, *terminal_id, true, window, cx); + self.activate_terminal_entry(metadata.clone(), workspace.clone(), true, window, cx); } } } @@ -4537,24 +5266,26 @@ impl Sidebar { is_focused: bool, cx: &mut Context, ) -> AnyElement { - let id = ElementId::from(format!("terminal-{}", terminal.id)); - let timestamp = format_history_entry_timestamp(terminal.created_at); + let id = ElementId::from(format!("terminal-{}", terminal.metadata.terminal_id)); + let timestamp = format_history_entry_timestamp(terminal.metadata.created_at); let is_hovered = self.hovered_thread_index == Some(ix); let color = cx.theme().colors(); let sidebar_bg = color .title_bar_background .blend(color.panel_background.opacity(0.25)); - let terminal_id = terminal.id; + let metadata = terminal.metadata.clone(); let workspace = terminal.workspace.clone(); let focus_handle = self.focus_handle.clone(); let worktrees = apply_worktree_label_mode( terminal.worktrees.clone(), cx.flag_value::(), ); + let is_remote = terminal.workspace.is_remote(cx); - ThreadItem::new(id, terminal.title.clone()) + ThreadItem::new(id, terminal.metadata.title.clone()) .base_bg(sidebar_bg) .icon(IconName::Terminal) + .is_remote(is_remote) .worktrees(worktrees) .timestamp(timestamp) .notified(terminal.has_notification) @@ -4587,14 +5318,21 @@ impl Sidebar { } }) .on_click(cx.listener(move |this, _, window, cx| { - this.close_terminal(&workspace, terminal_id, window, cx); + this.close_terminal(&metadata, &workspace, window, cx); })), ) }) .on_click(cx.listener({ + let metadata = terminal.metadata.clone(); let workspace = terminal.workspace.clone(); move |this, _, window, cx| { - this.activate_terminal(&workspace, terminal_id, false, window, cx); + this.activate_terminal_entry( + metadata.clone(), + workspace.clone(), + false, + window, + cx, + ); } })) .into_any_element() @@ -4996,8 +5734,9 @@ impl Sidebar { } } ListEntry::Terminal(terminal) => { + let metadata = terminal.metadata.clone(); let workspace = terminal.workspace.clone(); - self.activate_terminal(&workspace, terminal.id, true, window, cx); + self.activate_terminal_entry(metadata, workspace, true, window, cx); } ListEntry::ProjectHeader { .. } => {} } @@ -5693,29 +6432,6 @@ impl Render for Sidebar { } } -fn terminal_entries_for_workspace( - workspace: &Entity, - branch_by_path: &HashMap, - cx: &App, -) -> impl Iterator { - let Some(agent_panel) = workspace.read(cx).panel::(cx) else { - return None.into_iter().flatten(); - }; - let terminals = agent_panel.read(cx).terminals(cx).into_iter().map( - move |terminal: AgentPanelTerminalInfo| TerminalEntry { - id: terminal.id, - title: terminal.title, - workspace: workspace.clone(), - worktrees: terminal_worktree_info(workspace, branch_by_path, cx), - created_at: terminal.created_at, - has_notification: terminal.has_notification, - highlight_positions: Vec::new(), - }, - ); - - Some(terminals).into_iter().flatten() -} - fn all_thread_infos_for_workspace( workspace: &Entity, cx: &App, diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index 4817ab5ebc6..cb385828dc6 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -3,8 +3,12 @@ use acp_thread::{AcpThread, PermissionOptions, StubAgentConnection}; use agent::ThreadStore; use agent_ui::{ ThreadId, + terminal_thread_metadata_store::{ + TerminalThreadMetadata, TerminalThreadMetadataStore, TestTerminalMetadataDbName, + }, test_support::{ - active_session_id, active_thread_id, open_thread_with_connection, send_message, + active_session_id, active_thread_id, open_thread_with_connection, + open_thread_with_custom_connection, send_message, }, thread_metadata_store::{ThreadMetadata, WorktreePaths}, }; @@ -28,6 +32,7 @@ fn init_test(cx: &mut TestAppContext) { editor::init(cx); ThreadStore::init_global(cx); ThreadMetadataStore::init_global(cx); + TerminalThreadMetadataStore::init_global(cx); language_model::LanguageModelRegistry::test(cx); prompt_store::init(cx); }); @@ -148,7 +153,7 @@ fn assert_remote_project_integration_sidebar_state( ListEntry::Terminal(terminal) => { panic!( "unexpected sidebar terminal while simulating remote project integration flicker: title=`{}`", - terminal.title + terminal.metadata.title ); } } @@ -534,7 +539,7 @@ fn visible_entries_as_strings( } } ListEntry::Terminal(terminal) => { - let title = &terminal.title; + let title = &terminal.metadata.title; let worktree = format_linked_worktree_chips(&terminal.worktrees); format!(" {title}{worktree}{selected}") } @@ -1502,11 +1507,25 @@ async fn test_agent_panel_terminals_appear_in_sidebar_and_search(cx: &mut TestAp ); assert!( sidebar.contents.entries.iter().any(|entry| { - matches!(entry, ListEntry::Terminal(terminal) if terminal.id == terminal_id && terminal.title.as_ref() == "Dev Server") + matches!(entry, ListEntry::Terminal(terminal) if terminal.metadata.terminal_id == terminal_id && terminal.metadata.title.as_ref() == "Dev Server") }), "expected the inserted terminal to appear in sidebar contents", ); }); + sidebar.read_with(cx, |_sidebar, cx| { + let store = TerminalThreadMetadataStore::global(cx).read(cx); + let metadata = store + .entry(terminal_id) + .expect("terminal metadata should be persisted"); + assert_eq!(metadata.title.as_ref(), "Dev Server"); + assert!( + metadata + .folder_paths() + .paths() + .iter() + .any(|path| path.as_path() == Path::new("/my-project")) + ); + }); type_in_search(&sidebar, "server", cx); assert_eq!( @@ -1521,6 +1540,127 @@ async fn test_agent_panel_terminals_appear_in_sidebar_and_search(cx: &mut TestAp ); } +#[gpui::test] +async fn test_agent_panel_terminal_metadata_remains_visible_after_panel_is_removed( + cx: &mut TestAppContext, +) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + let workspace = multi_workspace.read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }); + + let terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.remove_panel(&panel, window, cx); + }); + sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + assert!(workspace.read_with(cx, |workspace, cx| { + workspace.panel::(cx).is_none() + })); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Dev Server"] + ); + + sidebar.read_with(cx, |sidebar, _cx| { + assert!(sidebar.contents.entries.iter().any(|entry| { + matches!(entry, ListEntry::Terminal(terminal) if terminal.metadata.terminal_id == terminal_id) + })); + }); +} + +#[gpui::test] +async fn test_terminal_metadata_is_deduped_across_project_groups(cx: &mut TestAppContext) { + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.set_global(agent_ui::MaxIdleRetainedThreads(1)); + ThreadStore::init_global(cx); + ThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + let workspace_a = multi_workspace.read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }); + multi_workspace.update_in(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(project_b, window, cx); + }); + let terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Original", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + workspace_a.update_in(cx, |workspace, window, cx| { + workspace.remove_panel(&panel, window, cx); + }); + let now = Utc::now(); + let metadata = TerminalThreadMetadata { + terminal_id, + title: "Dev Server".into(), + custom_title: None, + created_at: now, + worktree_paths: WorktreePaths::from_path_lists( + PathList::new(&[PathBuf::from("/project-a")]), + PathList::new(&[PathBuf::from("/project-b")]), + ) + .unwrap(), + remote_connection: None, + working_directory: None, + }; + + cx.update(|_, cx| { + TerminalThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save(metadata, cx); + }); + }); + sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar + .contents + .entries + .iter() + .filter(|entry| { + matches!( + entry, + ListEntry::Terminal(terminal) + if terminal.metadata.terminal_id == terminal_id + ) + }) + .count(), + 1 + ); + }); +} + #[gpui::test] async fn test_agent_panel_terminal_shows_project_and_linked_worktree(cx: &mut TestAppContext) { agent_ui::test_support::init_test(cx); @@ -1586,6 +1726,218 @@ async fn test_agent_panel_terminal_shows_project_and_linked_worktree(cx: &mut Te ); } +#[gpui::test] +async fn test_terminal_close_event_on_archived_linked_worktree_removes_workspace( + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/worktrees/project/feature-a/project", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/worktrees/project/feature-a/project"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test( + fs.clone(), + ["/worktrees/project/feature-a/project".as_ref()], + cx, + ) + .await; + + main_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + let worktree_workspace = multi_workspace.update_in(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(worktree_project.clone(), window, cx) + }); + let worktree_panel = add_agent_panel(&worktree_workspace, cx); + + let archived_session_id = acp::SessionId::new(Arc::from("archived-wt-thread")); + save_thread_metadata( + archived_session_id.clone(), + Some("Archived Worktree Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + None, + None, + &worktree_project, + cx, + ); + let archived_thread_id = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry_by_session(&archived_session_id) + .expect("archived thread metadata should exist") + .thread_id + }); + cx.update(|_, cx| { + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.archive(archived_thread_id, None, cx); + }); + }); + save_thread_metadata( + acp::SessionId::new(Arc::from("main-thread")), + Some("Main Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + None, + None, + &main_project, + cx, + ); + + let terminal_id = worktree_panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + assert_eq!( + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace + .workspaces() + .count()), + 2, + "should start with main and linked worktree workspaces" + ); + let entries_before = visible_entries_as_strings(&sidebar, cx); + assert!( + entries_before + .iter() + .any(|entry| entry.contains("Dev Server") && entry.contains('{')), + "expected linked worktree terminal before closing, got: {entries_before:?}" + ); + + worktree_panel.update(cx, |panel, cx| { + panel.emit_test_terminal_close(terminal_id, cx); + }); + for _ in 0..4 { + cx.run_until_parked(); + } + + let terminal_metadata_deleted = cx.update(|_, cx| { + TerminalThreadMetadataStore::global(cx) + .read(cx) + .entry(terminal_id) + .is_none() + }); + assert!( + terminal_metadata_deleted, + "terminal metadata should be deleted after close" + ); + let unarchived_worktree_threads = cx.update(|_, cx| { + let worktree_path_list = + PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]); + ThreadMetadataStore::global(cx) + .read(cx) + .entries_for_path(&worktree_path_list, None) + .count() + }); + assert_eq!( + unarchived_worktree_threads, 0, + "closing the terminal must not create a fallback draft for the removed worktree" + ); + assert_eq!( + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace + .workspaces() + .count()), + 1, + "linked worktree workspace should be removed after closing its last terminal" + ); + let entries_after = visible_entries_as_strings(&sidebar, cx); + assert!( + !entries_after.iter().any(|entry| entry.contains('{')), + "no sidebar entry should reference the archived worktree, got: {entries_after:?}" + ); + assert!( + !fs.is_dir(Path::new("/worktrees/project/feature-a/project")) + .await, + "linked worktree directory should be removed from disk after closing its last terminal" + ); +} + +#[gpui::test] +async fn test_terminal_close_event_closes_sidebar_terminal(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + let terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Dev Server"] + ); + + panel.update(cx, |panel, cx| { + panel.emit_test_terminal_close(terminal_id, cx); + }); + cx.run_until_parked(); + + panel.read_with(cx, |panel, _cx| { + assert!(!panel.has_terminal(terminal_id)); + }); + sidebar.read_with(cx, |sidebar, _cx| { + assert!(sidebar.contents.entries.iter().all(|entry| { + !matches!(entry, ListEntry::Terminal(terminal) if terminal.metadata.terminal_id == terminal_id) + })); + }); + sidebar.read_with(cx, |_sidebar, cx| { + assert!( + TerminalThreadMetadataStore::global(cx) + .read(cx) + .entry(terminal_id) + .is_none(), + "terminal metadata should be deleted when the terminal requests close" + ); + }); +} + #[gpui::test] async fn test_agent_panel_terminal_notifications_update_sidebar(cx: &mut TestAppContext) { let project = init_test_project_with_agent_panel("/my-project", cx).await; @@ -1618,7 +1970,7 @@ async fn test_agent_panel_terminal_notifications_update_sidebar(cx: &mut TestApp assert!(sidebar.has_notifications(cx)); assert!(sidebar.contents.notified_terminals.contains(&build_terminal_id)); assert!(sidebar.contents.entries.iter().any(|entry| { - matches!(entry, ListEntry::Terminal(terminal) if terminal.id == build_terminal_id && terminal.has_notification) + matches!(entry, ListEntry::Terminal(terminal) if terminal.metadata.terminal_id == build_terminal_id && terminal.has_notification) })); }); @@ -1712,6 +2064,513 @@ async fn test_thread_switcher_can_activate_agent_panel_terminal(cx: &mut TestApp }); } +#[gpui::test] +async fn test_thread_switcher_includes_terminal_metadata_for_open_project_group( + cx: &mut TestAppContext, +) { + let project = init_test_project_with_agent_panel("/project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + let terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Feature Terminal", true, window, cx) + }) + .expect("test terminal should be inserted"); + panel.update_in(cx, |panel, window, cx| { + panel.close_terminal(terminal_id, window, cx); + }); + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-newer")), + Some("Newer Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + None, + None, + &project, + cx, + ); + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-older")), + Some("Older Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + None, + None, + &project, + cx, + ); + + let created_at = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(); + let metadata = TerminalThreadMetadata { + terminal_id, + title: "Feature Terminal".into(), + custom_title: None, + created_at, + worktree_paths: WorktreePaths::from_path_lists( + PathList::new(&[PathBuf::from("/project")]), + PathList::new(&[PathBuf::from("/project-feature")]), + ) + .unwrap(), + remote_connection: None, + working_directory: None, + }; + cx.update(|_, cx| { + TerminalThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save(metadata, cx); + }); + }); + sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, cx| { + let switcher = sidebar + .thread_switcher + .as_ref() + .expect("switcher should be open"); + assert!( + switcher + .read(cx) + .entries() + .iter() + .any(|entry| entry.terminal_id() == Some(terminal_id)), + "terminal metadata row should be included like a closed thread row" + ); + }); +} + +#[gpui::test] +async fn test_thread_switcher_preserves_closed_terminal_linked_worktree_workspace( + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/worktrees/project/feature-a/project", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/worktrees/project/feature-a/project"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + main_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + let terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Feature Terminal", true, window, cx) + }) + .expect("test terminal should be inserted"); + panel.update_in(cx, |panel, window, cx| { + panel.close_terminal(terminal_id, window, cx); + }); + let created_at = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(); + let worktree_folder_paths = + PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]); + let metadata = TerminalThreadMetadata { + terminal_id, + title: "Feature Terminal".into(), + custom_title: None, + created_at, + worktree_paths: WorktreePaths::from_path_lists( + PathList::new(&[PathBuf::from("/project")]), + worktree_folder_paths.clone(), + ) + .unwrap(), + remote_connection: None, + working_directory: None, + }; + cx.update(|_, cx| { + TerminalThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save(metadata, cx); + }); + }); + save_thread_metadata( + acp::SessionId::new(Arc::from("main-thread")), + Some("Main Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + None, + None, + &main_project, + cx, + ); + sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths(&worktree_folder_paths, None, cx) + }) + .is_none(), + "linked worktree workspace should start closed" + ); + + focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, cx| { + let switcher = sidebar + .thread_switcher + .as_ref() + .expect("switcher should be open"); + match switcher + .read(cx) + .selected_entry() + .expect("switcher should select the terminal row by default") + { + ThreadSwitcherEntry::Terminal(entry) => { + assert_eq!(entry.metadata.terminal_id, terminal_id); + match &entry.workspace { + ThreadEntryWorkspace::Closed { + folder_paths, + project_group_key, + } => { + assert_eq!(folder_paths, &worktree_folder_paths); + assert_eq!( + project_group_key.path_list(), + &PathList::new(&[PathBuf::from("/project")]) + ); + } + ThreadEntryWorkspace::Open(_) => { + panic!("closed terminal row should retain its linked worktree target") + } + } + } + ThreadSwitcherEntry::Thread(_) => { + panic!("terminal row should be selected by default") + } + } + }); +} + +#[gpui::test] +async fn test_archive_selected_terminal_archives_closed_linked_worktree(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/worktrees/project/feature-a/project", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/worktrees/project/feature-a/project"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + main_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + let terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Feature Terminal", true, window, cx) + }) + .expect("test terminal should be inserted"); + panel.update_in(cx, |panel, window, cx| { + panel.close_terminal(terminal_id, window, cx); + }); + let worktree_folder_paths = + PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]); + let metadata = TerminalThreadMetadata { + terminal_id, + title: "Feature Terminal".into(), + custom_title: None, + created_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + worktree_paths: WorktreePaths::from_path_lists( + PathList::new(&[PathBuf::from("/project")]), + worktree_folder_paths.clone(), + ) + .unwrap(), + remote_connection: None, + working_directory: None, + }; + cx.update(|_, cx| { + TerminalThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save(metadata, cx); + }); + }); + sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + let terminal_index = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .position(|entry| matches!(entry, ListEntry::Terminal(terminal) if terminal.metadata.terminal_id == terminal_id)) + .expect("terminal should be visible in sidebar") + }); + sidebar.read_with(cx, |sidebar, _cx| { + match &sidebar.contents.entries[terminal_index] { + ListEntry::Terminal(terminal) => match &terminal.workspace { + ThreadEntryWorkspace::Closed { folder_paths, .. } => { + assert_eq!(folder_paths, &worktree_folder_paths); + } + ThreadEntryWorkspace::Open(_) => { + panic!("linked worktree terminal should start closed") + } + }, + _ => panic!("expected terminal row"), + } + }); + + focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(terminal_index); + }); + cx.dispatch_action(ArchiveSelectedThread); + for _ in 0..8 { + cx.run_until_parked(); + } + + let terminal_metadata_deleted = cx.update(|_, cx| { + TerminalThreadMetadataStore::global(cx) + .read(cx) + .entry(terminal_id) + .is_none() + }); + assert!( + terminal_metadata_deleted, + "terminal metadata should be deleted after closing from the sidebar" + ); + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths(&worktree_folder_paths, None, cx) + }) + .is_none(), + "temporary linked worktree workspace should be removed after archiving" + ); + assert_eq!( + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace + .workspaces() + .count()), + 1, + "closing a closed linked worktree terminal should leave only the main workspace" + ); + assert!( + !fs.is_dir(Path::new("/worktrees/project/feature-a/project")) + .await, + "linked worktree directory should be removed from disk after closing its terminal" + ); +} + +#[gpui::test] +async fn test_archive_selected_thread_archives_closed_linked_worktree(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/worktrees/project/feature-a/project", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/worktrees/project/feature-a/project"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + main_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let worktree_session_id = acp::SessionId::new(Arc::from("worktree-thread")); + let worktree_folder_paths = + PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]); + save_thread_metadata_with_main_paths( + "worktree-thread", + "Worktree Thread", + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + cx, + ); + save_thread_metadata( + acp::SessionId::new(Arc::from("main-thread")), + Some("Main Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + None, + None, + &main_project, + cx, + ); + sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + let thread_index = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .position(|entry| matches!(entry, ListEntry::Thread(thread) if thread.metadata.session_id.as_ref() == Some(&worktree_session_id))) + .expect("worktree thread should be visible in sidebar") + }); + sidebar.read_with(cx, |sidebar, _cx| { + match &sidebar.contents.entries[thread_index] { + ListEntry::Thread(thread) => match &thread.workspace { + ThreadEntryWorkspace::Closed { folder_paths, .. } => { + assert_eq!(folder_paths, &worktree_folder_paths); + } + ThreadEntryWorkspace::Open(_) => { + panic!("linked worktree thread should start closed") + } + }, + _ => panic!("expected thread row"), + } + }); + + focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(thread_index); + }); + cx.dispatch_action(ArchiveSelectedThread); + for _ in 0..8 { + cx.run_until_parked(); + } + + let thread_archived = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry_by_session(&worktree_session_id) + .map(|thread| thread.archived) + }); + assert_eq!( + thread_archived, + Some(true), + "thread metadata should remain archived after worktree archival" + ); + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths(&worktree_folder_paths, None, cx) + }) + .is_none(), + "temporary linked worktree workspace should be removed after archiving" + ); + assert_eq!( + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace + .workspaces() + .count()), + 1, + "archiving a closed linked worktree thread should leave only the main workspace" + ); + assert!( + !fs.is_dir(Path::new("/worktrees/project/feature-a/project")) + .await, + "linked worktree directory should be removed from disk after archiving its thread" + ); +} + #[gpui::test] async fn test_archive_selected_thread_closes_selected_agent_panel_terminal( cx: &mut TestAppContext, @@ -1734,7 +2593,7 @@ async fn test_archive_selected_thread_closes_selected_agent_panel_terminal( .contents .entries .iter() - .position(|entry| matches!(entry, ListEntry::Terminal(terminal) if terminal.id == terminal_id)) + .position(|entry| matches!(entry, ListEntry::Terminal(terminal) if terminal.metadata.terminal_id == terminal_id)) .expect("terminal should be visible in sidebar") }); sidebar.update_in(cx, |sidebar, _window, _cx| { @@ -1748,9 +2607,16 @@ async fn test_archive_selected_thread_closes_selected_agent_panel_terminal( }); sidebar.read_with(cx, |sidebar, _cx| { assert!(sidebar.contents.entries.iter().all(|entry| { - !matches!(entry, ListEntry::Terminal(terminal) if terminal.id == terminal_id) + !matches!(entry, ListEntry::Terminal(terminal) if terminal.metadata.terminal_id == terminal_id) })); }); + sidebar.read_with(cx, |_sidebar, cx| { + let store = TerminalThreadMetadataStore::global(cx).read(cx); + assert!( + store.entry(terminal_id).is_none(), + "terminal metadata should be deleted when closing from the sidebar" + ); + }); } #[gpui::test] @@ -1759,10 +2625,6 @@ async fn test_closing_active_agent_panel_terminal_activates_neighbor(cx: &mut Te let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); - let workspace = multi_workspace.read_with(cx, |multi_workspace, _cx| { - multi_workspace.workspace().clone() - }); - let build_terminal_id = panel .update_in(cx, |panel, window, cx| { panel.insert_test_terminal("Build", true, window, cx) @@ -1775,8 +2637,23 @@ async fn test_closing_active_agent_panel_terminal_activates_neighbor(cx: &mut Te .expect("server test terminal should be inserted"); cx.run_until_parked(); + let (server_metadata, server_workspace) = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .find_map(|entry| match entry { + ListEntry::Terminal(terminal) + if terminal.metadata.terminal_id == server_terminal_id => + { + Some((terminal.metadata.clone(), terminal.workspace.clone())) + } + _ => None, + }) + .expect("server terminal should be visible in sidebar") + }); sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.close_terminal(&workspace, server_terminal_id, window, cx); + sidebar.close_terminal(&server_metadata, &server_workspace, window, cx); }); cx.run_until_parked(); @@ -4788,7 +5665,7 @@ async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_proje ListEntry::Terminal(terminal) => { panic!( "unexpected sidebar terminal while opening linked worktree thread: title=`{}`", - terminal.title + terminal.metadata.title ); } } @@ -10073,15 +10950,24 @@ mod property_test { let panel = workspace.read_with(cx, |workspace, cx| workspace.panel::(cx)); if let Some(panel) = panel { - let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![ - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( - "Done".into(), - )), - ]); - open_thread_with_connection(&panel, connection, cx); - send_message(&panel, cx); + let agent_id = AgentId::new(format!("prop-agent-{}", state.thread_counter)); + let connection = StubAgentConnection::new().with_agent_id(agent_id.clone()); + open_thread_with_custom_connection(&panel, connection.clone(), cx); + let thread_id = active_thread_id(&panel, cx); let session_id = active_session_id(&panel, cx); + // Make the thread non-draft without exercising the prompt + // send path; these invariants are about sidebar state, not + // git checkpointing during user prompts. + cx.update(|_, cx| { + connection.send_update( + session_id.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + "Done".into(), + )), + cx, + ); + }); + cx.run_until_parked(); state.saved_thread_ids.push(session_id.clone()); let title: SharedString = format!("Thread {}", state.thread_counter).into(); @@ -10090,15 +10976,24 @@ mod property_test { chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0) .unwrap() + chrono::Duration::seconds(state.thread_counter as i64); - save_thread_metadata( - session_id, - Some(title), + let metadata = cx.update(|_, cx| ThreadMetadata { + thread_id, + session_id: Some(session_id), + agent_id, + title: Some(title), + title_override: None, updated_at, - None, - None, - &project, - cx, - ); + created_at: None, + interacted_at: None, + worktree_paths: project.read(cx).worktree_paths(cx), + archived: false, + remote_connection: project.read(cx).remote_connection_options(cx), + }); + cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .update(cx, |store, cx| store.save(metadata, cx)) + }); + cx.run_until_parked(); } } Operation::SaveWorktreeThread { worktree_index } => { @@ -10564,11 +11459,12 @@ mod property_test { // content yet. let panel = active_workspace.read(cx).panel::(cx).unwrap(); let panel_has_content = panel.read(cx).active_thread_id(cx).is_some() - || panel.read(cx).active_conversation_view().is_some(); + || panel.read(cx).active_conversation_view().is_some() + || panel.read(cx).active_terminal_id().is_some(); let Some(entry) = sidebar.active_entry.as_ref() else { if panel_has_content { - anyhow::bail!("active_entry is None but panel has content (draft or thread)"); + anyhow::bail!("active_entry is None but panel has content"); } return Ok(()); }; @@ -10721,15 +11617,19 @@ mod property_test { use std::sync::atomic::{AtomicUsize, Ordering}; static NEXT_PROPTEST_DB: AtomicUsize = AtomicUsize::new(0); + let test_db_id = NEXT_PROPTEST_DB.fetch_add(1, Ordering::SeqCst); + cx.update(|cx| { + cx.set_global(TestTerminalMetadataDbName(format!( + "PROPTEST_TERMINAL_THREAD_METADATA_{test_db_id}" + ))); + }); + agent_ui::test_support::init_test(cx); cx.update(|cx| { cx.set_global(db::AppDatabase::test_new()); cx.set_global(agent_ui::MaxIdleRetainedThreads(1)); cx.set_global(agent_ui::thread_metadata_store::TestMetadataDbName( - format!( - "PROPTEST_THREAD_METADATA_{}", - NEXT_PROPTEST_DB.fetch_add(1, Ordering::SeqCst) - ), + format!("PROPTEST_THREAD_METADATA_{test_db_id}"), )); ThreadStore::init_global(cx); diff --git a/crates/sidebar/src/thread_switcher.rs b/crates/sidebar/src/thread_switcher.rs index a91ae5fe067..e2f9cddd747 100644 --- a/crates/sidebar/src/thread_switcher.rs +++ b/crates/sidebar/src/thread_switcher.rs @@ -1,5 +1,9 @@ use action_log::DiffStats; -use agent_ui::{TerminalId, thread_metadata_store::ThreadMetadata}; +#[cfg(test)] +use agent_ui::TerminalId; +use agent_ui::{ + terminal_thread_metadata_store::TerminalThreadMetadata, thread_metadata_store::ThreadMetadata, +}; use gpui::{ Action as _, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Modifiers, ModifiersChangedEvent, Render, ScrollHandle, SharedString, prelude::*, @@ -8,6 +12,8 @@ use ui::{AgentThreadStatus, ThreadItem, ThreadItemWorktreeInfo, WithScrollbar, p use workspace::{ModalView, Workspace}; use zed_actions::agents_sidebar::ToggleThreadSwitcher; +use super::ThreadEntryWorkspace; + #[derive(Clone)] pub(crate) struct ThreadSwitcherThreadEntry { pub title: SharedString, @@ -27,12 +33,10 @@ pub(crate) struct ThreadSwitcherThreadEntry { #[derive(Clone)] pub(crate) struct ThreadSwitcherTerminalEntry { - pub terminal_id: TerminalId, - pub title: SharedString, - pub workspace: Entity, + pub metadata: TerminalThreadMetadata, + pub(super) workspace: ThreadEntryWorkspace, pub project_name: Option, pub worktrees: Vec, - pub created_at: chrono::DateTime, pub notified: bool, pub timestamp: SharedString, } @@ -44,26 +48,26 @@ pub(crate) enum ThreadSwitcherEntry { } #[derive(Clone)] -pub(crate) enum ThreadSwitcherSelection { +pub(super) enum ThreadSwitcherSelection { Thread { metadata: ThreadMetadata, workspace: Entity, }, Terminal { - terminal_id: TerminalId, - workspace: Entity, + metadata: TerminalThreadMetadata, + workspace: ThreadEntryWorkspace, }, } impl ThreadSwitcherEntry { - pub(crate) fn selection(&self) -> ThreadSwitcherSelection { + pub(super) fn selection(&self) -> ThreadSwitcherSelection { match self { Self::Thread(entry) => ThreadSwitcherSelection::Thread { metadata: entry.metadata.clone(), workspace: entry.workspace.clone(), }, Self::Terminal(entry) => ThreadSwitcherSelection::Terminal { - terminal_id: entry.terminal_id, + metadata: entry.metadata.clone(), workspace: entry.workspace.clone(), }, } @@ -75,16 +79,17 @@ impl ThreadSwitcherEntry { "thread-switcher-thread-{:?}", entry.metadata.thread_id )), - Self::Terminal(entry) => { - SharedString::from(format!("thread-switcher-terminal-{}", entry.terminal_id)) - } + Self::Terminal(entry) => SharedString::from(format!( + "thread-switcher-terminal-{}", + entry.metadata.terminal_id + )), } } fn title(&self) -> SharedString { match self { Self::Thread(entry) => entry.title.clone(), - Self::Terminal(entry) => entry.title.clone(), + Self::Terminal(entry) => entry.metadata.title.clone(), } } @@ -172,12 +177,12 @@ impl ThreadSwitcherEntry { pub fn terminal_id(&self) -> Option { match self { Self::Thread(_) => None, - Self::Terminal(entry) => Some(entry.terminal_id), + Self::Terminal(entry) => Some(entry.metadata.terminal_id), } } } -pub(crate) enum ThreadSwitcherEvent { +pub(super) enum ThreadSwitcherEvent { Preview(ThreadSwitcherSelection), Confirmed(ThreadSwitcherSelection), Dismissed, From 68340172a1fa259b9f53541155eea9a3e9d6ec59 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 18 May 2026 14:22:22 +0200 Subject: [PATCH 012/289] agent: Remove unused `LanguageModelImage` APIs (#57050) Pulled out from #56866. Will help with MCP image support Release Notes: - N/A --- crates/agent/src/thread.rs | 1 - crates/agent_ui/src/context.rs | 1 - crates/language_model/src/request.rs | 16 ------ crates/language_model_core/src/request.rs | 53 +------------------ .../language_models/src/provider/mistral.rs | 1 - crates/open_ai/src/completion.rs | 1 - 6 files changed, 1 insertion(+), 72 deletions(-) diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index b2114fcfb1d..2fe5bc99303 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -4394,7 +4394,6 @@ impl From for acp::ContentBlock { fn convert_image(image_content: acp::ImageContent) -> LanguageModelImage { LanguageModelImage { source: image_content.data.into(), - size: None, } } diff --git a/crates/agent_ui/src/context.rs b/crates/agent_ui/src/context.rs index ad8c95ba3e6..904fbfd3556 100644 --- a/crates/agent_ui/src/context.rs +++ b/crates/agent_ui/src/context.rs @@ -53,7 +53,6 @@ pub fn load_context(mention_set: &Entity, cx: &mut App) -> Task loaded_context.images.push(LanguageModelImage { source: mention_image.data, - ..LanguageModelImage::empty() }), Mention::Link => {} } diff --git a/crates/language_model/src/request.rs b/crates/language_model/src/request.rs index b28e6087e48..edb5645a8d1 100644 --- a/crates/language_model/src/request.rs +++ b/crates/language_model/src/request.rs @@ -118,13 +118,7 @@ impl LanguageModelImageExt for LanguageModelImage { // SAFETY: The base64 encoder should not produce non-UTF8. let source = unsafe { String::from_utf8_unchecked(base64_image) }; - let (final_width, final_height) = processed_image.dimensions(); - Some(LanguageModelImage { - size: Some(ImageSize { - width: final_width as i32, - height: final_height as i32, - }), source: source.into(), }) }) @@ -229,15 +223,5 @@ mod tests { w, h ); - - let size = lm_image.size.expect("ImageSize should be present"); - assert_eq!( - size.width, w as i32, - "ImageSize.width should match the encoded PNG width after downscaling" - ); - assert_eq!( - size.height, h as i32, - "ImageSize.height should match the encoded PNG height after downscaling" - ); } } diff --git a/crates/language_model_core/src/request.rs b/crates/language_model_core/src/request.rs index b2b42c091bc..7f8f7c7d764 100644 --- a/crates/language_model_core/src/request.rs +++ b/crates/language_model_core/src/request.rs @@ -16,8 +16,6 @@ pub struct ImageSize { pub struct LanguageModelImage { /// A base64-encoded PNG image. pub source: SharedString, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub size: Option, } impl LanguageModelImage { @@ -30,59 +28,26 @@ impl LanguageModelImage { } pub fn empty() -> Self { - Self { - source: "".into(), - size: None, - } + Self { source: "".into() } } /// Parse Self from a JSON object with case-insensitive field names pub fn from_json(obj: &serde_json::Map) -> Option { let mut source = None; - let mut size_obj = None; for (k, v) in obj.iter() { match k.to_lowercase().as_str() { "source" => source = v.as_str(), - "size" => size_obj = v.as_object(), _ => {} } } let source = source?; - let size_obj = size_obj?; - - let mut width = None; - let mut height = None; - - for (k, v) in size_obj.iter() { - match k.to_lowercase().as_str() { - "width" => width = v.as_i64().map(|w| w as i32), - "height" => height = v.as_i64().map(|h| h as i32), - _ => {} - } - } - Some(Self { - size: Some(ImageSize { - width: width?, - height: height?, - }), source: SharedString::from(source.to_string()), }) } - pub fn estimate_tokens(&self) -> usize { - let Some(size) = self.size.as_ref() else { - return 0; - }; - let width = size.width.unsigned_abs() as usize; - let height = size.height.unsigned_abs() as usize; - - // From: https://docs.anthropic.com/en/docs/build-with-claude/vision#calculate-image-costs - (width * height) / 750 - } - pub fn to_base64_url(&self) -> String { format!("data:image/png;base64,{}", self.source) } @@ -92,7 +57,6 @@ impl std::fmt::Debug for LanguageModelImage { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("LanguageModelImage") .field("source", &format!("<{} bytes>", self.source.len())) - .field("size", &self.size) .finish() } } @@ -480,9 +444,6 @@ mod tests { match content { LanguageModelToolResultContent::Image(image) => { assert_eq!(image.source.as_ref(), "base64encodedimagedata"); - let size = image.size.expect("size"); - assert_eq!(size.width, 100); - assert_eq!(size.height, 200); } _ => panic!("Expected Image variant"), } @@ -491,16 +452,12 @@ mod tests { let json = serde_json::json!({ "image": { "source": "wrappedimagedata", - "size": {"width": 50, "height": 75} } }); let content: LanguageModelToolResultContent = serde_json::from_value(json).unwrap(); match content { LanguageModelToolResultContent::Image(image) => { assert_eq!(image.source.as_ref(), "wrappedimagedata"); - let size = image.size.expect("size"); - assert_eq!(size.width, 50); - assert_eq!(size.height, 75); } _ => panic!("Expected Image variant"), } @@ -508,15 +465,11 @@ mod tests { // Test case insensitive let json = serde_json::json!({ "Source": "caseinsensitive", - "Size": {"Width": 30, "Height": 40} }); let content: LanguageModelToolResultContent = serde_json::from_value(json).unwrap(); match content { LanguageModelToolResultContent::Image(image) => { assert_eq!(image.source.as_ref(), "caseinsensitive"); - let size = image.size.expect("size"); - assert_eq!(size.width, 30); - assert_eq!(size.height, 40); } _ => panic!("Expected Image variant"), } @@ -524,15 +477,11 @@ mod tests { // Test direct image object let json = serde_json::json!({ "source": "directimage", - "size": {"width": 200, "height": 300} }); let content: LanguageModelToolResultContent = serde_json::from_value(json).unwrap(); match content { LanguageModelToolResultContent::Image(image) => { assert_eq!(image.source.as_ref(), "directimage"); - let size = image.size.expect("size"); - assert_eq!(size.width, 200); - assert_eq!(size.height, 300); } _ => panic!("Expected Image variant"), } diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index 9776dfffbc8..30e3836e8bb 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -1003,7 +1003,6 @@ mod tests { MessageContent::Text("What's in this image?".into()), MessageContent::Image(LanguageModelImage { source: "base64data".into(), - size: None, }), ], cache: false, diff --git a/crates/open_ai/src/completion.rs b/crates/open_ai/src/completion.rs index aac33387df1..6e16f0961f2 100644 --- a/crates/open_ai/src/completion.rs +++ b/crates/open_ai/src/completion.rs @@ -1340,7 +1340,6 @@ mod tests { }; let user_image = LanguageModelImage { source: SharedString::from("aGVsbG8="), - size: None, }; let expected_image_url = user_image.to_base64_url(); From 770e20c8390f9b12ef50fb84792c7b6ea7d654aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Houl=C3=A9?= <13155277+tomhoule@users.noreply.github.com> Date: Mon, 18 May 2026 14:48:55 +0200 Subject: [PATCH 013/289] context_server: Handle bad WWW-Authenticate resource_metadata URLs (#53502) In MCP OAuth, when the resource_metadata URL from the WWW-Authenticate header from the MCP server is on the same origin, but points to a broken endpoint (for example Pydantic Logfire doubles the path component, producing /mcp/mcp), fall back to the RFC 9728 well-known URIs instead of failing outright. The header URL is still tried first, as per the MCP spec. Release Notes: - MCP OAuth: Handle bad URLs in WWW-Authenticate by falling back to the well known authorization server metadata URLs. --- crates/context_server/src/oauth.rs | 79 +++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/crates/context_server/src/oauth.rs b/crates/context_server/src/oauth.rs index c5afa28c9b8..53a35161be3 100644 --- a/crates/context_server/src/oauth.rs +++ b/crates/context_server/src/oauth.rs @@ -694,7 +694,19 @@ pub async fn fetch_protected_resource_metadata( www_authenticate: &WwwAuthenticate, ) -> Result { let candidate_urls = match &www_authenticate.resource_metadata { - Some(url) if url.origin() == server_url.origin() => vec![url.clone()], + Some(url) if url.origin() == server_url.origin() => { + // Try the header-provided URL first (per MCP spec: "use the resource + // metadata URL from the parsed WWW-Authenticate headers when present"), + // then fall back to RFC 9728 well-known URIs in case the header URL is + // wrong (e.g. a buggy server that doubles the path component). + let mut urls = vec![url.clone()]; + for fallback in protected_resource_metadata_urls(server_url) { + if !urls.contains(&fallback) { + urls.push(fallback); + } + } + urls + } Some(url) => { log::warn!( "Ignoring cross-origin resource_metadata URL {} \ @@ -1920,6 +1932,71 @@ mod tests { }); } + #[test] + fn test_fetch_protected_resource_metadata_falls_back_when_header_url_fails() { + // Reproduces the Pydantic Logfire case: the server's WWW-Authenticate + // header contains a resource_metadata URL with a doubled path (e.g. + // /mcp/mcp), which returns HTML instead of JSON. The client should + // fall back to the RFC 9728 well-known URL, which works correctly. + gpui::block_on(async { + let client = make_fake_http_client(|req| { + Box::pin(async move { + let uri = req.uri().to_string(); + if uri + == "https://mcp.example.com/.well-known/oauth-protected-resource/api/mcp/mcp" + { + // Buggy header URL returns HTML (like a SPA catch-all). + Ok(Response::builder() + .status(200) + .header("Content-Type", "text/html") + .body(AsyncBody::from(b"".to_vec())) + .unwrap()) + } else if uri + == "https://mcp.example.com/.well-known/oauth-protected-resource/api/mcp" + { + // Correct well-known URL returns valid metadata. + json_response( + 200, + r#"{ + "resource": "https://mcp.example.com/api/mcp", + "authorization_servers": ["https://auth.example.com"] + }"#, + ) + } else { + json_response(404, "{}") + } + }) + }); + + let server_url = Url::parse("https://mcp.example.com/api/mcp").unwrap(); + let www_auth = WwwAuthenticate { + resource_metadata: Some( + // Buggy URL with doubled path component. + Url::parse( + "https://mcp.example.com/.well-known/oauth-protected-resource/api/mcp/mcp", + ) + .unwrap(), + ), + scope: None, + error: None, + error_description: None, + }; + + let metadata = fetch_protected_resource_metadata(&client, &server_url, &www_auth) + .await + .unwrap(); + + assert_eq!( + metadata.resource.as_str(), + "https://mcp.example.com/api/mcp" + ); + assert_eq!( + metadata.authorization_servers[0].as_str(), + "https://auth.example.com/" + ); + }); + } + #[test] fn test_fetch_protected_resource_metadata_rejects_cross_origin_url() { gpui::block_on(async { From 79f998a5f3bcc0e1273121d061905c8b379a1f05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Houl=C3=A9?= <13155277+tomhoule@users.noreply.github.com> Date: Mon, 18 May 2026 14:49:09 +0200 Subject: [PATCH 014/289] context_server: Mirror authorization server grant_types_supported (#53501) In MCP OAuth, mirror the authorization server's grant_types_supported in the DCR registration body instead of hardcoding just authorization_code. Logfire's auth server requires both authorization_code and refresh_token in grant_types, and we already uses refresh tokens, so the only issue was not advertising the capability during registration. The DCR body now intersects our supported grant types with what the server advertises, or sends all of ours when the server metadata omits grant_types_supported. Without this change, the Pydantic Logfire MCP auth server refuses our client registration. Release Notes: - MCP: Improve selection of the `grant_types` we send during OAuth dynamic client registration. --- crates/context_server/src/oauth.rs | 84 ++++++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 10 deletions(-) diff --git a/crates/context_server/src/oauth.rs b/crates/context_server/src/oauth.rs index 53a35161be3..098709555c8 100644 --- a/crates/context_server/src/oauth.rs +++ b/crates/context_server/src/oauth.rs @@ -146,6 +146,7 @@ pub struct AuthServerMetadata { pub token_endpoint: Url, pub registration_endpoint: Option, pub scopes_supported: Option>, + pub grant_types_supported: Option>, pub code_challenge_methods_supported: Option>, pub client_id_metadata_document_supported: bool, } @@ -672,11 +673,30 @@ pub fn token_refresh_params( /// port (e.g. `http://127.0.0.1:12345/callback`). Some auth servers do strict /// redirect URI matching even for loopback addresses, so we register the /// exact URI we intend to use. -pub fn dcr_registration_body(redirect_uri: &str) -> serde_json::Value { +/// The grant types Zed can use. Intersected with the server's +/// `grant_types_supported` to build the DCR request. +const SUPPORTED_GRANT_TYPES: &[&str] = &["authorization_code", "refresh_token"]; + +pub fn dcr_registration_body( + redirect_uri: &str, + server_grant_types: Option<&[String]>, +) -> serde_json::Value { + // Use the intersection of what we support and what the server advertises. + // When the server doesn't advertise grant_types_supported, send all of + // ours β€” the server will reject what it doesn't like. + let grant_types: Vec<&str> = match server_grant_types { + Some(server) => SUPPORTED_GRANT_TYPES + .iter() + .copied() + .filter(|gt| server.iter().any(|s| s == *gt)) + .collect(), + None => SUPPORTED_GRANT_TYPES.to_vec(), + }; + serde_json::json!({ "client_name": "Zed", "redirect_uris": [redirect_uri], - "grant_types": ["authorization_code"], + "grant_types": grant_types, "response_types": ["code"], "token_endpoint_auth_method": "none" }) @@ -772,6 +792,7 @@ pub async fn fetch_auth_server_metadata( return Ok(AuthServerMetadata { issuer: reported_issuer, + grant_types_supported: response.grant_types_supported, authorization_endpoint: response .authorization_endpoint .ok_or_else(|| anyhow!("missing authorization_endpoint"))?, @@ -858,7 +879,18 @@ pub async fn resolve_client_registration( }), ClientRegistrationStrategy::Dcr { registration_endpoint, - } => perform_dcr(http_client, ®istration_endpoint, redirect_uri).await, + } => { + perform_dcr( + http_client, + ®istration_endpoint, + redirect_uri, + discovery + .auth_server_metadata + .grant_types_supported + .as_deref(), + ) + .await + } ClientRegistrationStrategy::Unavailable => { bail!("authorization server supports neither CIMD nor DCR") } @@ -872,10 +904,11 @@ pub async fn perform_dcr( http_client: &Arc, registration_endpoint: &Url, redirect_uri: &str, + server_grant_types: Option<&[String]>, ) -> Result { validate_oauth_url(registration_endpoint)?; - let body = dcr_registration_body(redirect_uri); + let body = dcr_registration_body(redirect_uri, server_grant_types); let body_bytes = serde_json::to_vec(&body)?; let request = Request::builder() @@ -1094,6 +1127,8 @@ struct AuthServerMetadataResponse { #[serde(default)] scopes_supported: Option>, #[serde(default)] + grant_types_supported: Option>, + #[serde(default)] code_challenge_methods_supported: Option>, #[serde(default)] client_id_metadata_document_supported: Option, @@ -1595,6 +1630,7 @@ mod tests { scopes_supported: None, code_challenge_methods_supported: Some(vec!["S256".into()]), client_id_metadata_document_supported: true, + grant_types_supported: None, }; assert_eq!( determine_registration_strategy(&metadata), @@ -1615,6 +1651,7 @@ mod tests { scopes_supported: None, code_challenge_methods_supported: Some(vec!["S256".into()]), client_id_metadata_document_supported: false, + grant_types_supported: None, }; assert_eq!( determine_registration_strategy(&metadata), @@ -1634,6 +1671,7 @@ mod tests { scopes_supported: None, code_challenge_methods_supported: Some(vec!["S256".into()]), client_id_metadata_document_supported: false, + grant_types_supported: None, }; assert_eq!( determine_registration_strategy(&metadata), @@ -1690,6 +1728,7 @@ mod tests { scopes_supported: None, code_challenge_methods_supported: Some(vec!["S256".into()]), client_id_metadata_document_supported: true, + grant_types_supported: None, }; let pkce = PkceChallenge { verifier: "test_verifier".into(), @@ -1732,6 +1771,7 @@ mod tests { scopes_supported: None, code_challenge_methods_supported: Some(vec!["S256".into()]), client_id_metadata_document_supported: false, + grant_types_supported: None, }; let pkce = PkceChallenge { verifier: "v".into(), @@ -1815,15 +1855,35 @@ mod tests { // -- DCR body test ------------------------------------------------------- #[test] - fn test_dcr_registration_body_shape() { - let body = dcr_registration_body("http://127.0.0.1:12345/callback"); + fn test_dcr_registration_body_without_server_metadata() { + // When server metadata is unavailable, include all supported grant types. + let body = dcr_registration_body("http://127.0.0.1:12345/callback", None); assert_eq!(body["client_name"], "Zed"); assert_eq!(body["redirect_uris"][0], "http://127.0.0.1:12345/callback"); assert_eq!(body["grant_types"][0], "authorization_code"); + assert_eq!(body["grant_types"][1], "refresh_token"); assert_eq!(body["response_types"][0], "code"); assert_eq!(body["token_endpoint_auth_method"], "none"); } + #[test] + fn test_dcr_registration_body_mirrors_server_grant_types() { + // When the server only supports authorization_code, omit refresh_token. + let server_types = vec!["authorization_code".to_string()]; + let body = dcr_registration_body("http://127.0.0.1:12345/callback", Some(&server_types)); + assert_eq!(body["grant_types"][0], "authorization_code"); + assert!(body["grant_types"].as_array().unwrap().len() == 1); + + // When the server supports both, include both. + let server_types = vec![ + "authorization_code".to_string(), + "refresh_token".to_string(), + ]; + let body = dcr_registration_body("http://127.0.0.1:12345/callback", Some(&server_types)); + assert_eq!(body["grant_types"][0], "authorization_code"); + assert_eq!(body["grant_types"][1], "refresh_token"); + } + // -- Test helpers for async/HTTP tests ----------------------------------- fn make_fake_http_client( @@ -2351,6 +2411,7 @@ mod tests { scopes_supported: None, code_challenge_methods_supported: Some(vec!["S256".into()]), client_id_metadata_document_supported: true, + grant_types_supported: None, }; let tokens = exchange_code( @@ -2425,6 +2486,7 @@ mod tests { scopes_supported: None, code_challenge_methods_supported: Some(vec!["S256".into()]), client_id_metadata_document_supported: true, + grant_types_supported: None, }; let result = exchange_code( @@ -2461,9 +2523,10 @@ mod tests { }); let endpoint = Url::parse("https://auth.example.com/register").unwrap(); - let registration = perform_dcr(&client, &endpoint, "http://127.0.0.1:9999/callback") - .await - .unwrap(); + let registration = + perform_dcr(&client, &endpoint, "http://127.0.0.1:9999/callback", None) + .await + .unwrap(); assert_eq!(registration.client_id, "dynamic-client-001"); assert_eq!( @@ -2483,7 +2546,8 @@ mod tests { }); let endpoint = Url::parse("https://auth.example.com/register").unwrap(); - let result = perform_dcr(&client, &endpoint, "http://127.0.0.1:9999/callback").await; + let result = + perform_dcr(&client, &endpoint, "http://127.0.0.1:9999/callback", None).await; assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("403")); From 533e975df091b1547ec44e0eb5e15de8b00d627f Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Mon, 18 May 2026 14:59:22 +0200 Subject: [PATCH 015/289] extension_cli: Disallow slash command extensions (#56864) This will make CI error when we receive updates or new slash command extensions. Release Notes: - N/A --------- Co-authored-by: Marshall Bowers --- Cargo.lock | 1 + crates/extension_cli/Cargo.toml | 1 + crates/extension_cli/src/main.rs | 127 +++++++++++++++++++++++++++++-- 3 files changed, 123 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c90f3ef4f7e..cfe21109573 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6100,6 +6100,7 @@ dependencies = [ "snippet_provider", "task", "theme_settings", + "thiserror 2.0.17", "tokio", "toml 0.8.23", "tree-sitter", diff --git a/crates/extension_cli/Cargo.toml b/crates/extension_cli/Cargo.toml index c019a323196..2170647845b 100644 --- a/crates/extension_cli/Cargo.toml +++ b/crates/extension_cli/Cargo.toml @@ -30,6 +30,7 @@ settings_content.workspace = true snippet_provider.workspace = true task.workspace = true theme_settings.workspace = true +thiserror.workspace = true tokio = { workspace = true, features = ["full"] } toml.workspace = true tree-sitter.workspace = true diff --git a/crates/extension_cli/src/main.rs b/crates/extension_cli/src/main.rs index 38dc626562b..737b497fef0 100644 --- a/crates/extension_cli/src/main.rs +++ b/crates/extension_cli/src/main.rs @@ -296,17 +296,47 @@ async fn copy_extension_resources( Ok(()) } -fn validate_extension_features(provides: &BTreeSet) -> Result<()> { +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +enum ExtensionFeatureError { + #[error("extension does not provide any features")] + NoFeatures, + #[error("extension must not provide other features along with themes")] + ThemesMixedWithOtherFeatures, + #[error("extension must not provide other features along with icon themes")] + IconThemesMixedWithOtherFeatures, + #[error( + "Slash commands have been deprecated and \ + the slash command API will be removed in a future release. {}", + if *.sole_feature { + "Slash command extensions will no longer be accepted at this time." + } else { + "Please remove any slash-command related code from your extension." + } + )] + SlashCommandsDeprecated { sole_feature: bool }, +} + +fn validate_extension_features( + provides: &BTreeSet, +) -> Result<(), ExtensionFeatureError> { if provides.is_empty() { - bail!("extension does not provide any features"); + return Err(ExtensionFeatureError::NoFeatures); } - if provides.contains(&ExtensionProvides::Themes) && provides.len() != 1 { - bail!("extension must not provide other features along with themes"); + let provides_single_feature = provides.len() == 1; + + if provides.contains(&ExtensionProvides::Themes) && !provides_single_feature { + return Err(ExtensionFeatureError::ThemesMixedWithOtherFeatures); } - if provides.contains(&ExtensionProvides::IconThemes) && provides.len() != 1 { - bail!("extension must not provide other features along with icon themes"); + if provides.contains(&ExtensionProvides::IconThemes) && !provides_single_feature { + return Err(ExtensionFeatureError::IconThemesMixedWithOtherFeatures); + } + + if provides.contains(&ExtensionProvides::SlashCommands) { + return Err(ExtensionFeatureError::SlashCommandsDeprecated { + sole_feature: provides_single_feature, + }); } Ok(()) @@ -470,3 +500,88 @@ async fn test_snippets( Ok(()) } + +#[cfg(test)] +mod tests { + use cloud_api_types::ExtensionProvides; + + use super::*; + + #[test] + fn test_validate_empty_features() { + let provides = BTreeSet::new(); + assert_eq!( + validate_extension_features(&provides), + Err(ExtensionFeatureError::NoFeatures), + ); + } + + #[test] + fn test_validate_single_language_feature() { + let provides = BTreeSet::from([ExtensionProvides::Languages]); + assert_eq!(validate_extension_features(&provides), Ok(())); + } + + #[test] + fn test_validate_single_themes_feature() { + let provides = BTreeSet::from([ExtensionProvides::Themes]); + assert_eq!(validate_extension_features(&provides), Ok(())); + } + + #[test] + fn test_validate_themes_with_other_features() { + let provides = BTreeSet::from([ExtensionProvides::Themes, ExtensionProvides::Languages]); + assert_eq!( + validate_extension_features(&provides), + Err(ExtensionFeatureError::ThemesMixedWithOtherFeatures), + ); + } + + #[test] + fn test_validate_single_icon_themes_feature() { + let provides = BTreeSet::from([ExtensionProvides::IconThemes]); + assert_eq!(validate_extension_features(&provides), Ok(())); + } + + #[test] + fn test_validate_icon_themes_with_other_features() { + let provides = BTreeSet::from([ExtensionProvides::IconThemes, ExtensionProvides::Grammars]); + assert_eq!( + validate_extension_features(&provides), + Err(ExtensionFeatureError::IconThemesMixedWithOtherFeatures), + ); + } + + #[test] + fn test_validate_slash_commands_only() { + let provides = BTreeSet::from([ExtensionProvides::SlashCommands]); + assert_eq!( + validate_extension_features(&provides), + Err(ExtensionFeatureError::SlashCommandsDeprecated { sole_feature: true }), + ); + } + + #[test] + fn test_validate_slash_commands_with_other_features() { + let provides = BTreeSet::from([ + ExtensionProvides::SlashCommands, + ExtensionProvides::Languages, + ]); + assert_eq!( + validate_extension_features(&provides), + Err(ExtensionFeatureError::SlashCommandsDeprecated { + sole_feature: false + }), + ); + } + + #[test] + fn test_validate_multiple_non_theme_features() { + let provides = BTreeSet::from([ + ExtensionProvides::Languages, + ExtensionProvides::Grammars, + ExtensionProvides::LanguageServers, + ]); + assert_eq!(validate_extension_features(&provides), Ok(())); + } +} From 8ce894b909ec3cbbb84e1bc8eedc542a5f975c61 Mon Sep 17 00:00:00 2001 From: Koya Masuda <71430401+koxya@users.noreply.github.com> Date: Mon, 18 May 2026 22:13:41 +0900 Subject: [PATCH 016/289] git_ui: Count commit title length by characters instead of bytes (#57025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # What - Replace `title.len()` with `title.chars().count()` in the commit title length check, so the limit is measured in Unicode characters instead of UTF-8 bytes. - Applied in two places that share the same logic: - `crates/git_ui/src/git_panel.rs` β€” Git panel's inline warning - `crates/git_ui/src/commit_modal.rs` β€” commit modal's inline warning # Why The commit title length check used str::len(), which returns UTF-8 byte length rather than character count. As a result, titles containing multi-byte characters (Japanese, Chinese, emoji, etc.) triggered the warning far below the configured commit_title_max_length β€” around 24 characters instead of the default 72. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - Before https://github.com/user-attachments/assets/a895530c-2f73-470c-97fa-29d9467c14e1 - After https://github.com/user-attachments/assets/ffbe1ba2-0ccc-4b02-87f5-836da7841dd9 - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - Fixed commit title length check miscounting multi-byte characters as multiple characters. --- crates/git_ui/src/commit_modal.rs | 6 +++-- crates/git_ui/src/git_panel.rs | 43 ++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs index 3ec5453f9b7..5da2f670f31 100644 --- a/crates/git_ui/src/commit_modal.rs +++ b/crates/git_ui/src/commit_modal.rs @@ -1,5 +1,7 @@ use crate::branch_picker::{self, BranchList}; -use crate::git_panel::{GitPanel, commit_message_editor, panel_editor_style}; +use crate::git_panel::{ + GitPanel, commit_message_editor, commit_title_exceeds_limit, panel_editor_style, +}; use crate::git_panel_settings::GitPanelSettings; use git::repository::CommitOptions; use git::{Amend, Commit, GenerateCommitMessage, Signoff}; @@ -548,7 +550,7 @@ impl Render for CommitModal { .text(cx) .lines() .next() - .is_some_and(|title| title.len() > max_title_length) + .is_some_and(|title| commit_title_exceeds_limit(title, max_title_length)) } else { false }; diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 9bf5d047c45..04246fcd645 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -4566,7 +4566,7 @@ impl GitPanel { .text(cx) .lines() .next() - .is_some_and(|title| title.len() > max_title_length) + .is_some_and(|title| commit_title_exceeds_limit(title, max_title_length)) } else { false }; @@ -7438,6 +7438,10 @@ fn format_git_error_toast_message(error: &anyhow::Error) -> String { } } +pub(crate) fn commit_title_exceeds_limit(title: &str, max_length: usize) -> bool { + max_length > 0 && title.chars().count() > max_length +} + #[cfg(test)] mod tests { use git::{ @@ -8916,6 +8920,43 @@ mod tests { } } + #[test] + fn test_commit_title_exceeds_limit() { + // ASCII only + let within_ascii = "abcde"; + let exceeds_ascii = "abcdef"; + assert!(!commit_title_exceeds_limit(within_ascii, 5)); + assert!(commit_title_exceeds_limit(exceeds_ascii, 5)); + + // Multi-byte characters are counted as grapheme clusters + let within_japanese = "γ‚γ„γ†γˆγŠ"; // 5 chars, 15 bytes + let exceeds_japanese = "γ‚γ„γ†γˆγŠγ‹"; // 6 chars, 18 bytes + assert!(!commit_title_exceeds_limit(within_japanese, 5)); + assert!(commit_title_exceeds_limit(exceeds_japanese, 5)); + + // Mixed ASCII + multi-byte + let within_mixed = "abcあ"; + let exceeds_mixed = "abcああ"; + assert!(!commit_title_exceeds_limit(within_mixed, 4)); + assert!(commit_title_exceeds_limit(exceeds_mixed, 4)); + + // Emoji counts as one character each + let within_emoji = "πŸš€"; + let exceeds_emoji = "πŸš€πŸš€"; + assert!(!commit_title_exceeds_limit(within_emoji, 1)); + assert!(commit_title_exceeds_limit(exceeds_emoji, 1)); + + // A max_length of 0 disables the limit check + assert!(!commit_title_exceeds_limit( + "anything goes when disabled", + 0 + )); + assert!(!commit_title_exceeds_limit("", 0)); + + // Empty title never exceeds a positive limit + assert!(!commit_title_exceeds_limit("", 72)); + } + #[gpui::test] async fn test_dispatch_context_with_focus_states(cx: &mut TestAppContext) { init_test(cx); From 62c01be72eb72b4a87f13d1fcdb8217a077950e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Houl=C3=A9?= <13155277+tomhoule@users.noreply.github.com> Date: Mon, 18 May 2026 16:49:40 +0200 Subject: [PATCH 017/289] cloud_api_client: Add update_system_settings (#56843) Wire up the client side of the new `PATCH /client/system_settings` endpoint added in zed-industries/cloud#2444, so we can persist the currently selected organization on a per-system basis. This PR only adds the request types and the client method; hooking it up to the actual organization switcher in the editor will come next. The endpoint requires the `x-zed-system-id` header (the server returns 400 without it), so the method does not take an Option like the other client calls. Release Notes: - N/A --- .../cloud_api_client/src/cloud_api_client.rs | 50 +++++++++++++++++++ crates/cloud_api_types/src/cloud_api_types.rs | 10 ++++ 2 files changed, 60 insertions(+) diff --git a/crates/cloud_api_client/src/cloud_api_client.rs b/crates/cloud_api_client/src/cloud_api_client.rs index 1121dc47572..dc00d001d3b 100644 --- a/crates/cloud_api_client/src/cloud_api_client.rs +++ b/crates/cloud_api_client/src/cloud_api_client.rs @@ -239,6 +239,56 @@ impl CloudApiClient { serde_json::from_str(&body).map_err(|e| ClientApiError::InvalidResponse(e.into())) } + pub async fn update_system_settings( + &self, + system_id: String, + body: UpdateSystemSettingsBody, + ) -> Result { + let host = self.cloud_host(); + let request_builder = Request::builder() + .method(Method::PATCH) + .uri( + self.http_client + .build_zed_cloud_url("/client/system_settings") + .map_err(ClientApiError::RequestBuildFailed)? + .as_ref(), + ) + .header(ZED_SYSTEM_ID_HEADER_NAME, system_id); + + let request = self.build_request(request_builder, Json(body))?; + + let mut response = self.http_client.send(request).await.map_err(|source| { + ClientApiError::ConnectionFailed { + host: host.clone(), + source, + } + })?; + + if !response.status().is_success() { + if response.status() == StatusCode::UNAUTHORIZED { + return Err(ClientApiError::Unauthorized); + } + + let mut body = String::new(); + response.body_mut().read_to_string(&mut body).await.ok(); + + return Err(ClientApiError::ServerError { + host, + status: response.status(), + body, + }); + } + + let mut body = String::new(); + response + .body_mut() + .read_to_string(&mut body) + .await + .map_err(|e| ClientApiError::InvalidResponse(e.into()))?; + + serde_json::from_str(&body).map_err(|e| ClientApiError::InvalidResponse(e.into())) + } + pub async fn validate_credentials(&self, user_id: u32, access_token: &str) -> Result { let request = build_request( Request::builder().method(Method::GET).uri( diff --git a/crates/cloud_api_types/src/cloud_api_types.rs b/crates/cloud_api_types/src/cloud_api_types.rs index 8966d02d0de..0422c4fa035 100644 --- a/crates/cloud_api_types/src/cloud_api_types.rs +++ b/crates/cloud_api_types/src/cloud_api_types.rs @@ -87,6 +87,16 @@ pub struct CreateLlmTokenResponse { pub token: LlmToken, } +#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)] +pub struct UpdateSystemSettingsBody { + pub selected_organization_id: Option, +} + +#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)] +pub struct SystemSettings { + pub selected_organization_id: Option, +} + #[derive(Debug, PartialEq, Serialize, Deserialize)] pub struct SubmitAgentThreadFeedbackBody { pub organization_id: Option, From 988f083fc515095d440de5d813074c2cf8740f4d Mon Sep 17 00:00:00 2001 From: Neel Date: Mon, 18 May 2026 15:59:50 +0100 Subject: [PATCH 018/289] docs: Update threshold billing section (#57060) Release Notes: - N/A --- docs/src/ai/billing.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/src/ai/billing.md b/docs/src/ai/billing.md index 219f2fae1da..d5fc6750e83 100644 --- a/docs/src/ai/billing.md +++ b/docs/src/ai/billing.md @@ -30,6 +30,8 @@ For example, - You use $12 of incremental tokens in the month of February, with the first $10 spent on February 15. You'll receive an invoice for $10 on February 15. - On March 1, you receive an invoice for $12: $10 (March Pro subscription) and $2 in leftover token spend, since your usage didn't cross the $10 threshold. +For high-volume users, the threshold automatically scales up over time to keep invoicing manageable, so subsequent invoices may trigger at larger increments rather than every $10. + ### Payment failures {#payment-failures} If payment of an invoice fails, Zed will block usage of our hosted models until the payment is complete. Email [billing-support@zed.dev](mailto:billing-support@zed.dev) for assistance. From d3d5fb0d15173e68fd2d01195c1b0b2f096ca379 Mon Sep 17 00:00:00 2001 From: Neel Date: Mon, 18 May 2026 16:09:21 +0100 Subject: [PATCH 019/289] zed: Improve `zed://` URL handling (#57047) Release Notes: - Improved `zed://` and `zed://agent` URL handling --- crates/workspace/src/workspace.rs | 12 ++--- crates/zed/src/main.rs | 31 ++++++++++++ crates/zed/src/zed/open_listener.rs | 73 ++++++++++++++++++++++++++++- 3 files changed, 109 insertions(+), 7 deletions(-) diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 90e497eb4e2..da8ffe972ee 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -670,7 +670,11 @@ fn prompt_and_open_paths( create_new_window: bool, cx: &mut App, ) { - if let Some(workspace_window) = local_workspace_windows(cx).into_iter().next() { + if let Some(workspace_window) = + workspace_windows_for_location(&SerializedWorkspaceLocation::Local, cx) + .into_iter() + .next() + { workspace_window .update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspace().clone(); @@ -9471,7 +9475,7 @@ pub async fn get_any_active_multi_workspace( activate_any_workspace_window(&mut cx).context("could not open zed") } -fn activate_any_workspace_window(cx: &mut AsyncApp) -> Option> { +pub fn activate_any_workspace_window(cx: &mut AsyncApp) -> Option> { cx.update(|cx| { if let Some(workspace_window) = cx .active_window() @@ -9492,10 +9496,6 @@ fn activate_any_workspace_window(cx: &mut AsyncApp) -> Option Vec> { - workspace_windows_for_location(&SerializedWorkspaceLocation::Local, cx) -} - pub fn workspace_windows_for_location( serialized_location: &SerializedWorkspaceLocation, cx: &App, diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index affe1521a68..4cbbf700f57 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -925,6 +925,14 @@ fn main() { .ok() .and_then(|request| OpenRequest::parse(request, cx).log_err()) { + Some(request) if request.is_focus_app_only() => cx.spawn({ + let app_state = app_state.clone(); + async move |cx| { + if let Err(e) = restore_or_create_workspace(app_state, cx).await { + fail_to_open_window_async(e, cx) + } + } + }), Some(request) => { handle_open_request(request, app_state.clone(), cx); Task::ready(()) @@ -978,6 +986,15 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut cx.spawn(async move |cx| handle_cli_connection(connection, app_state, cx).await) .detach(); } + OpenRequestKind::FocusApp => { + cx.spawn(async move |cx| { + if workspace::activate_any_workspace_window(cx).is_some() { + return anyhow::Ok(()); + } + restore_or_create_workspace(app_state, cx).await + }) + .detach_and_log_err(cx); + } OpenRequestKind::Extension { extension_id } => { cx.spawn(async move |cx| { let workspace = @@ -1001,6 +1018,15 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut let multi_workspace = workspace::get_any_active_multi_workspace(app_state, cx.clone()).await?; + let panels_task = multi_workspace.update(cx, |multi_workspace, _, cx| { + multi_workspace + .workspace() + .update(cx, |workspace, _| workspace.take_panels_task()) + })?; + if let Some(task) = panels_task { + task.await.log_err(); + } + multi_workspace.update(cx, |multi_workspace, window, cx| { multi_workspace.workspace().update(cx, |workspace, cx| { if let Some(panel) = workspace.focus_panel::(window, cx) { @@ -1011,6 +1037,11 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut cx, ); }); + } else { + log::warn!( + "zed://agent received but the AgentPanel is not registered \ + (is `disable_ai` enabled?)" + ); } }); }) diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index 0ac86e9d2b5..1544b877dcc 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -51,6 +51,7 @@ pub enum OpenRequestKind { Box, ), ), + FocusApp, Extension { extension_id: String, }, @@ -82,6 +83,7 @@ impl std::fmt::Debug for OpenRequestKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::CliConnection(_) => write!(f, "CliConnection(..)"), + Self::FocusApp => write!(f, "FocusApp"), Self::Extension { extension_id } => f .debug_struct("Extension") .field("extension_id", extension_id) @@ -118,6 +120,15 @@ impl std::fmt::Debug for OpenRequestKind { } impl OpenRequest { + pub fn is_focus_app_only(&self) -> bool { + matches!(self.kind, Some(OpenRequestKind::FocusApp)) + && self.open_paths.is_empty() + && self.diff_paths.is_empty() + && self.remote_connection.is_none() + && self.join_channel.is_none() + && self.open_channel_notes.is_empty() + } + pub fn parse(request: RawOpenRequest, cx: &App) -> Result { let mut this = Self::default(); @@ -167,6 +178,8 @@ impl OpenRequest { } } else if let Some(agent_path) = url.strip_prefix("zed://agent") { this.parse_agent_url(agent_path) + } else if url == "zed://" || url == "zed://open" || url == "zed://open/" { + this.kind = Some(OpenRequestKind::FocusApp); } else if let Some(schema_path) = url.strip_prefix("zed://schemas/") { this.kind = Some(OpenRequestKind::BuiltinJsonSchema { schema_path: schema_path.to_string(), @@ -210,7 +223,8 @@ impl OpenRequest { } fn parse_agent_url(&mut self, agent_path: &str) { - // Format: "" or "?prompt=" + // Format: "" or "?prompt=". + let agent_path = agent_path.strip_prefix('/').unwrap_or(agent_path); let external_source_prompt = agent_path.strip_prefix('?').and_then(|query| { url::form_urlencoded::parse(query.as_bytes()) .find_map(|(key, value)| (key == "prompt").then_some(value)) @@ -1230,6 +1244,63 @@ mod tests { } } + #[gpui::test] + fn test_parse_agent_url_with_trailing_slash(cx: &mut TestAppContext) { + let _app_state = init_test(cx); + + let request = cx.update(|cx| { + OpenRequest::parse( + RawOpenRequest { + urls: vec!["zed://agent/?prompt=hello".into()], + ..Default::default() + }, + cx, + ) + .unwrap() + }); + + match request.kind { + Some(OpenRequestKind::AgentPanel { + external_source_prompt, + }) => { + assert_eq!( + external_source_prompt + .as_ref() + .map(ExternalSourcePrompt::as_str), + Some("hello") + ); + } + _ => panic!("Expected AgentPanel kind"), + } + } + + #[gpui::test] + fn test_parse_focus_app_url(cx: &mut TestAppContext) { + let _app_state = init_test(cx); + + for url in ["zed://", "zed://open", "zed://open/"] { + let request = cx.update(|cx| { + OpenRequest::parse( + RawOpenRequest { + urls: vec![url.into()], + ..Default::default() + }, + cx, + ) + .unwrap() + }); + assert!( + matches!(request.kind, Some(OpenRequestKind::FocusApp)), + "expected FocusApp for {url}, got {:?}", + request.kind + ); + assert!( + request.is_focus_app_only(), + "expected is_focus_app_only for {url}" + ); + } + } + #[gpui::test] fn test_parse_agent_url_with_empty_prompt(cx: &mut TestAppContext) { let _app_state = init_test(cx); From 342580531ccb6f3ccd78f94e70672e8df3a16840 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Mon, 18 May 2026 10:35:35 -0500 Subject: [PATCH 020/289] script: Trigger docs release (#56953) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - N/A or Added/Fixed/Improved ... --- script/trigger-docs-build | 50 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100755 script/trigger-docs-build diff --git a/script/trigger-docs-build b/script/trigger-docs-build new file mode 100755 index 00000000000..3d429e0097d --- /dev/null +++ b/script/trigger-docs-build @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +which gh >/dev/null || brew install gh + +case "${1:-}" in + preview | stable) + channel="$1" + ;; + *) + echo "Usage: $0 preview|stable [--from-main]" >&2 + exit 1 + ;; +esac + +case "${2:-}" in + "") + from_main=false + ;; + --from-main) + from_main=true + ;; + *) + echo "Usage: $0 preview|stable [--from-main]" >&2 + exit 1 + ;; +esac + +version=$(./script/get-released-version "$channel") +branch=$(echo "$version" | sed -E 's/^([0-9]+)\.([0-9]+)\.[0-9]+$/v\1.\2.x/') +workflow_ref="$branch" +if [ "$from_main" = true ]; then + workflow_ref="main" +fi + +echo "Triggering docs build for $channel ($branch) using workflow from $workflow_ref" +echo "This will publish docs from $branch before the next release." +echo "Only continue if $branch has no unreleased feature-specific docs." +read -r -p "Continue? [y/N] " confirmation +case "$confirmation" in + y | Y | yes | YES) + ;; + *) + echo "Cancelled." + exit 1 + ;; +esac + +gh workflow run "deploy_docs.yml" --ref "$workflow_ref" -f channel="$channel" -f checkout_ref="$branch" +echo "Follow along at: https://github.com/zed-industries/zed/actions/workflows/deploy_docs.yml" From ea01b926ea4925cccef09f77235c350d1cdc6791 Mon Sep 17 00:00:00 2001 From: Hadley99 <94683523+Hadley99@users.noreply.github.com> Date: Mon, 18 May 2026 21:24:51 +0530 Subject: [PATCH 021/289] languages: Exclude angle brackets from rainbow bracket colorization for Javascript (#57063) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Extends #51311 to JSX in JavaScript files, which uses the same javascript grammar for both .js and .jsx. ## Changes - Added (#set! rainbow.exclude) to the three angle bracket patterns in crates/grammars/src/javascript/brackets.scm, matching the TSX fix in #51311. ## Before / After Before: angle brackets in JSX tags receive rainbow colors alongside `{}`, `()`, `[]`, making every tag visually noisy. After: only `{}`, `()`, and `[]` receive rainbow colors β€” angle brackets are excluded, matching the HTML extension behavior. Release Notes: - Fixed angled brackets being included in rainbow bracket highlights for JavaScript. --- crates/grammars/src/javascript/brackets.scm | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/grammars/src/javascript/brackets.scm b/crates/grammars/src/javascript/brackets.scm index 69acbcd614e..a5f51bbbb11 100644 --- a/crates/grammars/src/javascript/brackets.scm +++ b/crates/grammars/src/javascript/brackets.scm @@ -7,14 +7,17 @@ ("{" @open "}" @close) -("<" @open +(("<" @open ">" @close) + (#set! rainbow.exclude)) -("<" @open +(("<" @open "/>" @close) + (#set! rainbow.exclude)) -("" @close) + (#set! rainbow.exclude)) (("\"" @open "\"" @close) From ec9ba5f069f415713a2f2e3e8550c28479678dba Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 18 May 2026 13:18:59 -0300 Subject: [PATCH 022/289] Make restricted mode more obvious (#57056) Closes TRA-150 This PR makes the restricted mode more obvious by: - Immediately opening the restricted mode modal upon opening an untrusted project - Disabling dismissing the modal on escape or click away to force choosing one of the two options (and avoid accidentally staying in restricted mode by simply dismissing it) - Showing the LSP button but with communication about language servers being disabled for untrusted projects - Showing a banner in the project settings with the same communication The motivation for this change was that we tried to be minimal with how we communicate a project is untrusted, but it was so minimal that people were confused as to why language servers and other settings weren't working. It was easy to miss the title bar button, for some reason. The changes in this PR makes it so acting on this decision (trust or not a project) is mandatory in order to even start to interact with the project. I appreciate changes here are more aggressive, but I think it's better to make you think about this decision vs. letting you be confused as to why you don't see LS completions or formatting. Release Notes: - Made restricted mode more obvious, demanding immediate action when opening an untrusted project. --- crates/git_ui/src/worktree_service.rs | 16 ++--- crates/language_tools/src/lsp_button.rs | 86 +++++++++++++++++++++---- crates/project/src/trusted_worktrees.rs | 11 ++++ crates/settings_ui/src/settings_ui.rs | 62 +++++++++++++++++- crates/title_bar/src/title_bar.rs | 9 +-- crates/workspace/src/security_modal.rs | 14 ++-- crates/workspace/src/workspace.rs | 20 ++++-- 7 files changed, 178 insertions(+), 40 deletions(-) diff --git a/crates/git_ui/src/worktree_service.rs b/crates/git_ui/src/worktree_service.rs index 0ec34f3d915..1eda4219092 100644 --- a/crates/git_ui/src/worktree_service.rs +++ b/crates/git_ui/src/worktree_service.rs @@ -252,17 +252,11 @@ fn maybe_propagate_worktree_trust( if ProjectSettings::get_global(cx).session.trust_all_worktrees { return; } - let Some(trusted_store) = TrustedWorktrees::try_get_global(cx) else { - return; - }; - let source_is_trusted = source_workspace .upgrade() .map(|workspace| { let source_worktree_store = workspace.read(cx).project().read(cx).worktree_store(); - !trusted_store - .read(cx) - .has_restricted_worktrees(&source_worktree_store, cx) + !TrustedWorktrees::has_restricted_worktrees(&source_worktree_store, cx) }) .unwrap_or(false); @@ -280,9 +274,11 @@ fn maybe_propagate_worktree_trust( .collect(); if !paths_to_trust.is_empty() { - trusted_store.update(cx, |store, cx| { - store.trust(&worktree_store, paths_to_trust, cx); - }); + if let Some(trusted_store) = TrustedWorktrees::try_get_global(cx) { + trusted_store.update(cx, |store, cx| { + store.trust(&worktree_store, paths_to_trust, cx); + }); + } } }) .ok(); diff --git a/crates/language_tools/src/lsp_button.rs b/crates/language_tools/src/lsp_button.rs index 8b7088dc228..e7c6d5b2160 100644 --- a/crates/language_tools/src/lsp_button.rs +++ b/crates/language_tools/src/lsp_button.rs @@ -13,12 +13,12 @@ use language::language_settings::{EditPredictionProvider, all_language_settings} use client::proto; use collections::HashSet; use editor::{Editor, EditorEvent}; -use gpui::{Anchor, App, Entity, Subscription, Task, TaskExt, WeakEntity, actions}; +use gpui::{Action as _, Anchor, App, Entity, Subscription, Task, TaskExt, WeakEntity, actions}; use language::{BinaryStatus, BufferId, ServerHealth}; use lsp::{LanguageServerId, LanguageServerName, LanguageServerSelector}; use project::{ LspStore, LspStoreEvent, Worktree, lsp_store::log_store::GlobalLogStore, - project_settings::ProjectSettings, + project_settings::ProjectSettings, trusted_worktrees::TrustedWorktrees, }; use settings::{Settings as _, SettingsStore}; use ui::{ @@ -26,7 +26,7 @@ use ui::{ }; use util::{ResultExt, paths::PathExt, rel_path::RelPath}; -use workspace::{StatusItemView, Workspace}; +use workspace::{StatusItemView, ToggleWorktreeSecurity, Workspace}; use crate::lsp_log_view; @@ -221,6 +221,45 @@ impl LanguageServerState { return menu; }; + let is_restricted = self + .workspace + .upgrade() + .map(|workspace| { + let worktree_store = workspace.read(cx).project().read(cx).worktree_store(); + TrustedWorktrees::has_restricted_worktrees(&worktree_store, cx) + }) + .unwrap_or(false); + + if is_restricted { + menu = menu.custom_entry( + move |_window, _cx| { + v_flex() + .child( + h_flex() + .gap_1() + .child( + Icon::new(IconName::Warning) + .color(Color::Warning) + .size(IconSize::XSmall), + ) + .child( + Label::new("Project is in Restricted Mode") + .size(LabelSize::Small), + ), + ) + .child( + Label::new("Language Servers can't run until you trust this project.") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .into_any_element() + }, + move |window, cx| { + window.dispatch_action(ToggleWorktreeSecurity.boxed_clone(), cx); + }, + ); + } + let server_metadata = self .lsp_store .update(cx, |lsp_store, _| { @@ -832,12 +871,18 @@ impl LspButton { lsp_menu_refresh: Task::ready(()), _subscriptions: vec![settings_subscription, lsp_store_subscription], }; - if !lsp_button - .server_state - .read(cx) - .language_servers - .binary_statuses - .is_empty() + let is_restricted = TrustedWorktrees::has_restricted_worktrees( + &workspace.project().read(cx).worktree_store(), + cx, + ); + + if is_restricted + || !lsp_button + .server_state + .read(cx) + .language_servers + .binary_statuses + .is_empty() { lsp_button.refresh_lsp_menu(true, window, cx); } @@ -1258,7 +1303,20 @@ impl StatusItemView for LspButton { impl Render for LspButton { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl ui::IntoElement { - if self.server_state.read(cx).language_servers.is_empty() || self.lsp_menu.is_none() { + let is_restricted = self + .server_state + .read(cx) + .workspace + .upgrade() + .map(|workspace| { + let worktree_store = workspace.read(cx).project().read(cx).worktree_store(); + TrustedWorktrees::has_restricted_worktrees(&worktree_store, cx) + }) + .unwrap_or(false); + + if !is_restricted + && (self.server_state.read(cx).language_servers.is_empty() || self.lsp_menu.is_none()) + { return div().hidden(); } @@ -1288,7 +1346,12 @@ impl Render for LspButton { } } - let (indicator, description) = if has_errors { + let (indicator, description) = if is_restricted { + ( + Some(Indicator::dot().color(Color::Warning)), + "Restricted Mode", + ) + } else if has_errors { ( Some(Indicator::dot().color(Color::Error)), "Server with errors", @@ -1333,6 +1396,7 @@ impl Render for LspButton { IconButton::new("zed-lsp-tool-button", IconName::BoltOutlined) .when_some(indicator, IconButton::indicator) .icon_size(IconSize::Small) + .when(is_restricted, |s| s.icon_color(Color::Warning)) .indicator_border_color(Some(cx.theme().colors().status_bar_background)), move |_window, cx| { Tooltip::with_meta("Language Servers", Some(&ToggleMenu), description, cx) diff --git a/crates/project/src/trusted_worktrees.rs b/crates/project/src/trusted_worktrees.rs index 69d410adc66..8d8804c3f97 100644 --- a/crates/project/src/trusted_worktrees.rs +++ b/crates/project/src/trusted_worktrees.rs @@ -113,6 +113,17 @@ impl TrustedWorktrees { pub fn try_get_global(cx: &App) -> Option> { cx.try_global::().map(|this| this.0.clone()) } + + /// Whether the given project store has any restricted worktrees. + pub fn has_restricted_worktrees(worktree_store: &Entity, cx: &App) -> bool { + Self::try_get_global(cx) + .map(|trusted| { + trusted + .read(cx) + .has_restricted_worktrees(worktree_store, cx) + }) + .unwrap_or(false) + } } /// A collection of worktrees that are considered trusted and not trusted. diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index f8d938e9eec..02bbacdfa30 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -3350,6 +3350,65 @@ impl SettingsWindow { .into_any_element() } + let mut restricted_banner = gpui::Empty.into_any_element(); + if let SettingsUiFile::Project((worktree_id, _)) = &self.current_file { + let worktree_id = *worktree_id; + let is_restricted = all_projects(self.original_window.as_ref(), cx) + .find(|project| project.read(cx).worktree_for_id(worktree_id, cx).is_some()) + .map(|project| { + let worktree_store = project.read(cx).worktree_store(); + project::trusted_worktrees::TrustedWorktrees::has_restricted_worktrees( + &worktree_store, + cx, + ) + }) + .unwrap_or(false); + + if is_restricted { + let original_window = self.original_window; + restricted_banner = Banner::new() + .severity(Severity::Warning) + .child( + v_flex() + .my_0p5() + .gap_0p5() + .child(Label::new("Restricted Mode")) + .child( + Label::new( + "This project is in restricted mode. Some project settings may not apply.", + ) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .action_slot( + div().pr_2().pb_1().child( + Button::new("manage-trust", "Manage Trust") + .style(ButtonStyle::Tinted(ui::TintColor::Warning)) + .on_click(cx.listener(move |_this, _, window, cx| { + if let Some(original_window) = original_window { + original_window + .update(cx, |multi_workspace, window, cx| { + multi_workspace + .workspace() + .update(cx, |workspace, cx| { + workspace + .show_worktree_trust_security_modal( + true, window, cx, + ); + }); + }) + .log_err(); + } + // Close the settings window + window.remove_window(); + })), + ), + ) + .into_any_element(); + } + } + v_flex() .id("settings-ui-page") .on_action(cx.listener(|this, _: &menu::SelectNext, window, cx| { @@ -3440,7 +3499,8 @@ impl SettingsWindow { .px_8() .gap_2() .child(page_header) - .child(warning_banner), + .child(warning_banner) + .child(restricted_banner), ) .child( div() diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index c15f840e69d..3bc12a20748 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -641,13 +641,8 @@ impl TitleBar { } pub fn render_restricted_mode(&self, cx: &mut Context) -> Option { - let has_restricted_worktrees = TrustedWorktrees::try_get_global(cx) - .map(|trusted_worktrees| { - trusted_worktrees - .read(cx) - .has_restricted_worktrees(&self.project.read(cx).worktree_store(), cx) - }) - .unwrap_or(false); + let has_restricted_worktrees = + TrustedWorktrees::has_restricted_worktrees(&self.project.read(cx).worktree_store(), cx); if !has_restricted_worktrees { return None; } diff --git a/crates/workspace/src/security_modal.rs b/crates/workspace/src/security_modal.rs index 2130a1d1eca..89ce2abfd66 100644 --- a/crates/workspace/src/security_modal.rs +++ b/crates/workspace/src/security_modal.rs @@ -56,11 +56,17 @@ impl ModalView for SecurityModal { fn on_before_dismiss(&mut self, _: &mut Window, _: &mut Context) -> DismissDecision { match self.trusted { - Some(false) => telemetry::event!("Open in Restricted", source = "Worktree Trust Modal"), - Some(true) => telemetry::event!("Trust and Continue", source = "Worktree Trust Modal"), - None => telemetry::event!("Dismissed", source = "Worktree Trust Modal"), + Some(false) => { + telemetry::event!("Open in Restricted", source = "Worktree Trust Modal"); + DismissDecision::Dismiss(true) + } + Some(true) => { + telemetry::event!("Trust and Continue", source = "Worktree Trust Modal"); + DismissDecision::Dismiss(true) + } + // Block dismiss via escape or clicking outside; user must pick an action + None => DismissDecision::Dismiss(false), } - DismissDecision::Dismiss(true) } } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index da8ffe972ee..599a2d23681 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -2122,6 +2122,15 @@ impl Workspace { .log_err(); } + // Auto-show the security modal if the project has restricted worktrees + window + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.show_worktree_trust_security_modal(false, window, cx); + }); + }) + .log_err(); + Ok(OpenResult { window, workspace, @@ -8014,13 +8023,10 @@ impl Workspace { }); } } else { - let has_restricted_worktrees = TrustedWorktrees::try_get_global(cx) - .map(|trusted_worktrees| { - trusted_worktrees - .read(cx) - .has_restricted_worktrees(&self.project().read(cx).worktree_store(), cx) - }) - .unwrap_or(false); + let has_restricted_worktrees = TrustedWorktrees::has_restricted_worktrees( + &self.project().read(cx).worktree_store(), + cx, + ); if has_restricted_worktrees { let project = self.project().read(cx); let remote_host = project From b9ba43c9c1f9fc466d27b690ea5e7bea75209110 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 18 May 2026 13:36:37 -0300 Subject: [PATCH 023/289] agent_ui: Add mention disambiguation (#56926) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes AI-261 This PR adds mention disambiguation in the agent panel, which works both for regular @-mentions as well as for skills. Effectively, when you mention files with the same name, the mention crease displays the next path parent name, following a similar approach to common tabs in the editor. For skills, the skill source is displayed (either global or from some project). Screenshot 2026-05-15 at 6β€― 32@2x Release Notes: - Agent: Improved file and skill mention disambiguation in the agent panel. --------- Co-authored-by: Richard Feldman --- crates/acp_thread/src/mention.rs | 83 ++++++++++++++ crates/agent_ui/src/completion_provider.rs | 4 +- crates/agent_ui/src/mention_set.rs | 124 ++++++++++++++++----- crates/agent_ui/src/message_editor.rs | 54 ++++++--- 4 files changed, 224 insertions(+), 41 deletions(-) diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index 6fc2cc50c1f..67c1ddb9416 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -342,6 +342,21 @@ impl MentionUri { .. } => selection_name(path.as_deref(), line_range), MentionUri::Fetch { url } => url.to_string(), + MentionUri::Skill { name, .. } => name.clone(), + } + } + + /// Returns a label for this mention at the given disambiguation `detail` + /// level. `detail == 0` is the base name returned by [`Self::name`]; higher + /// levels include progressively more context (e.g. additional parent path + /// components for files, or the source for skills) until a fixed point is + /// reached. Intended to be driven by [`util::disambiguate::compute_disambiguation_details`]. + pub fn disambiguated_name(&self, detail: usize) -> String { + if detail == 0 { + return self.name(); + } + + match self { MentionUri::Skill { name, source, .. } => { if source.is_empty() { format!("{} (global)", name) @@ -349,6 +364,10 @@ impl MentionUri { format!("{} ({})", name, source) } } + MentionUri::File { abs_path, .. } | MentionUri::Directory { abs_path, .. } => { + project::path_suffix(abs_path, detail) + } + _ => self.name(), } } @@ -1070,4 +1089,68 @@ mod tests { let parsed_single = MentionUri::parse(single_line_uri, PathStyle::local()).unwrap(); assert_eq!(parsed_single.name(), "Terminal (1 line)"); } + + #[test] + fn test_disambiguated_name() { + // Two files with the same name β€” should disambiguate with parent dir + let file_a = MentionUri::File { + abs_path: PathBuf::from(path!("/project/src/README.md")), + }; + let file_b = MentionUri::File { + abs_path: PathBuf::from(path!("/project/docs/README.md")), + }; + assert_eq!(file_a.name(), "README.md"); + assert_eq!(file_b.name(), "README.md"); + assert_eq!(file_a.disambiguated_name(0), "README.md"); + assert_eq!(file_a.disambiguated_name(1), "src/README.md"); + assert_eq!(file_b.disambiguated_name(1), "docs/README.md"); + + // Files that still collide at one parent should grow further. + let deep_a = MentionUri::File { + abs_path: PathBuf::from(path!("/a/src/foo.rs")), + }; + let deep_b = MentionUri::File { + abs_path: PathBuf::from(path!("/b/src/foo.rs")), + }; + assert_eq!(deep_a.disambiguated_name(1), "src/foo.rs"); + assert_eq!(deep_b.disambiguated_name(1), "src/foo.rs"); + assert_eq!(deep_a.disambiguated_name(2), "a/src/foo.rs"); + assert_eq!(deep_b.disambiguated_name(2), "b/src/foo.rs"); + + // Two skills with the same name β€” should disambiguate with source + let global_skill = MentionUri::Skill { + name: "create-skill".into(), + source: "".into(), + skill_file_path: PathBuf::from("/global/create-skill/SKILL.md"), + }; + let project_skill = MentionUri::Skill { + name: "create-skill".into(), + source: "my-project".into(), + skill_file_path: PathBuf::from("/project/create-skill/SKILL.md"), + }; + assert_eq!(global_skill.name(), "create-skill"); + assert_eq!(global_skill.disambiguated_name(0), "create-skill"); + assert_eq!(global_skill.disambiguated_name(1), "create-skill (global)"); + assert_eq!( + project_skill.disambiguated_name(1), + "create-skill (my-project)" + ); + + // A type without special disambiguation (Thread) β€” detail has no effect + // (the value is a fixed point so the disambiguation loop terminates). + let thread = MentionUri::Thread { + id: acp::SessionId::new("123"), + name: "My Thread".into(), + }; + assert_eq!(thread.disambiguated_name(0), "My Thread"); + assert_eq!(thread.disambiguated_name(1), "My Thread"); + assert_eq!(thread.disambiguated_name(5), "My Thread"); + + // Edge case: file at filesystem root has no parent to show + let root_file = MentionUri::File { + abs_path: PathBuf::from(path!("/README.md")), + }; + assert_eq!(root_file.disambiguated_name(1), "README.md"); + assert_eq!(root_file.disambiguated_name(5), "README.md"); + } } diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index 37074aa35a2..3a4ae6ecc2b 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -2603,7 +2603,7 @@ fn completion_text_for_terminal_selections( }; mention_set - .update(cx, |mention_set, _| { + .update(cx, |mention_set, cx| { mention_set.insert_mention( crease_id, mention_uri.clone(), @@ -2612,6 +2612,8 @@ fn completion_text_for_terminal_selections( tracked_buffers: vec![], })) .shared(), + None, + cx, ); }) .ok(); diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 17ad11d3288..31bb31c046c 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -63,6 +63,7 @@ pub struct MentionSet { thread_store: Option>, prompt_store: Option>, mentions: HashMap, + crease_entities: HashMap>, } impl MentionSet { @@ -76,6 +77,7 @@ impl MentionSet { thread_store, prompt_store, mentions: HashMap::default(), + crease_entities: HashMap::default(), } } @@ -110,12 +112,24 @@ impl MentionSet { for (crease_id, crease) in snapshot.crease_snapshot.creases() { if !crease.range().start.is_valid(snapshot.buffer_snapshot()) { self.mentions.remove(&crease_id); + self.crease_entities.remove(&crease_id); } } } - pub fn insert_mention(&mut self, crease_id: CreaseId, uri: MentionUri, task: MentionTask) { + pub fn insert_mention( + &mut self, + crease_id: CreaseId, + uri: MentionUri, + task: MentionTask, + crease_entity: Option>, + cx: &mut App, + ) { self.mentions.insert(crease_id, (uri, task)); + if let Some(entity) = crease_entity { + self.crease_entities.insert(crease_id, entity); + } + self.recompute_disambiguation(cx); } /// Creates the appropriate confirmation task for a mention based on its URI type. @@ -165,8 +179,10 @@ impl MentionSet { } } - pub fn remove_mention(&mut self, crease_id: &CreaseId) { + pub fn remove_mention(&mut self, crease_id: &CreaseId, cx: &mut App) { self.mentions.remove(crease_id); + self.crease_entities.remove(crease_id); + self.recompute_disambiguation(cx); } pub fn creases(&self) -> HashSet { @@ -196,13 +212,32 @@ impl MentionSet { } pub fn set_mentions(&mut self, mentions: HashMap) { + self.crease_entities + .retain(|id, _| mentions.contains_key(id)); self.mentions = mentions; } pub fn clear(&mut self) -> impl Iterator { + self.crease_entities.clear(); self.mentions.drain() } + fn recompute_disambiguation(&self, cx: &mut App) { + let labels = + compute_disambiguated_labels(self.mentions.iter().map(|(id, (uri, _))| (*id, uri))); + + for (crease_id, new_label) in labels { + if let Some(entity) = self.crease_entities.get(&crease_id) { + entity.update(cx, |loading_ctx, cx| { + if loading_ctx.label != new_label { + loading_ctx.label = new_label; + cx.notify(); + } + }); + } + } + } + pub fn confirm_mention_completion( &mut self, crease_text: SharedString, @@ -273,7 +308,7 @@ impl MentionSet { cx, ) }; - let Some((crease_id, tx)) = crease else { + let Some((crease_id, tx, crease_entity)) = crease else { return Task::ready(()); }; @@ -325,6 +360,10 @@ impl MentionSet { .spawn(async move |_, _| task.await.map_err(|e| e.to_string())) .shared(); self.mentions.insert(crease_id, (mention_uri, task.clone())); + if let Some(entity) = crease_entity { + self.crease_entities.insert(crease_id, entity); + } + self.recompute_disambiguation(cx); // Notify the user if we failed to load the mentioned context let workspace = workspace.downgrade(); @@ -338,6 +377,7 @@ impl MentionSet { editor.edit([(start_anchor..end_anchor, "")], cx); }); this.mentions.remove(&crease_id); + this.crease_entities.remove(&crease_id); }) .ok(); } @@ -669,6 +709,26 @@ impl MentionSet { } } +/// Computes disambiguated labels for a set of mentions. When multiple mentions +/// share the same base name, their labels include extra context (additional +/// parent path components for files/directories, source for skills) so the user +/// can tell them apart. Driven by [`util::disambiguate::compute_disambiguation_details`], +/// which is the same utility used for buffer tab titles and the sidebar. +fn compute_disambiguated_labels<'a>( + mentions: impl Iterator, +) -> HashMap { + let mentions: Vec<_> = mentions.collect(); + let details = + util::disambiguate::compute_disambiguation_details(&mentions, |(_, uri), detail| { + uri.disambiguated_name(detail) + }); + mentions + .into_iter() + .zip(details) + .map(|((id, uri), detail)| (id, uri.disambiguated_name(detail).into())) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -821,7 +881,7 @@ pub(crate) async fn insert_images_as_context( snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len) }); let image = Arc::new(image); - let Ok(Some((crease_id, tx))) = cx.update(|window, cx| { + let Ok(Some((crease_id, tx, crease_entity))) = cx.update(|window, cx| { insert_crease_for_mention( text_anchor, content_len, @@ -856,13 +916,15 @@ pub(crate) async fn insert_images_as_context( }) .shared(); - mention_set.update(cx, |mention_set, _cx| { + mention_set.update(cx, |mention_set, cx| { mention_set.insert_mention( crease_id, MentionUri::PastedImage { name: name.to_string(), }, task.clone(), + crease_entity, + cx, ) }); @@ -874,8 +936,8 @@ pub(crate) async fn insert_images_as_context( editor.update(cx, |editor, cx| { editor.edit([(start_anchor..end_anchor, "")], cx); }); - mention_set.update(cx, |mention_set, _cx| { - mention_set.remove_mention(&crease_id) + mention_set.update(cx, |mention_set, cx| { + mention_set.remove_mention(&crease_id, cx) }); } } @@ -991,7 +1053,11 @@ pub(crate) fn insert_crease_for_mention( editor: Entity, window: &mut Window, cx: &mut App, -) -> Option<(CreaseId, postage::barrier::Sender)> { +) -> Option<( + CreaseId, + postage::barrier::Sender, + Option>, +)> { let (tx, rx) = postage::barrier::channel(); let crease_id = editor.update(cx, |editor, cx| { @@ -1002,19 +1068,20 @@ pub(crate) fn insert_crease_for_mention( let start = start.bias_right(&snapshot); let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len); + let (render, crease_entity) = render_mention_fold_button( + crease_label.clone(), + crease_icon.clone(), + crease_tooltip, + mention_uri.clone(), + workspace.clone(), + start..end, + rx, + image, + cx.weak_entity(), + cx, + ); let placeholder = FoldPlaceholder { - render: render_mention_fold_button( - crease_label.clone(), - crease_icon.clone(), - crease_tooltip, - mention_uri.clone(), - workspace.clone(), - start..end, - rx, - image, - cx.weak_entity(), - cx, - ), + render, merge_adjacent: false, ..Default::default() }; @@ -1033,10 +1100,11 @@ pub(crate) fn insert_crease_for_mention( let ids = editor.insert_creases(vec![crease.clone()], cx); editor.fold_creases(vec![crease], false, window, cx); - Some(ids[0]) + Some((ids[0], crease_entity)) })?; - Some((crease_id, tx)) + let (crease_id, crease_entity) = crease_id; + Some((crease_id, tx, Some(crease_entity))) } pub(crate) fn crease_for_mention( @@ -1215,7 +1283,10 @@ fn render_mention_fold_button( image_task: Option, String>>>>, editor: WeakEntity, cx: &mut App, -) -> Arc, &mut App) -> AnyElement> { +) -> ( + Arc, &mut App) -> AnyElement>, + Entity, +) { let loading = cx.new(|cx| { let loading = cx.spawn(async move |this, cx| { loading_finished.recv().await; @@ -1238,10 +1309,13 @@ fn render_mention_fold_button( image: image_task.clone(), } }); - Arc::new(move |_fold_id, _fold_range, _cx| loading.clone().into_any_element()) + let loading_clone = loading.clone(); + let render: Arc, &mut App) -> AnyElement> = + Arc::new(move |_fold_id, _fold_range, _cx| loading_clone.clone().into_any_element()); + (render, loading) } -struct LoadingContext { +pub struct LoadingContext { id: EntityId, label: SharedString, icon: SharedString, diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 8a152239c14..ecd1febba72 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -1134,7 +1134,7 @@ impl MessageEditor { (text_anchor, mention_text.len()) }); - let Some((crease_id, tx)) = insert_crease_for_mention( + let Some((crease_id, tx, crease_entity)) = insert_crease_for_mention( text_anchor, content_len, crease_text.into(), @@ -1181,8 +1181,14 @@ impl MessageEditor { }) .shared(); - self.mention_set.update(cx, |mention_set, _cx| { - mention_set.insert_mention(crease_id, mention_uri.clone(), mention_task) + self.mention_set.update(cx, |mention_set, cx| { + mention_set.insert_mention( + crease_id, + mention_uri.clone(), + mention_task, + crease_entity, + cx, + ) }); } } @@ -1241,7 +1247,7 @@ impl MessageEditor { let http_client = workspace.read(cx).client().http_client(); for (anchor, content_len, mention_uri) in all_mentions { - let Some((crease_id, tx)) = insert_crease_for_mention( + let Some((crease_id, tx, crease_entity)) = insert_crease_for_mention( snapshot.anchor_to_buffer_anchor(anchor).unwrap().0, content_len, mention_uri.name().into(), @@ -1271,8 +1277,14 @@ impl MessageEditor { .spawn(async move |_, _| task.await.map_err(|e| e.to_string())) .shared(); - self.mention_set.update(cx, |mention_set, _cx| { - mention_set.insert_mention(crease_id, mention_uri.clone(), task.clone()) + self.mention_set.update(cx, |mention_set, cx| { + mention_set.insert_mention( + crease_id, + mention_uri.clone(), + task.clone(), + crease_entity, + cx, + ) }); // Drop the tx after inserting to signal the crease is ready @@ -1463,7 +1475,7 @@ impl MessageEditor { (text_anchor, mention_text.len()) }); - let Some((crease_id, tx)) = insert_crease_for_mention( + let Some((crease_id, tx, crease_entity)) = insert_crease_for_mention( text_anchor, content_len, mention_uri.name().into(), @@ -1488,8 +1500,14 @@ impl MessageEditor { .spawn(async move |_cx| confirm_task.await.map_err(|e| e.to_string())) .shared(); - mention_set.update(cx, |mention_set, _| { - mention_set.insert_mention(crease_id, mention_uri, mention_task); + mention_set.update(cx, |mention_set, cx| { + mention_set.insert_mention( + crease_id, + mention_uri, + mention_task, + crease_entity, + cx, + ); }); }) }) @@ -1744,7 +1762,7 @@ impl MessageEditor { for (range, mention_uri, mention) in mentions { let adjusted_start = insertion_start + range.start; let anchor = snapshot.anchor_before(MultiBufferOffset(adjusted_start)); - let Some((crease_id, tx)) = insert_crease_for_mention( + let Some((crease_id, tx, crease_entity)) = insert_crease_for_mention( snapshot.anchor_to_buffer_anchor(anchor).unwrap().0, range.end - range.start, mention_uri.name().into(), @@ -1761,11 +1779,13 @@ impl MessageEditor { }; drop(tx); - self.mention_set.update(cx, |mention_set, _cx| { + self.mention_set.update(cx, |mention_set, cx| { mention_set.insert_mention( crease_id, mention_uri.clone(), Task::ready(Ok(mention)).shared(), + crease_entity, + cx, ) }); } @@ -4349,7 +4369,7 @@ mod tests { "line 3\nline 4\n".to_string(), ), ] { - let Some((crease_id, tx)) = insert_crease_for_mention( + let Some((crease_id, tx, _crease_entity)) = insert_crease_for_mention( snapshot .anchor_to_buffer_anchor( snapshot.anchor_before(MultiBufferOffset(range.start)), @@ -4371,7 +4391,7 @@ mod tests { }; drop(tx); - message_editor.mention_set.update(cx, |mention_set, _cx| { + message_editor.mention_set.update(cx, |mention_set, cx| { mention_set.insert_mention( crease_id, uri, @@ -4380,6 +4400,8 @@ mod tests { tracked_buffers: Vec::new(), })) .shared(), + None, + cx, ); }); } @@ -4508,7 +4530,7 @@ mod tests { "line 3\nline 4\n".to_string(), ), ] { - let Some((crease_id, tx)) = insert_crease_for_mention( + let Some((crease_id, tx, _crease_entity)) = insert_crease_for_mention( snapshot .anchor_to_buffer_anchor( snapshot.anchor_before(MultiBufferOffset(range.start)), @@ -4530,7 +4552,7 @@ mod tests { }; drop(tx); - message_editor.mention_set.update(cx, |mention_set, _cx| { + message_editor.mention_set.update(cx, |mention_set, cx| { mention_set.insert_mention( crease_id, uri, @@ -4539,6 +4561,8 @@ mod tests { tracked_buffers: Vec::new(), })) .shared(), + None, + cx, ); }); } From 7dcd422a7350cfe0b1fe3e8f2244a07cc74c752f Mon Sep 17 00:00:00 2001 From: Vlad Ionescu Date: Mon, 18 May 2026 20:15:04 +0300 Subject: [PATCH 024/289] opencode: Model updates (#57076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **TL;DR**: clearer docs + models cleanup. ---- **Docs**: - as per the discussion in https://github.com/zed-industries/zed/issues/56869, added a note to the docs highlighting that temporary models should be configured using Custom Models. Adding a whole example felt redundant considering the full example is literally 2 rows below. **Model updates**: - **Ring 2.6 1T Free**: removed - **GLM 5 and GLM 5.1**: different settings based on subscription β€” [131k](https://github.com/anomalyco/models.dev/blob/8e710e19eabbfa464764afe54d67585cbee9f4d8/providers/opencode/models/glm-5.1.toml#L22) [output](https://github.com/anomalyco/models.dev/blob/8e710e19eabbfa464764afe54d67585cbee9f4d8/providers/opencode/models/glm-5.toml#L22) on OpenCode but [32k](https://github.com/anomalyco/models.dev/blob/8e710e19eabbfa464764afe54d67585cbee9f4d8/providers/opencode-go/models/glm-5.1.toml#L22) [output](https://github.com/anomalyco/models.dev/blob/8e710e19eabbfa464764afe54d67585cbee9f4d8/providers/opencode-go/models/glm-5.toml#L22) on OpenCode Go - **MiniMax M2.5**: different settings based on subscription β€” [131k output on OpenCode](https://github.com/anomalyco/models.dev/blob/8e710e19eabbfa464764afe54d67585cbee9f4d8/providers/opencode/models/minimax-m2.5.toml#L22) and [65k on OpenCode Go](https://github.com/anomalyco/models.dev/blob/8e710e19eabbfa464764afe54d67585cbee9f4d8/providers/opencode-go/models/minimax-m2.5.toml#L19) - **Nemotron 3 Super Free**: enabled interleaved reasoning as per [docs](https://github.com/anomalyco/models.dev/blob/8e710e19eabbfa464764afe54d67585cbee9f4d8/providers/opencode/models/nemotron-3-super-free.toml#L13). Ran some quick tests and confirmed everything seems to work fine (_"rename this variable. add a simple function. remove the function. tell me a joke"_) - **GPT 5.3 Codex Spark**: removed image support as per [docs](https://github.com/anomalyco/models.dev/blob/dev/providers/opencode/models/gpt-5.3-codex-spark.toml#L23-L25) The [docs say GLM 5 in OpenCode Zen has a deprecation date of May 14](https://opencode.ai/docs/zen/#deprecated-models) but that seems to still be active and is [not marked as deprecated on models.dev](https://github.com/anomalyco/models.dev/blob/8e710e19eabbfa464764afe54d67585cbee9f4d8/providers/opencode/models/glm-5.toml) so I didn't remove it yet 🀷 ---- Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes https://github.com/zed-industries/zed/issues/56869 Release Notes: - OpenCode: updated models (removed Ring 2.6 1T Free, enabled interleaved reasoning for Nemotron 3 Super Free, deleted incorrect image support for GPT 5.3 Codex Spark, and updated token counts for MiniMax M2.5, GLM 5, and GLM 5.1) - OpenCode Free: clearer docs for temporary free models --- .../language_models/src/provider/opencode.rs | 12 +++-- crates/opencode/src/opencode.rs | 54 +++++++++++-------- docs/src/ai/llm-providers.md | 2 + 3 files changed, 42 insertions(+), 26 deletions(-) diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs index 1d77c59f5d9..8179713a6a7 100644 --- a/crates/language_models/src/provider/opencode.rs +++ b/crates/language_models/src/provider/opencode.rs @@ -602,11 +602,11 @@ impl LanguageModel for OpenCodeLanguageModel { } fn max_token_count(&self) -> u64 { - self.model.max_token_count() + self.model.max_token_count(self.subscription) } fn max_output_tokens(&self) -> Option { - self.model.max_output_tokens() + self.model.max_output_tokens(self.subscription) } fn stream_completion( @@ -646,7 +646,9 @@ impl LanguageModel for OpenCodeLanguageModel { request, self.model.id().to_string(), 1.0, - self.model.max_output_tokens().unwrap_or(8192), + self.model + .max_output_tokens(self.subscription) + .unwrap_or(8192), mode, anthropic::completion::AnthropicPromptCacheMode::Automatic, ); @@ -671,7 +673,7 @@ impl LanguageModel for OpenCodeLanguageModel { self.model.id(), false, false, - self.model.max_output_tokens(), + self.model.max_output_tokens(self.subscription), reasoning_effort, self.model.interleaved_reasoning(), ); @@ -692,7 +694,7 @@ impl LanguageModel for OpenCodeLanguageModel { self.model.id(), false, false, - self.model.max_output_tokens(), + self.model.max_output_tokens(self.subscription), None, supports_none_reasoning_effort, ); diff --git a/crates/opencode/src/opencode.rs b/crates/opencode/src/opencode.rs index 0e235bf7166..c4919f1759c 100644 --- a/crates/opencode/src/opencode.rs +++ b/crates/opencode/src/opencode.rs @@ -141,8 +141,6 @@ pub enum Model { MimoV2_5, #[serde(rename = "big-pickle")] BigPickle, - #[serde(rename = "ring-2.6-1t-free")] - Ring2_6_1TFree, #[serde(rename = "nemotron-3-super-free")] Nemotron3SuperFree, #[serde(rename = "qwen3.5-plus")] @@ -204,10 +202,9 @@ impl Model { | Self::DeepSeekV4Flash => &[OpenCodeSubscription::Go], // Free models - Self::MiniMaxM2_5Free - | Self::Nemotron3SuperFree - | Self::BigPickle - | Self::Ring2_6_1TFree => &[OpenCodeSubscription::Free], + Self::MiniMaxM2_5Free | Self::Nemotron3SuperFree | Self::BigPickle => { + &[OpenCodeSubscription::Free] + } // Custom models get their subscription from settings, not from here Self::Custom { .. } => &[], @@ -263,7 +260,6 @@ impl Model { Self::Qwen3_5Plus => "qwen3.5-plus", Self::Qwen3_6Plus => "qwen3.6-plus", Self::BigPickle => "big-pickle", - Self::Ring2_6_1TFree => "ring-2.6-1t-free", Self::Nemotron3SuperFree => "nemotron-3-super-free", Self::Custom { name, .. } => name, @@ -316,7 +312,6 @@ impl Model { Self::Qwen3_5Plus => "Qwen3.5 Plus", Self::Qwen3_6Plus => "Qwen3.6 Plus", Self::BigPickle => "Big Pickle", - Self::Ring2_6_1TFree => "Ring 2.6 1T Free", Self::Nemotron3SuperFree => "Nemotron 3 Super Free", Self::Custom { @@ -378,7 +373,6 @@ impl Model { | Self::DeepSeekV4Pro | Self::DeepSeekV4Flash | Self::BigPickle - | Self::Ring2_6_1TFree | Self::Nemotron3SuperFree => ApiProtocol::OpenAiChat, Self::Custom { protocol, .. } => *protocol, @@ -395,8 +389,8 @@ impl Model { | Self::MimoV2_5Pro | Self::Glm5 | Self::Glm5_1 - | Self::BigPickle - | Self::Ring2_6_1TFree => true, + | Self::Nemotron3SuperFree + | Self::BigPickle => true, Self::Custom { interleaved_reasoning, @@ -407,7 +401,7 @@ impl Model { } } - pub fn max_token_count(&self) -> u64 { + pub fn max_token_count(&self, subscription: OpenCodeSubscription) -> u64 { match self { // Anthropic models Self::ClaudeOpus4_7 => 1_000_000, @@ -436,13 +430,18 @@ impl Model { // OpenAI-compatible models Self::MiniMaxM2_7 => 204_800, Self::MiniMaxM2_5 | Self::MiniMaxM2_5Free => 204_800, - Self::Glm5 | Self::Glm5_1 => 202_725, + Self::Glm5 | Self::Glm5_1 => { + if subscription == OpenCodeSubscription::Go { + 202_752 + } else { + 204_800 + } + } Self::KimiK2_6 | Self::KimiK2_5 => 262_144, Self::MimoV2_5Pro => 1_048_576, Self::MimoV2_5 => 1_000_000, Self::Qwen3_5Plus | Self::Qwen3_6Plus => 262_144, Self::BigPickle => 200_000, - Self::Ring2_6_1TFree => 262_000, Self::Nemotron3SuperFree => 204_800, Self::DeepSeekV4Pro | Self::DeepSeekV4Flash => 1_000_000, @@ -450,7 +449,7 @@ impl Model { } } - pub fn max_output_tokens(&self) -> Option { + pub fn max_output_tokens(&self, subscription: OpenCodeSubscription) -> Option { match self { // Anthropic models Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 => Some(128_000), @@ -485,10 +484,22 @@ impl Model { // OpenAI-compatible models Self::MiniMaxM2_7 => Some(131_072), - Self::MiniMaxM2_5 | Self::MiniMaxM2_5Free => Some(131_072), - Self::Glm5 | Self::Glm5_1 => Some(32_768), + Self::MiniMaxM2_5Free => Some(131_072), + Self::MiniMaxM2_5 => { + if subscription == OpenCodeSubscription::Go { + Some(65_536) + } else { + Some(131_072) + } + } + Self::Glm5 | Self::Glm5_1 => { + if subscription == OpenCodeSubscription::Go { + Some(32_768) + } else { + Some(131_072) + } + } Self::BigPickle => Some(128_000), - Self::Ring2_6_1TFree => Some(66_000), Self::KimiK2_6 | Self::KimiK2_5 => Some(65_536), Self::Qwen3_5Plus | Self::Qwen3_6Plus => Some(65_536), Self::DeepSeekV4Pro | Self::DeepSeekV4Flash => Some(384_000), @@ -525,7 +536,6 @@ impl Model { | Self::Gpt5_4Mini | Self::Gpt5_4Nano | Self::Gpt5_3Codex - | Self::Gpt5_3Spark | Self::Gpt5_2 | Self::Gpt5_2Codex | Self::Gpt5_1 @@ -536,6 +546,9 @@ impl Model { | Self::Gpt5Codex | Self::Gpt5Nano => true, + // OpenAI models without image support + Self::Gpt5_3Spark => false, + // Google models support images Self::Gemini3_1Pro | Self::Gemini3Flash => true, @@ -556,7 +569,6 @@ impl Model { | Self::DeepSeekV4Pro | Self::DeepSeekV4Flash | Self::BigPickle - | Self::Ring2_6_1TFree | Self::Nemotron3SuperFree => false, Self::Custom { protocol, .. } => matches!( @@ -571,7 +583,7 @@ impl Model { pub fn supported_reasoning_effort_levels(&self) -> Option> { match self { - Self::Ring2_6_1TFree | Self::MimoV2_5Pro | Self::MimoV2_5 => Some(vec![ + Self::MimoV2_5Pro | Self::MimoV2_5 => Some(vec![ ReasoningEffort::Low, ReasoningEffort::Medium, ReasoningEffort::High, diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index 3a8455a327c..3c08a960da8 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -656,6 +656,8 @@ By default, models from all subscription types are shown. Optionally, you can hi } ``` +**Note:** Zed only bundles configuration for long-term OpenCode Free models! Free models that are only available for a limited time are not included in Zed. To use such models, create a Custom Model using the configuration settings published on [the OpenCode website](https://opencode.ai/docs/zen#pricing) and on [models.dev](https://github.com/anomalyco/models.dev/tree/dev/providers/opencode/models). + #### Custom Models {#opencode-custom-models} The Zed agent comes pre-configured with OpenCode models. If you wish to use newer models or models with custom endpoints, you can do so by adding the following to your Zed settings file ([how to edit](../configuring-zed.md#settings-files)): From 7a37888f7bc93146ee435ca404dc4b6923ddb71a Mon Sep 17 00:00:00 2001 From: Neel Date: Mon, 18 May 2026 19:02:51 +0100 Subject: [PATCH 025/289] editor: Add action to toggle all diff hunks (#56421) Release Notes: - Added action to toggle all diff hunks --- crates/editor/src/actions.rs | 3 +++ crates/editor/src/element.rs | 1 + crates/editor/src/git.rs | 13 +++++++++++++ 3 files changed, 17 insertions(+) diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 01f52e7064d..03557c029f1 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -501,6 +501,9 @@ actions!( ExpandAllDiffHunks, /// Collapses all diff hunks in the editor. CollapseAllDiffHunks, + /// Toggles all diff hunks in the editor. Collapses all hunks if any are + /// currently expanded, otherwise expands all hunks. + ToggleAllDiffHunks, /// Expands macros recursively at cursor position. ExpandMacroRecursively, /// Finds the next match in the search. diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index e04161b6c8a..97380794178 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -468,6 +468,7 @@ impl EditorElement { register_action(editor, window, Editor::unstage_and_next); register_action(editor, window, Editor::expand_all_diff_hunks); register_action(editor, window, Editor::collapse_all_diff_hunks); + register_action(editor, window, Editor::toggle_all_diff_hunks); register_action(editor, window, Editor::toggle_review_comments_expanded); register_action(editor, window, Editor::submit_diff_review_comment_action); register_action(editor, window, Editor::edit_review_comment); diff --git a/crates/editor/src/git.rs b/crates/editor/src/git.rs index 2571c0b2022..16b1bc4daee 100644 --- a/crates/editor/src/git.rs +++ b/crates/editor/src/git.rs @@ -759,6 +759,19 @@ impl Editor { }); } + pub fn toggle_all_diff_hunks( + &mut self, + _: &ToggleAllDiffHunks, + window: &mut Window, + cx: &mut Context, + ) { + if self.has_any_expanded_diff_hunks(cx) { + self.collapse_all_diff_hunks(&CollapseAllDiffHunks, window, cx); + } else { + self.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx); + } + } + pub(super) fn toggle_selected_diff_hunks( &mut self, _: &ToggleSelectedDiffHunks, From c3951af24fff4942fdd941d814f1d8c84e25b9e3 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 18 May 2026 20:27:54 +0200 Subject: [PATCH 026/289] acp: Support additional session directories (#57051) Still behind a feature flag for now for testing with various agents. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- Cargo.lock | 8 +- Cargo.toml | 2 +- crates/acp_thread/src/connection.rs | 19 + crates/agent_servers/src/acp.rs | 489 +++++++++++++++++- .../src/conversation_view/thread_view.rs | 9 + 5 files changed, 496 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cfe21109573..3631fa41c4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -224,9 +224,9 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1084cabbc2b00d353bad7e54750b0ef0f0bba9204c5884240c83a628704db86c" +checksum = "4361ba6627e51de955b10f3c77fb9eb959c85191a236c1c2c84e32f4ff240faf" dependencies = [ "agent-client-protocol-derive", "agent-client-protocol-schema", @@ -259,9 +259,9 @@ dependencies = [ [[package]] name = "agent-client-protocol-schema" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984583e634f3f4d479b585aaa76de4a633255dcdf2be6489c6a8486f758af04" +checksum = "b957d8391ac3933e2a940446171c508d2b8ffc386d8fa7d0b9c936a2575b463e" dependencies = [ "anyhow", "derive_more", diff --git a/Cargo.toml b/Cargo.toml index 7303d4ebe0a..e6d883245ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -500,7 +500,7 @@ ztracing_macro = { path = "crates/ztracing_macro" } # External crates # -agent-client-protocol = { version = "=0.12.0", features = ["unstable"] } +agent-client-protocol = { version = "=0.12.1", features = ["unstable"] } aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "9d9640d4" } any_vec = "0.14" diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 87c8ccf65c1..f58d8a581b8 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -115,6 +115,11 @@ pub trait AgentConnection { self.supports_load_session() || self.supports_resume_session() } + /// Whether this agent supports additional session directories. + fn supports_session_additional_directories(&self, _cx: &App) -> bool { + false + } + fn auth_methods(&self) -> &[acp::AuthMethod]; fn terminal_auth_task( @@ -702,6 +707,7 @@ mod test_support { permission_requests: HashMap, next_prompt_updates: Arc>>, supports_load_session: bool, + supports_session_additional_directories: bool, agent_id: AgentId, telemetry_id: SharedString, } @@ -724,6 +730,7 @@ mod test_support { permission_requests: HashMap::default(), sessions: Arc::default(), supports_load_session: false, + supports_session_additional_directories: false, agent_id: AgentId::new("stub"), telemetry_id: "stub".into(), } @@ -746,6 +753,14 @@ mod test_support { self } + pub fn with_supports_session_additional_directories( + mut self, + supports_session_additional_directories: bool, + ) -> Self { + self.supports_session_additional_directories = supports_session_additional_directories; + self + } + pub fn with_agent_id(mut self, agent_id: AgentId) -> Self { self.agent_id = agent_id; self @@ -863,6 +878,10 @@ mod test_support { self.supports_load_session } + fn supports_session_additional_directories(&self, _cx: &App) -> bool { + self.supports_session_additional_directories + } + fn load_session( self: Rc, session_id: acp::SessionId, diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index b3328790a5d..ff5519b7240 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -9,7 +9,7 @@ use agent_client_protocol::{ }; use anyhow::anyhow; use async_channel; -use collections::HashMap; +use collections::{HashMap, HashSet}; use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _}; use futures::channel::mpsc; use futures::future::Shared; @@ -509,6 +509,7 @@ impl AgentSessionList for AcpSessionList { cx: &mut App, ) -> Task> { let conn = self.connection.clone(); + let include_additional_directories = cx.has_flag::(); cx.foreground_executor().spawn(async move { let acp_request = acp::ListSessionsRequest::new() .cwd(request.cwd) @@ -522,7 +523,14 @@ impl AgentSessionList for AcpSessionList { .into_iter() .map(|s| AgentSessionInfo { session_id: s.session_id, - work_dirs: Some(PathList::new(&[s.cwd])), + work_dirs: Some(work_dirs_from_session_info( + s.cwd, + if include_additional_directories { + s.additional_directories + } else { + vec![] + }, + )), title: s.title.map(Into::into), updated_at: s.updated_at.and_then(|date_str| { chrono::DateTime::parse_from_rfc3339(&date_str) @@ -1053,6 +1061,15 @@ impl AcpConnection { } } + fn session_directories_from_work_dirs( + &self, + work_dirs: &PathList, + cx: &App, + ) -> Result { + let supports_additional_directories = self.supports_session_additional_directories(cx); + session_directories_from_work_dirs(work_dirs, supports_additional_directories) + } + fn open_or_create_session( self: Rc, session_id: acp::SessionId, @@ -1062,7 +1079,7 @@ impl AcpConnection { rpc_call: impl FnOnce( ConnectionTo, acp::SessionId, - PathBuf, + SessionDirectories, ) -> futures::future::LocalBoxFuture<'static, Result> + 'static, @@ -1089,9 +1106,9 @@ impl AcpConnection { } } - // TODO: remove this once ACP supports multiple working directories - let Some(cwd) = work_dirs.ordered_paths().next().cloned() else { - return Task::ready(Err(anyhow!("Working directory cannot be empty"))); + let directories = match self.session_directories_from_work_dirs(&work_dirs, cx) { + Ok(directories) => directories, + Err(error) => return Task::ready(Err(error)), }; let shared_task = cx @@ -1133,7 +1150,9 @@ impl AcpConnection { ); let response = - match rpc_call(this.connection.clone(), session_id.clone(), cwd).await { + match rpc_call(this.connection.clone(), session_id.clone(), directories) + .await + { Ok(response) => response, Err(err) => { this.sessions.borrow_mut().remove(&session_id); @@ -1288,6 +1307,77 @@ impl AcpConnection { } } +#[derive(Clone, Debug, PartialEq, Eq)] +struct SessionDirectories { + cwd: PathBuf, + additional_directories: Vec, +} + +impl SessionDirectories { + fn into_new_session_request(self, mcp_servers: Vec) -> acp::NewSessionRequest { + acp::NewSessionRequest::new(self.cwd) + .additional_directories(self.additional_directories) + .mcp_servers(mcp_servers) + } + + fn into_load_session_request( + self, + session_id: acp::SessionId, + mcp_servers: Vec, + ) -> acp::LoadSessionRequest { + acp::LoadSessionRequest::new(session_id, self.cwd) + .additional_directories(self.additional_directories) + .mcp_servers(mcp_servers) + } + + fn into_resume_session_request( + self, + session_id: acp::SessionId, + mcp_servers: Vec, + ) -> acp::ResumeSessionRequest { + acp::ResumeSessionRequest::new(session_id, self.cwd) + .additional_directories(self.additional_directories) + .mcp_servers(mcp_servers) + } +} + +fn session_directories_from_work_dirs( + work_dirs: &PathList, + supports_additional_directories: bool, +) -> Result { + let mut ordered_paths = work_dirs.ordered_paths(); + let cwd = ordered_paths + .next() + .cloned() + .ok_or_else(|| anyhow!("Working directory cannot be empty"))?; + let additional_directories = if supports_additional_directories { + ordered_paths.cloned().collect() + } else { + Vec::new() + }; + + Ok(SessionDirectories { + cwd, + additional_directories, + }) +} + +fn work_dirs_from_session_info(cwd: PathBuf, additional_directories: Vec) -> PathList { + let mut seen_paths = HashSet::default(); + let mut paths = Vec::with_capacity(1 + additional_directories.len()); + + seen_paths.insert(cwd.clone()); + paths.push(cwd); + + for path in additional_directories { + if seen_paths.insert(path.clone()) { + paths.push(path); + } + } + + PathList::new(&paths) +} + fn emit_load_error_to_all_sessions( sessions: &Rc>>, error: LoadError, @@ -1385,17 +1475,18 @@ impl AgentConnection for AcpConnection { work_dirs: PathList, cx: &mut App, ) -> Task>> { - // TODO: remove this once ACP supports multiple working directories - let Some(cwd) = work_dirs.ordered_paths().next().cloned() else { - return Task::ready(Err(anyhow!("Working directory cannot be empty"))); + let directories = match self.session_directories_from_work_dirs(&work_dirs, cx) { + Ok(directories) => directories, + Err(error) => return Task::ready(Err(error)), }; let name = self.id.0.clone(); let mcp_servers = mcp_servers_for_project(&project, cx); cx.spawn(async move |cx| { let response = into_foreground_future( - self.connection - .send_request(acp::NewSessionRequest::new(cwd.clone()).mcp_servers(mcp_servers)), + self.connection.send_request( + directories.into_new_session_request(mcp_servers), + ), ) .await .map_err(map_acp_error)?; @@ -1550,6 +1641,15 @@ impl AgentConnection for AcpConnection { .is_some() } + fn supports_session_additional_directories(&self, cx: &App) -> bool { + cx.has_flag::() + && self + .agent_capabilities + .session_capabilities + .additional_directories + .is_some() + } + fn load_session( self: Rc, session_id: acp::SessionId, @@ -1570,14 +1670,11 @@ impl AgentConnection for AcpConnection { project, work_dirs, title, - move |connection, session_id, cwd| { + move |connection, session_id, directories| { Box::pin(async move { - let response = into_foreground_future( - connection.send_request( - acp::LoadSessionRequest::new(session_id.clone(), cwd) - .mcp_servers(mcp_servers), - ), - ) + let response = into_foreground_future(connection.send_request( + directories.into_load_session_request(session_id.clone(), mcp_servers), + )) .await .map_err(map_acp_error)?; Ok(SessionConfigResponse { @@ -1616,14 +1713,11 @@ impl AgentConnection for AcpConnection { project, work_dirs, title, - move |connection, session_id, cwd| { + move |connection, session_id, directories| { Box::pin(async move { - let response = into_foreground_future( - connection.send_request( - acp::ResumeSessionRequest::new(session_id.clone(), cwd) - .mcp_servers(mcp_servers), - ), - ) + let response = into_foreground_future(connection.send_request( + directories.into_resume_session_request(session_id.clone(), mcp_servers), + )) .await .map_err(map_acp_error)?; Ok(SessionConfigResponse { @@ -2107,6 +2201,10 @@ pub mod test_support { self.inner.supports_resume_session() } + fn supports_session_additional_directories(&self, cx: &App) -> bool { + self.inner.supports_session_additional_directories(cx) + } + fn resume_session( self: Rc, session_id: acp::SessionId, @@ -2557,6 +2655,345 @@ mod tests { ); } + #[test] + fn session_directories_use_ordered_paths_when_supported() { + let work_dirs = PathList::new(&[ + std::path::PathBuf::from("/workspace-b"), + std::path::PathBuf::from("/workspace-a"), + std::path::PathBuf::from("/workspace-c"), + ]); + + let directories = + session_directories_from_work_dirs(&work_dirs, true).expect("work dirs should convert"); + + assert_eq!( + directories, + SessionDirectories { + cwd: std::path::PathBuf::from("/workspace-b"), + additional_directories: vec![ + std::path::PathBuf::from("/workspace-a"), + std::path::PathBuf::from("/workspace-c") + ], + } + ); + + let session_id = acp::SessionId::new("session-1"); + let new_session_request = directories.clone().into_new_session_request(Vec::new()); + let load_session_request = directories + .clone() + .into_load_session_request(session_id.clone(), Vec::new()); + let resume_session_request = + directories.into_resume_session_request(session_id, Vec::new()); + + assert_eq!( + new_session_request.cwd, + std::path::PathBuf::from("/workspace-b") + ); + assert_eq!( + new_session_request.additional_directories, + vec![ + std::path::PathBuf::from("/workspace-a"), + std::path::PathBuf::from("/workspace-c") + ] + ); + assert_eq!( + load_session_request.additional_directories, + new_session_request.additional_directories + ); + assert_eq!( + resume_session_request.additional_directories, + new_session_request.additional_directories + ); + } + + #[test] + fn session_directories_drop_additional_paths_when_unsupported() { + let work_dirs = PathList::new(&[ + std::path::PathBuf::from("/workspace-b"), + std::path::PathBuf::from("/workspace-a"), + ]); + + let directories = session_directories_from_work_dirs(&work_dirs, false) + .expect("work dirs should convert"); + + assert_eq!( + directories, + SessionDirectories { + cwd: std::path::PathBuf::from("/workspace-b"), + additional_directories: Vec::new(), + } + ); + } + + #[test] + fn session_info_work_dirs_preserve_cwd_then_additional_directories() { + let work_dirs = work_dirs_from_session_info( + std::path::PathBuf::from("/workspace-b"), + vec![ + std::path::PathBuf::from("/workspace-a"), + std::path::PathBuf::from("/workspace-c"), + ], + ); + + assert_eq!( + work_dirs.ordered_paths().cloned().collect::>(), + vec![ + std::path::PathBuf::from("/workspace-b"), + std::path::PathBuf::from("/workspace-a"), + std::path::PathBuf::from("/workspace-c"), + ] + ); + } + + #[test] + fn session_info_work_dirs_deduplicate_cwd_and_additional_directories() { + let work_dirs = work_dirs_from_session_info( + std::path::PathBuf::from("/workspace-b"), + vec![ + std::path::PathBuf::from("/workspace-a"), + std::path::PathBuf::from("/workspace-b"), + std::path::PathBuf::from("/workspace-a"), + std::path::PathBuf::from("/workspace-c"), + ], + ); + + assert_eq!( + work_dirs.ordered_paths().cloned().collect::>(), + vec![ + std::path::PathBuf::from("/workspace-b"), + std::path::PathBuf::from("/workspace-a"), + std::path::PathBuf::from("/workspace-c"), + ] + ); + } + + #[gpui::test] + async fn session_list_includes_additional_directories_in_work_dirs_when_beta_enabled( + cx: &mut gpui::TestAppContext, + ) { + cx.update(|cx| set_acp_beta_override(cx, "on")); + let connection = connect_session_list_test_agent( + vec![ + acp::SessionInfo::new("session-1", "/workspace-b").additional_directories(vec![ + std::path::PathBuf::from("/workspace-a"), + std::path::PathBuf::from("/workspace-b"), + std::path::PathBuf::from("/workspace-a"), + std::path::PathBuf::from("/workspace-c"), + ]), + ], + cx, + ) + .await; + let session_list = AcpSessionList::new(connection, false); + + let response = cx + .update(|cx| session_list.list_sessions(AgentSessionListRequest::default(), cx)) + .await + .expect("session list should load"); + let session = response + .sessions + .first() + .expect("session list should include the returned session"); + let work_dirs = session + .work_dirs + .as_ref() + .expect("session should include work dirs"); + + assert_eq!( + work_dirs.ordered_paths().cloned().collect::>(), + vec![ + std::path::PathBuf::from("/workspace-b"), + std::path::PathBuf::from("/workspace-a"), + std::path::PathBuf::from("/workspace-c"), + ] + ); + } + + #[gpui::test] + async fn session_list_excludes_additional_directories_in_work_dirs_when_beta_disabled( + cx: &mut gpui::TestAppContext, + ) { + cx.update(|cx| set_acp_beta_override(cx, "off")); + + let connection = connect_session_list_test_agent( + vec![ + acp::SessionInfo::new("session-1", "/workspace-b").additional_directories(vec![ + std::path::PathBuf::from("/workspace-a"), + std::path::PathBuf::from("/workspace-c"), + ]), + ], + cx, + ) + .await; + let session_list = AcpSessionList::new(connection, false); + + let response = cx + .update(|cx| session_list.list_sessions(AgentSessionListRequest::default(), cx)) + .await + .expect("session list should load"); + let session = response + .sessions + .first() + .expect("session list should include the returned session"); + let work_dirs = session + .work_dirs + .as_ref() + .expect("session should include work dirs"); + + assert_eq!( + work_dirs.ordered_paths().cloned().collect::>(), + vec![std::path::PathBuf::from("/workspace-b")] + ); + } + + fn set_acp_beta_override(cx: &mut App, value: &str) { + let store = settings::SettingsStore::test(cx); + cx.set_global(store); + settings::SettingsStore::update_global(cx, |store, _| { + store.register_setting::(); + }); + feature_flags::FeatureFlagStore::init(cx); + + let value = value.to_string(); + settings::SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |content| { + content + .feature_flags + .get_or_insert_default() + .insert(AcpBetaFeatureFlag::NAME.to_string(), value); + }); + }); + } + + async fn connect_session_list_test_agent( + sessions: Vec, + cx: &mut gpui::TestAppContext, + ) -> ConnectionTo { + let (client_transport, agent_transport) = agent_client_protocol::Channel::duplex(); + let sessions = Arc::new(sessions); + + cx.background_spawn( + Agent + .builder() + .name("list-test-agent") + .on_receive_request( + { + let sessions = sessions.clone(); + async move |_request: acp::ListSessionsRequest, responder, _cx| { + responder.respond(acp::ListSessionsResponse::new((*sessions).clone())) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .connect_to(agent_transport), + ) + .detach(); + + let (connection_tx, connection_rx) = futures::channel::oneshot::channel(); + cx.background_spawn(Client.builder().name("list-test-client").connect_with( + client_transport, + move |connection: ConnectionTo| async move { + connection_tx.send(connection).ok(); + futures::future::pending::>().await + }, + )) + .detach(); + + connection_rx + .await + .expect("failed to receive ACP connection") + } + + #[gpui::test] + async fn additional_directories_support_requires_beta_flag_and_agent_capability( + cx: &mut gpui::TestAppContext, + ) { + cx.update(|cx| { + let store = settings::SettingsStore::test(cx); + cx.set_global(store); + settings::SettingsStore::update_global(cx, |store, _| { + store.register_setting::(); + }); + feature_flags::FeatureFlagStore::init(cx); + }); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree("/", serde_json::json!({ "a": {}, "b": {} })) + .await; + let project = project::Project::test(fs, [std::path::Path::new("/a")], cx).await; + let mut harness = test_support::connect_fake_acp_connection(project, cx).await; + cx.update(|cx| { + settings::SettingsStore::update_global(cx, |store, _| { + store.register_setting::(); + }); + feature_flags::FeatureFlagStore::init(cx); + }); + + let work_dirs = PathList::new(&[ + std::path::PathBuf::from("/workspace-b"), + std::path::PathBuf::from("/workspace-a"), + ]); + + let missing_capability = cx + .update(|cx| { + harness + .connection + .session_directories_from_work_dirs(&work_dirs, cx) + }) + .expect("work dirs should convert"); + assert!(missing_capability.additional_directories.is_empty()); + + Rc::get_mut(&mut harness.connection) + .expect("test harness should own the only ACP connection handle") + .agent_capabilities + .session_capabilities + .additional_directories = Some(acp::SessionAdditionalDirectoriesCapabilities::new()); + + cx.update(|cx| { + settings::SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |content| { + content + .feature_flags + .get_or_insert_default() + .insert("acp-beta".to_string(), "off".to_string()); + }); + }); + }); + let disabled = cx + .update(|cx| { + harness + .connection + .session_directories_from_work_dirs(&work_dirs, cx) + }) + .expect("work dirs should convert"); + assert!(disabled.additional_directories.is_empty()); + + cx.update(|cx| { + settings::SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |content| { + content + .feature_flags + .get_or_insert_default() + .insert("acp-beta".to_string(), "on".to_string()); + }); + }); + }); + let enabled = cx + .update(|cx| { + harness + .connection + .session_directories_from_work_dirs(&work_dirs, cx) + }) + .expect("work dirs should convert"); + assert_eq!( + enabled, + SessionDirectories { + cwd: std::path::PathBuf::from("/workspace-b"), + additional_directories: vec![std::path::PathBuf::from("/workspace-a")], + } + ); + } + #[gpui::test] async fn session_delete_support_requires_beta_flag_and_capability( cx: &mut gpui::TestAppContext, diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 3bd2fb6326b..9d78baf826c 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -8826,6 +8826,15 @@ impl ThreadView { return None; } + if self + .thread + .read(cx) + .connection() + .supports_session_additional_directories(cx) + { + return None; + } + let project = self.project.upgrade()?; let worktree_count = project.read(cx).visible_worktrees(cx).count(); if worktree_count <= 1 { From 10fc0fb5271e2f41d32cb11fcbe2c65ca6d67654 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 18 May 2026 21:07:09 +0200 Subject: [PATCH 027/289] Update wgpu to 29.0.3 (#57086) Updates our fork to the latest v29 branch. Still waiting on a backport to get upstreamed so we can go back to the main crate. Release Notes: - N/A --- Cargo.lock | 37 +++++++++++++++++++------------------ Cargo.toml | 2 +- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3631fa41c4e..b46f8c8f265 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11004,8 +11004,8 @@ checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" [[package]] name = "naga" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "arrayvec", "bit-set 0.9.1", @@ -20559,8 +20559,8 @@ checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" [[package]] name = "wgpu" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "arrayvec", "bitflags 2.10.0", @@ -20588,8 +20588,8 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "arrayvec", "bit-set 0.9.1", @@ -20620,32 +20620,32 @@ dependencies = [ [[package]] name = "wgpu-core-deps-apple" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-emscripten" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-windows-linux-android" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-hal" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "android_system_properties", "arrayvec", @@ -20692,12 +20692,13 @@ dependencies = [ "wgpu-types", "windows 0.62.2", "windows-core 0.62.2", + "windows-result 0.4.1", ] [[package]] name = "wgpu-naga-bridge" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "naga", "wgpu-types", @@ -20705,8 +20706,8 @@ dependencies = [ [[package]] name = "wgpu-types" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "bitflags 2.10.0", "bytemuck", diff --git a/Cargo.toml b/Cargo.toml index e6d883245ab..7e113da2f7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -812,7 +812,7 @@ which = "6.0.0" wasm-bindgen = "0.2.120" web-time = "1.1.0" webrtc-sys = "0.3.23" -wgpu = { git = "https://github.com/zed-industries/wgpu.git", branch = "v29" } +wgpu = { git = "https://github.com/zed-industries/wgpu.git", rev = "357a0c56e0070480ad9daea5d2eaa83150b79e88" } windows-core = "0.61" yaml-rust2 = "0.8" yawc = "0.2.5" From 2a00db06ce6d01089bfafd207b6348078e980df9 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 18 May 2026 23:10:06 +0200 Subject: [PATCH 028/289] node_runtime: Respect npm release-age filters for managed npm installs (#56957) Zed-managed npm installers were resolving a concrete latest version with `npm info` and then installing `package@version`. That is brittle when users configure npm release-age filtering via `before` or `min-release-age`: npm's installer applies those rules during resolution, but our pinned install target could disagree with it, and therefore fail to install. This changes managed npm installs to install `package@latest` and let npm apply its own resolver and user config. The local latest-version lookup remains as a best-effort cache freshness check, not as the exact install target. Exact extension API installs remain unchanged because extensions explicitly request a package and version. If we want to revisit that we can. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #53611 Release Notes: - Fixed npm-backed tool installs to better respect npm release-age filters. --- Cargo.lock | 1 + crates/copilot/src/copilot.rs | 5 +- crates/languages/src/bash.rs | 10 +- crates/languages/src/css.rs | 10 +- crates/languages/src/json.rs | 10 +- crates/languages/src/python.rs | 22 +- crates/languages/src/tailwind.rs | 10 +- crates/languages/src/tailwindcss.rs | 10 +- crates/languages/src/typescript.rs | 11 +- crates/languages/src/vtsls.rs | 47 +++- crates/languages/src/yaml.rs | 10 +- crates/node_runtime/Cargo.toml | 1 + crates/node_runtime/src/node_runtime.rs | 345 +++++++++++++++++++++++- crates/project/src/debugger/session.rs | 2 +- crates/project/src/prettier_store.rs | 30 +-- 15 files changed, 405 insertions(+), 119 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b46f8c8f265..51a1d750fa4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11178,6 +11178,7 @@ dependencies = [ "async-std", "async-tar", "async-trait", + "chrono", "futures 0.3.32", "http_client", "log", diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index 4fa41fc8cb4..a48bf3c1a43 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -1413,10 +1413,7 @@ async fn get_copilot_lsp(fs: Arc, node_runtime: NodeRuntime) -> anyhow:: .await; if should_install { node_runtime - .npm_install_packages( - paths::copilot_dir(), - &[(PACKAGE_NAME, &latest_version.to_string())], - ) + .npm_install_latest_packages(paths::copilot_dir(), &[PACKAGE_NAME]) .await?; } diff --git a/crates/languages/src/bash.rs b/crates/languages/src/bash.rs index 438090e2aa9..2f550e87c7f 100644 --- a/crates/languages/src/bash.rs +++ b/crates/languages/src/bash.rs @@ -141,7 +141,7 @@ impl LspInstaller for BashLspAdapter { fn fetch_server_binary( &self, - latest_version: Self::BinaryVersion, + _latest_version: Self::BinaryVersion, container_dir: std::path::PathBuf, delegate: &Arc, ) -> impl Send + Future> + use<> { @@ -152,13 +152,9 @@ impl LspInstaller for BashLspAdapter { let server_path = container_dir .join("node_modules") .join(Self::NODE_MODULE_RELATIVE_SERVER_PATH); - let latest_version = latest_version.to_string(); - node.npm_install_packages( - &container_dir, - &[(Self::PACKAGE_NAME, latest_version.as_str())], - ) - .await?; + node.npm_install_latest_packages(&container_dir, &[Self::PACKAGE_NAME]) + .await?; let env = delegate.shell_env().await; Ok(LanguageServerBinary { diff --git a/crates/languages/src/css.rs b/crates/languages/src/css.rs index dfa0bc9fd3d..4506481a17b 100644 --- a/crates/languages/src/css.rs +++ b/crates/languages/src/css.rs @@ -67,7 +67,7 @@ impl LspInstaller for CssLspAdapter { fn fetch_server_binary( &self, - latest_version: Self::BinaryVersion, + _latest_version: Self::BinaryVersion, container_dir: PathBuf, _: &Arc, ) -> impl Send + Future> + use<> { @@ -75,13 +75,9 @@ impl LspInstaller for CssLspAdapter { async move { let server_path = container_dir.join(SERVER_PATH); - let latest_version = latest_version.to_string(); - node.npm_install_packages( - &container_dir, - &[(Self::PACKAGE_NAME, latest_version.as_str())], - ) - .await?; + node.npm_install_latest_packages(&container_dir, &[Self::PACKAGE_NAME]) + .await?; Ok(LanguageServerBinary { path: node.binary_path().await?, diff --git a/crates/languages/src/json.rs b/crates/languages/src/json.rs index 9cd6c1565ad..8389fd65f65 100644 --- a/crates/languages/src/json.rs +++ b/crates/languages/src/json.rs @@ -213,7 +213,7 @@ impl LspInstaller for JsonLspAdapter { fn fetch_server_binary( &self, - latest_version: Self::BinaryVersion, + _latest_version: Self::BinaryVersion, container_dir: PathBuf, _: &Arc, ) -> impl Send + Future> + use<> { @@ -221,13 +221,9 @@ impl LspInstaller for JsonLspAdapter { async move { let server_path = container_dir.join(SERVER_PATH); - let latest_version = latest_version.to_string(); - node.npm_install_packages( - &container_dir, - &[(Self::PACKAGE_NAME, latest_version.as_str())], - ) - .await?; + node.npm_install_latest_packages(&container_dir, &[Self::PACKAGE_NAME]) + .await?; Ok(LanguageServerBinary { path: node.binary_path().await?, diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index 483430bd75d..5d2024d3b8d 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -786,7 +786,7 @@ impl LspInstaller for PyrightLspAdapter { fn fetch_server_binary( &self, - latest_version: Self::BinaryVersion, + _latest_version: Self::BinaryVersion, container_dir: PathBuf, delegate: &Arc, ) -> impl Send + Future> + use<> { @@ -795,13 +795,8 @@ impl LspInstaller for PyrightLspAdapter { async move { let server_path = container_dir.join(Self::SERVER_PATH); - let latest_version = latest_version.to_string(); - - node.npm_install_packages( - &container_dir, - &[(Self::SERVER_NAME.as_ref(), latest_version.as_str())], - ) - .await?; + node.npm_install_latest_packages(&container_dir, &[Self::SERVER_NAME.as_ref()]) + .await?; let env = delegate.shell_env().await; Ok(LanguageServerBinary { @@ -2252,7 +2247,7 @@ impl LspInstaller for BasedPyrightLspAdapter { fn fetch_server_binary( &self, - latest_version: Self::BinaryVersion, + _latest_version: Self::BinaryVersion, container_dir: PathBuf, delegate: &Arc, ) -> impl Send + Future> + use<> { @@ -2261,13 +2256,8 @@ impl LspInstaller for BasedPyrightLspAdapter { async move { let server_path = container_dir.join(Self::SERVER_PATH); - let latest_version = latest_version.to_string(); - - node.npm_install_packages( - &container_dir, - &[(Self::SERVER_NAME.as_ref(), latest_version.as_str())], - ) - .await?; + node.npm_install_latest_packages(&container_dir, &[Self::SERVER_NAME.as_ref()]) + .await?; let env = delegate.shell_env().await; Ok(LanguageServerBinary { diff --git a/crates/languages/src/tailwind.rs b/crates/languages/src/tailwind.rs index 41fa248a935..6d4211b58c8 100644 --- a/crates/languages/src/tailwind.rs +++ b/crates/languages/src/tailwind.rs @@ -72,7 +72,7 @@ impl LspInstaller for TailwindLspAdapter { fn fetch_server_binary( &self, - latest_version: Self::BinaryVersion, + _latest_version: Self::BinaryVersion, container_dir: PathBuf, _: &Arc, ) -> impl Send + Future> + use<> { @@ -80,13 +80,9 @@ impl LspInstaller for TailwindLspAdapter { async move { let server_path = container_dir.join(SERVER_PATH); - let latest_version = latest_version.to_string(); - node.npm_install_packages( - &container_dir, - &[(Self::PACKAGE_NAME, latest_version.as_str())], - ) - .await?; + node.npm_install_latest_packages(&container_dir, &[Self::PACKAGE_NAME]) + .await?; Ok(LanguageServerBinary { path: node.binary_path().await?, diff --git a/crates/languages/src/tailwindcss.rs b/crates/languages/src/tailwindcss.rs index dcc9e8bf4ef..0e9ac9af40f 100644 --- a/crates/languages/src/tailwindcss.rs +++ b/crates/languages/src/tailwindcss.rs @@ -68,7 +68,7 @@ impl LspInstaller for TailwindCssLspAdapter { fn fetch_server_binary( &self, - latest_version: Self::BinaryVersion, + _latest_version: Self::BinaryVersion, container_dir: PathBuf, _: &Arc, ) -> impl Send + Future> + use<> { @@ -76,13 +76,9 @@ impl LspInstaller for TailwindCssLspAdapter { async move { let server_path = container_dir.join(SERVER_PATH); - let latest_version = latest_version.to_string(); - node.npm_install_packages( - &container_dir, - &[(Self::PACKAGE_NAME, latest_version.as_str())], - ) - .await?; + node.npm_install_latest_packages(&container_dir, &[Self::PACKAGE_NAME]) + .await?; Ok(LanguageServerBinary { path: node.binary_path().await?, diff --git a/crates/languages/src/typescript.rs b/crates/languages/src/typescript.rs index d6889d8cbb8..4d37898eca1 100644 --- a/crates/languages/src/typescript.rs +++ b/crates/languages/src/typescript.rs @@ -718,7 +718,7 @@ impl LspInstaller for TypeScriptLspAdapter { fn fetch_server_binary( &self, - latest_version: Self::BinaryVersion, + _latest_version: Self::BinaryVersion, container_dir: PathBuf, _: &Arc, ) -> impl Send + Future> + use<> { @@ -726,15 +726,10 @@ impl LspInstaller for TypeScriptLspAdapter { async move { let server_path = container_dir.join(Self::NEW_SERVER_PATH); - let typescript_version = latest_version.typescript_version.to_string(); - let server_version = latest_version.server_version.to_string(); - node.npm_install_packages( + node.npm_install_latest_packages( &container_dir, - &[ - (Self::PACKAGE_NAME, typescript_version.as_str()), - (Self::SERVER_PACKAGE_NAME, server_version.as_str()), - ], + &[Self::PACKAGE_NAME, Self::SERVER_PACKAGE_NAME], ) .await?; diff --git a/crates/languages/src/vtsls.rs b/crates/languages/src/vtsls.rs index 4bc4401ff30..c46ea39a4f1 100644 --- a/crates/languages/src/vtsls.rs +++ b/crates/languages/src/vtsls.rs @@ -126,7 +126,7 @@ impl LspInstaller for VtslsLspAdapter { fn fetch_server_binary( &self, - latest_version: Self::BinaryVersion, + _latest_version: Self::BinaryVersion, container_dir: PathBuf, _: &Arc, ) -> impl Send + Future> + use<> { @@ -135,21 +135,44 @@ impl LspInstaller for VtslsLspAdapter { async move { let server_path = container_dir.join(Self::SERVER_PATH); - let typescript_version = latest_version.typescript_version.to_string(); - let server_version = latest_version.server_version.to_string(); + node.npm_install_latest_packages( + &container_dir, + &[Self::PACKAGE_NAME, Self::TYPESCRIPT_PACKAGE_NAME], + ) + .await?; - let mut packages_to_install = Vec::new(); + Ok(LanguageServerBinary { + path: node.binary_path().await?, + env: None, + arguments: typescript_server_binary_arguments(&server_path), + }) + } + } + + fn check_if_version_installed( + &self, + version: &Self::BinaryVersion, + container_dir: &PathBuf, + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); + let typescript_version = version.typescript_version.clone(); + let server_version = version.server_version.clone(); + let container_dir = container_dir.clone(); + + async move { + let server_path = container_dir.join(Self::SERVER_PATH); if node .should_install_npm_package( Self::PACKAGE_NAME, &server_path, &container_dir, - VersionStrategy::Latest(&latest_version.server_version), + VersionStrategy::Latest(&server_version), ) .await { - packages_to_install.push((Self::PACKAGE_NAME, server_version.as_str())); + return None; } if node @@ -157,19 +180,15 @@ impl LspInstaller for VtslsLspAdapter { Self::TYPESCRIPT_PACKAGE_NAME, &container_dir.join(Self::TYPESCRIPT_TSDK_PATH), &container_dir, - VersionStrategy::Latest(&latest_version.typescript_version), + VersionStrategy::Latest(&typescript_version), ) .await { - packages_to_install - .push((Self::TYPESCRIPT_PACKAGE_NAME, typescript_version.as_str())); + return None; } - node.npm_install_packages(&container_dir, &packages_to_install) - .await?; - - Ok(LanguageServerBinary { - path: node.binary_path().await?, + Some(LanguageServerBinary { + path: node.binary_path().await.ok()?, env: None, arguments: typescript_server_binary_arguments(&server_path), }) diff --git a/crates/languages/src/yaml.rs b/crates/languages/src/yaml.rs index 22781acf25a..de9b11b03dc 100644 --- a/crates/languages/src/yaml.rs +++ b/crates/languages/src/yaml.rs @@ -68,7 +68,7 @@ impl LspInstaller for YamlLspAdapter { fn fetch_server_binary( &self, - latest_version: Self::BinaryVersion, + _latest_version: Self::BinaryVersion, container_dir: PathBuf, _: &Arc, ) -> impl Send + Future> + use<> { @@ -76,13 +76,9 @@ impl LspInstaller for YamlLspAdapter { async move { let server_path = container_dir.join(SERVER_PATH); - let latest_version = latest_version.to_string(); - node.npm_install_packages( - &container_dir, - &[(Self::PACKAGE_NAME, latest_version.as_str())], - ) - .await?; + node.npm_install_latest_packages(&container_dir, &[Self::PACKAGE_NAME]) + .await?; Ok(LanguageServerBinary { path: node.binary_path().await?, diff --git a/crates/node_runtime/Cargo.toml b/crates/node_runtime/Cargo.toml index dfa40ad666e..25f7b2997e5 100644 --- a/crates/node_runtime/Cargo.toml +++ b/crates/node_runtime/Cargo.toml @@ -20,6 +20,7 @@ anyhow.workspace = true async-compression.workspace = true async-tar.workspace = true async-trait.workspace = true +chrono.workspace = true futures.workspace = true http_client.workspace = true log.workspace = true diff --git a/crates/node_runtime/src/node_runtime.rs b/crates/node_runtime/src/node_runtime.rs index 9d4bfe9cffb..7ce29532644 100644 --- a/crates/node_runtime/src/node_runtime.rs +++ b/crates/node_runtime/src/node_runtime.rs @@ -1,6 +1,7 @@ use anyhow::{Context as _, Result, anyhow, bail}; use async_compression::futures::bufread::GzipDecoder; use async_tar::Archive; +use chrono::{DateTime, Utc}; use futures::{AsyncReadExt, FutureExt as _, channel::oneshot, future::Shared}; use http_client::{Host, HttpClient, Url}; use log::Level; @@ -253,9 +254,8 @@ impl NodeRuntime { pub async fn npm_package_latest_version(&self, name: &str) -> Result { let http = self.0.lock().await.http.clone(); - let output = self - .instance() - .await + let instance = self.instance().await; + let output = instance .run_npm_subcommand( None, http.proxy(), @@ -273,11 +273,18 @@ impl NodeRuntime { ) .await?; - let mut info: NpmInfo = serde_json::from_slice(&output.stdout)?; - info.dist_tags - .latest - .or_else(|| info.versions.pop()) - .with_context(|| format!("no version found for npm package {name}")) + let info: NpmInfo = serde_json::from_slice(&output.stdout)?; + let before = npm_config_before(instance.as_ref(), http.proxy()) + .await + .context("getting npm before config") + .log_err() + .flatten(); + let latest_dist_tag = info.dist_tags.latest.clone(); + let selected_version = select_npm_package_version(name, info, before.as_deref())?; + log::debug!( + "selected latest npm package version package={name:?} before={before:?} dist_tag_latest={latest_dist_tag:?} selected={selected_version}" + ); + Ok(selected_version) } pub async fn npm_install_packages( @@ -289,6 +296,11 @@ impl NodeRuntime { return Ok(()); } + log::debug!( + "installing npm packages directory={} packages={packages:?}", + directory.display() + ); + let packages: Vec<_> = packages .iter() .map(|(name, version)| format!("{name}@{version}")) @@ -314,6 +326,23 @@ impl NodeRuntime { Ok(()) } + pub async fn npm_install_latest_packages( + &self, + directory: &Path, + package_names: &[&str], + ) -> Result<()> { + // Let npm apply user config such as `before` and `min-release-age` during resolution. + log::debug!( + "installing latest npm packages directory={} packages={package_names:?}", + directory.display() + ); + let packages = package_names + .iter() + .map(|package_name| (*package_name, "latest")) + .collect::>(); + self.npm_install_packages(directory, &packages).await + } + pub async fn should_install_npm_package( &self, package_name: &str, @@ -325,6 +354,10 @@ impl NodeRuntime { // or in the instances where we fail to parse package.json data, // we attempt to install the package. if fs::metadata(local_executable_path).await.is_err() { + log::debug!( + "npm package cache miss package={package_name:?} reason=missing-executable executable={}", + local_executable_path.display() + ); return true; } @@ -334,13 +367,33 @@ impl NodeRuntime { .log_err() .flatten() else { + log::debug!( + "npm package cache miss package={package_name:?} reason=missing-installed-version package_dir={}", + local_package_directory.display() + ); return true; }; - match version_strategy { - VersionStrategy::Pin(pinned_version) => &installed_version != pinned_version, - VersionStrategy::Latest(latest_version) => &installed_version < latest_version, - } + let version_strategy_label = match &version_strategy { + VersionStrategy::Pin(version) => format!("pin:{version}"), + VersionStrategy::Latest(version) => format!("latest:{version}"), + }; + let should_install = + should_install_npm_package_version(&installed_version, version_strategy); + log::debug!( + "npm package cache check package={package_name:?} installed={installed_version} strategy={version_strategy_label} should_install={should_install}" + ); + should_install + } +} + +fn should_install_npm_package_version( + installed_version: &Version, + version_strategy: VersionStrategy<'_>, +) -> bool { + match version_strategy { + VersionStrategy::Pin(pinned_version) => installed_version != pinned_version, + VersionStrategy::Latest(latest_version) => installed_version < latest_version, } } @@ -355,6 +408,8 @@ pub struct NpmInfo { #[serde(default)] dist_tags: NpmInfoDistTags, versions: Vec, + #[serde(default)] + time: HashMap, } #[derive(Debug, Deserialize, Default)] @@ -362,6 +417,95 @@ pub struct NpmInfoDistTags { latest: Option, } +#[derive(Debug, Deserialize)] +struct NpmConfig { + #[serde(default)] + before: Option, +} + +async fn npm_config_before( + node_runtime: &dyn NodeRuntimeTrait, + proxy: Option<&Url>, +) -> Result> { + // `npm config get before` renders Date values for display. The JSON config output keeps the + // computed cutoff in the same ISO format used by `npm info --json` release times. + let output = node_runtime + .run_npm_subcommand(None, proxy, "config", &["list", "--json"]) + .await?; + let config: NpmConfig = serde_json::from_slice(&output.stdout)?; + Ok(config + .before + .filter(|before| !before.trim().is_empty() && before != "null")) +} + +fn select_npm_package_version( + package_name: &str, + mut info: NpmInfo, + before: Option<&str>, +) -> Result { + if let Some(before) = before + && !info.time.is_empty() + { + let before_timestamp = DateTime::parse_from_rfc3339(before) + .with_context(|| format!("parsing npm before config timestamp {before:?}"))? + .with_timezone(&Utc); + let latest_version = info.dist_tags.latest.as_ref(); + + if let Some(version) = latest_version + && npm_version_was_published_before(version, &info.time, &before_timestamp)? + { + return Ok(version.clone()); + } + + for version in info.versions.iter().rev() { + if is_allowed_npm_version_before( + version, + latest_version, + &info.time, + &before_timestamp, + )? { + return Ok(version.clone()); + } + } + + bail!("no version found for npm package {package_name} before {before}"); + } + + info.dist_tags + .latest + .or_else(|| info.versions.pop()) + .with_context(|| format!("no version found for npm package {package_name}")) +} + +fn is_allowed_npm_version_before( + version: &Version, + latest_version: Option<&Version>, + published_at_by_version: &HashMap, + before: &DateTime, +) -> Result { + if !version.pre.is_empty() + || latest_version.is_some_and(|latest_version| version > latest_version) + { + return Ok(false); + } + + npm_version_was_published_before(version, published_at_by_version, before) +} + +fn npm_version_was_published_before( + version: &Version, + published_at_by_version: &HashMap, + before: &DateTime, +) -> Result { + let Some(published_at) = published_at_by_version.get(&version.to_string()) else { + return Ok(false); + }; + let published_at = DateTime::parse_from_rfc3339(published_at) + .with_context(|| format!("parsing npm release timestamp for version {version}"))? + .with_timezone(&Utc); + Ok(&published_at <= before) +} + #[async_trait::async_trait] trait NodeRuntimeTrait: Send + Sync { fn boxed_clone(&self) -> Box; @@ -936,9 +1080,14 @@ fn npm_command_env(node_binary: Option<&Path>) -> HashMap { mod tests { use std::path::Path; + use anyhow::{Result, bail}; use http_client::Url; + use semver::Version; - use super::{build_npm_command_args, proxy_argument}; + use super::{ + NpmInfo, VersionStrategy, build_npm_command_args, proxy_argument, + select_npm_package_version, should_install_npm_package_version, + }; // Map localhost to 127.0.0.1 // NodeRuntime without environment information can not parse `localhost` correctly. @@ -1021,4 +1170,174 @@ mod tests { ] ); } + + #[test] + fn test_latest_version_strategy_accepts_newer_installed_versions() -> Result<()> { + let target_version = Version::parse("2.0.0")?; + + assert!(!should_install_npm_package_version( + &Version::parse("2.0.0")?, + VersionStrategy::Latest(&target_version) + )); + assert!(should_install_npm_package_version( + &Version::parse("1.0.0")?, + VersionStrategy::Latest(&target_version) + )); + assert!(!should_install_npm_package_version( + &Version::parse("3.0.0")?, + VersionStrategy::Latest(&target_version) + )); + + Ok(()) + } + + #[test] + fn test_select_npm_package_version_uses_dist_tag_without_before() -> Result<()> { + let info: NpmInfo = serde_json::from_str( + r#"{ + "dist-tags": { "latest": "3.0.0" }, + "versions": ["1.0.0", "2.0.0", "3.0.0"], + "time": { + "1.0.0": "2024-01-01T00:00:00.000Z", + "2.0.0": "2024-02-01T00:00:00.000Z", + "3.0.0": "2024-03-01T00:00:00.000Z" + } + }"#, + )?; + + assert_eq!( + select_npm_package_version("test-package", info, None)?, + Version::parse("3.0.0")? + ); + Ok(()) + } + + #[test] + fn test_select_npm_package_version_uses_latest_before_npm_before_config() -> Result<()> { + let info: NpmInfo = serde_json::from_str( + r#"{ + "dist-tags": { "latest": "3.0.0" }, + "versions": ["1.0.0", "2.0.0", "3.0.0"], + "time": { + "1.0.0": "2024-01-01T00:00:00.000Z", + "2.0.0": "2024-02-01T00:00:00.000Z", + "3.0.0": "2024-03-01T00:00:00.000Z" + } + }"#, + )?; + + assert_eq!( + select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?, + Version::parse("2.0.0")? + ); + Ok(()) + } + + #[test] + fn test_select_npm_package_version_keeps_allowed_latest_dist_tag() -> Result<()> { + let info: NpmInfo = serde_json::from_str( + r#"{ + "dist-tags": { "latest": "2.0.0" }, + "versions": ["1.0.0", "2.0.0", "3.0.0"], + "time": { + "1.0.0": "2024-01-01T00:00:00.000Z", + "2.0.0": "2024-02-01T00:00:00.000Z", + "3.0.0": "2024-03-01T00:00:00.000Z" + } + }"#, + )?; + + assert_eq!( + select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?, + Version::parse("2.0.0")? + ); + Ok(()) + } + + #[test] + fn test_select_npm_package_version_keeps_allowed_prerelease_latest_dist_tag() -> Result<()> { + let info: NpmInfo = serde_json::from_str( + r#"{ + "dist-tags": { "latest": "2.0.0-beta.1" }, + "versions": ["1.0.0", "2.0.0-beta.1"], + "time": { + "1.0.0": "2024-01-01T00:00:00.000Z", + "2.0.0-beta.1": "2024-02-01T00:00:00.000Z" + } + }"#, + )?; + + assert_eq!( + select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?, + Version::parse("2.0.0-beta.1")? + ); + Ok(()) + } + + #[test] + fn test_select_npm_package_version_ignores_prereleases_before_cutoff() -> Result<()> { + let info: NpmInfo = serde_json::from_str( + r#"{ + "dist-tags": { "latest": "2.0.0" }, + "versions": ["1.0.0", "2.0.0-beta.1", "2.0.0"], + "time": { + "1.0.0": "2024-01-01T00:00:00.000Z", + "2.0.0-beta.1": "2024-02-01T00:00:00.000Z", + "2.0.0": "2024-03-01T00:00:00.000Z" + } + }"#, + )?; + + assert_eq!( + select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?, + Version::parse("1.0.0")? + ); + Ok(()) + } + + #[test] + fn test_select_npm_package_version_ignores_versions_above_latest_dist_tag() -> Result<()> { + let info: NpmInfo = serde_json::from_str( + r#"{ + "dist-tags": { "latest": "2.0.0" }, + "versions": ["1.0.0", "2.0.0", "3.0.0"], + "time": { + "1.0.0": "2024-01-01T00:00:00.000Z", + "2.0.0": "2024-03-01T00:00:00.000Z", + "3.0.0": "2024-02-01T00:00:00.000Z" + } + }"#, + )?; + + assert_eq!( + select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?, + Version::parse("1.0.0")? + ); + Ok(()) + } + + #[test] + fn test_select_npm_package_version_errors_when_no_version_matches_before() -> Result<()> { + let info: NpmInfo = serde_json::from_str( + r#"{ + "dist-tags": { "latest": "2.0.0" }, + "versions": ["1.0.0", "2.0.0"], + "time": { + "1.0.0": "2024-01-01T00:00:00.000Z", + "2.0.0": "2024-02-01T00:00:00.000Z" + } + }"#, + )?; + + let Err(error) = + select_npm_package_version("test-package", info, Some("2023-12-01T00:00:00.000Z")) + else { + bail!("expected cutoff to reject all package versions"); + }; + assert_eq!( + error.to_string(), + "no version found for npm package test-package before 2023-12-01T00:00:00.000Z" + ); + Ok(()) + } } diff --git a/crates/project/src/debugger/session.rs b/crates/project/src/debugger/session.rs index 39578eaf8f0..fc5f56395cc 100644 --- a/crates/project/src/debugger/session.rs +++ b/crates/project/src/debugger/session.rs @@ -3145,7 +3145,7 @@ async fn get_or_install_companion(node: NodeRuntime, cx: &mut AsyncApp) -> Resul async fn install_latest_version(dir: PathBuf, node: NodeRuntime) -> Result { let temp_dir = tempfile::tempdir().context("creating temporary directory")?; - node.npm_install_packages(temp_dir.path(), &[(PACKAGE_NAME, "latest")]) + node.npm_install_latest_packages(temp_dir.path(), &[PACKAGE_NAME]) .await .context("installing latest companion package")?; let version = node diff --git a/crates/project/src/prettier_store.rs b/crates/project/src/prettier_store.rs index faa2cca7986..8d9399dce64 100644 --- a/crates/project/src/prettier_store.rs +++ b/crates/project/src/prettier_store.rs @@ -930,23 +930,11 @@ async fn install_prettier_packages( plugins_to_install: HashSet>, node: NodeRuntime, ) -> anyhow::Result<()> { - let packages_to_versions = future::try_join_all( - plugins_to_install - .iter() - .chain(Some(&"prettier".into())) - .map(|package_name| async { - let returned_package_name = package_name.to_string(); - let latest_version = node - .npm_package_latest_version(package_name) - .await - .with_context(|| { - format!("fetching latest npm version for package {returned_package_name}") - })?; - anyhow::Ok((returned_package_name, latest_version.to_string())) - }), - ) - .await - .context("fetching latest npm versions")?; + let packages_to_install = plugins_to_install + .iter() + .map(|package_name| package_name.to_string()) + .chain(Some("prettier".to_string())) + .collect::>(); let default_prettier_dir = default_prettier_dir().as_path(); match fs.metadata(default_prettier_dir).await.with_context(|| { @@ -962,12 +950,12 @@ async fn install_prettier_packages( .with_context(|| format!("creating default prettier dir {default_prettier_dir:?}"))?, } - log::info!("Installing default prettier and plugins: {packages_to_versions:?}"); - let borrowed_packages = packages_to_versions + log::info!("Installing default prettier and plugins: {packages_to_install:?}"); + let borrowed_packages = packages_to_install .iter() - .map(|(package, version)| (package.as_str(), version.as_str())) + .map(|package_name| package_name.as_str()) .collect::>(); - node.npm_install_packages(default_prettier_dir, &borrowed_packages) + node.npm_install_latest_packages(default_prettier_dir, &borrowed_packages) .await .context("fetching formatter packages")?; anyhow::Ok(()) From 9abb73ee326118a7bf8afcd524acbb660e97bd50 Mon Sep 17 00:00:00 2001 From: Gepcel Date: Tue, 19 May 2026 06:32:04 +0800 Subject: [PATCH 029/289] xtask: Fix `setup-webrtc` config overwrite refusal and Windows path format (#57058) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] No unsafe blocks - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #57057 Release Notes: - N/A --------- Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> --- tooling/xtask/src/tasks/setup_webrtc.rs | 52 ++++++++++++++++--------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/tooling/xtask/src/tasks/setup_webrtc.rs b/tooling/xtask/src/tasks/setup_webrtc.rs index 756a3767838..5dbf5bcaa96 100644 --- a/tooling/xtask/src/tasks/setup_webrtc.rs +++ b/tooling/xtask/src/tasks/setup_webrtc.rs @@ -219,31 +219,47 @@ fn update_cargo_config(webrtc_path: &Path) -> Result<()> { .or_else(|| std::env::var_os("USERPROFILE")) .context("could not determine home directory")?; let config_path = PathBuf::from(home).join(".cargo").join("config.toml"); - if config_path.exists() { - bail!( - "{} already exists; refusing to modify it. \ - Add `[env]\\n{ENV_VAR} = \"{}\"` yourself, \ - or re-run with --no-cargo-config.", - config_path.display(), - webrtc_path.display(), - ); - } if let Some(parent) = config_path.parent() { fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; } - let mut doc = DocumentMut::new(); - let mut env_table = Table::new(); - env_table.set_implicit(false); - let path_str = webrtc_path - .to_str() - .context("webrtc path is not valid UTF-8")?; - env_table.insert(ENV_VAR, value(path_str)); - doc.insert("env", Item::Table(env_table)); + let existing_content = if config_path.exists() { + fs::read_to_string(&config_path) + .with_context(|| format!("reading {}", config_path.display()))? + } else { + String::new() + }; + + let mut doc = existing_content + .parse::() + .with_context(|| format!("parsing existing {}", config_path.display()))?; + + let env_table = doc + .entry("env") + .or_insert(Item::Table(Table::new())) + .as_table_mut() + .context("`env` entry is not a table")?; + + let cleaned_path = clean_webrtc_path(webrtc_path)?; + env_table.insert(ENV_VAR, value(cleaned_path.clone())); fs::write(&config_path, doc.to_string()) .with_context(|| format!("writing {}", config_path.display()))?; - eprintln!("Wrote {} with {ENV_VAR}={path_str}", config_path.display()); + + eprintln!( + "Updated {} with {ENV_VAR}={cleaned_path}", + config_path.display() + ); Ok(()) } + +fn clean_webrtc_path(path: &Path) -> Result { + let path_str = path.to_str().context("webrtc path is not valid UTF-8")?; + let mut cleaned = path_str.to_string(); + if cleaned.starts_with(r"\\?\") { + cleaned = cleaned[4..].to_string(); + } + cleaned = cleaned.replace('\\', "/"); + Ok(cleaned) +} From 980a2942929f60812ba4e6e0e2855e1ebe96c468 Mon Sep 17 00:00:00 2001 From: Higor Prado Date: Mon, 18 May 2026 20:57:51 -0300 Subject: [PATCH 030/289] gpui: Prefer Mailbox present mode on Wayland to avoid FIFO stalls (#57077) The WgpuRenderer defaults to VK_PRESENT_MODE_FIFO_KHR (vsync), which blocks vkQueuePresentKHR until the compositor releases a buffer via wl_surface.frame. On some Wayland compositor+driver combinations (notably NVIDIA proprietary + Hyprland, but also observed on KDE/GNOME + AMD RADV), these frame callbacks can be delayed or lost, stalling the entire calloop event loop for tens of seconds. VK_PRESENT_MODE_MAILBOX_KHR does not block on vblank: it replaces the pending frame in a single-entry queue. This avoids the stall entirely. The renderer already falls back to Fifo automatically if Mailbox is unsupported by the driver. The WgpuSurfaceConfig has had a preferred_present_mode field since #50815 (added for Android lifecycle transitions with the same rationale). This commit sets it to Mailbox in the Wayland window creation path only. X11 is not affected. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Note on tests: This change is in the Wayland platform's window creation path (WaylandWindowState::new). The surface configuration is delegated to WgpuRenderer which already has test coverage for preferred_present_mode fallback logic. A full integration test would require a running Wayland compositor in CI. Verified manually and tested against the renderer's unwrap_or(Fifo) safety net by inspecting surface_caps.present_modes on both NVIDIA proprietary and Mesa RADV drivers. Closes: #50229 Closes: #55345 Closes: #39097 Closes: #50734 Refs: #38497, #52009, #52403, #50574, #49961, #47750, #46203, #50195, #50283, #42164, #39156, #39234, #35948, #32618 Release Notes: - Fixed UI freezes on Linux (Wayland) when on certain GPU/driver combinations --------- Co-authored-by: Neel --- crates/gpui_linux/src/linux/wayland/window.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index 37d0f492d25..73a2bda279f 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -37,7 +37,7 @@ use gpui::{ WindowDecorations, WindowKind, WindowParams, layer_shell::LayerShellNotSupportedError, px, size, }; -use gpui_wgpu::{CompositorGpuHint, WgpuRenderer, WgpuSurfaceConfig}; +use gpui_wgpu::{CompositorGpuHint, WgpuRenderer, WgpuSurfaceConfig, wgpu}; #[derive(Default)] pub(crate) struct Callbacks { @@ -346,7 +346,8 @@ impl WaylandWindowState { height: DevicePixels(f32::from(options.bounds.size.height) as i32), }, transparent: true, - preferred_present_mode: None, + // Prefer Mailbox to avoid blocking. Falls back to FIFO if Mailbox is unsupported. + preferred_present_mode: Some(wgpu::PresentMode::Mailbox), }; WgpuRenderer::new(gpu_context, &raw_window, config, compositor_gpu)? }; From 8ca194d833a4d2e9a3f3c43f84e806a36c3839c4 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 18 May 2026 21:16:04 -0300 Subject: [PATCH 031/289] Add built-in `create-skill` skill (#57064) Closes AI-266 This PR adds a built-in skill called `create-skill`, which allows the Zed agent to have access to a skill that teaches it how to properly create skills for Zed. You can manually invoke it as well as just letting the model auto-invoke it in case your prompt suggests creating a new skill. Release Notes: - Agent: Added a built-in skill called `create-skill` to make the Zed agent informed about how to do that. --------- Co-authored-by: Richard Feldman --- crates/acp_thread/src/mention.rs | 1 + crates/agent/src/agent.rs | 219 +++++++++++++----- crates/agent/src/thread.rs | 6 +- crates/agent/src/tools/skill_tool.rs | 51 ++-- crates/agent_skills/agent_skills.rs | 118 +++++++++- .../builtin/create-skill/SKILL.md | 95 ++++++++ crates/agent_ui/src/mention_set.rs | 8 + crates/agent_ui/src/ui/mention_crease.rs | 37 +++ crates/prompt_store/src/prompts.rs | 1 + 9 files changed, 449 insertions(+), 87 deletions(-) create mode 100644 crates/agent_skills/builtin/create-skill/SKILL.md diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index 67c1ddb9416..12827acc833 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -359,6 +359,7 @@ impl MentionUri { match self { MentionUri::Skill { name, source, .. } => { if source.is_empty() { + // Must match `SkillSource::display_label()` in agent_skills. format!("{} (global)", name) } else { format!("{} ({})", name, source) diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index eda50ab5637..ffe24590169 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -32,7 +32,7 @@ use acp_thread::{ use agent_client_protocol::schema as acp; use agent_skills::{ MAX_SKILL_DESCRIPTIONS_SIZE, Skill, SkillLoadError, SkillScopeId, SkillSource, SkillSummary, - global_skills_dir, load_skills_from_directory, project_skills_relative_path, + builtin_skills, global_skills_dir, load_skills_from_directory, project_skills_relative_path, }; use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Utc}; @@ -104,7 +104,7 @@ impl From<&Skill> for NativeAvailableSkill { Self { name: skill.name.clone(), description: skill.description.clone(), - source: skill.source.scope_prefix().to_string().into(), + source: skill.source.display_label().to_string().into(), skill_file_path: skill.skill_file_path.clone(), } } @@ -1644,14 +1644,18 @@ impl NativeAgent { // Read the body on demand here β€” bodies live on disk between // materializations to keep memory cost O(total frontmatter) // rather than O(total file size). - let body = agent_skills::read_skill_body(fs.as_ref(), &skill.skill_file_path) - .await - .with_context(|| { - format!( - "Failed to read skill body from {}", - skill.skill_file_path.display() - ) - })?; + let body = if let Some(embedded) = skill.embedded_body { + embedded.to_string() + } else { + agent_skills::read_skill_body(fs.as_ref(), &skill.skill_file_path) + .await + .with_context(|| { + format!( + "Failed to read skill body from {}", + skill.skill_file_path.display() + ) + })? + }; let envelope = crate::tools::render_skill_envelope(&skill, &body); let envelope_block = acp::ContentBlock::Text(acp::TextContent::new(envelope)); @@ -2245,9 +2249,12 @@ impl acp_thread::AgentConnection for NativeAgentConnection { // we don't clone the entire skill list on every prompt // (including prompts like `/help` that aren't skills at // all). The resolution rule matches the override-applied - // view: prefer a project-local with the matching name, - // falling back to a global, so the slash command picks the - // same entry the model sees in its catalog. + // view: among skills with the matching name, pick the one + // with the highest source precedence, so the slash command + // picks the same entry the model sees in its catalog. + // Ties (e.g. two project-local skills from different + // worktrees) resolve to the first in iteration order to + // match `apply_skill_overrides`. if parsed_command.explicit_server_id.is_none() && parsed_command.skill_scope.is_none() && !project_state.skills.is_empty() @@ -2256,15 +2263,13 @@ impl acp_thread::AgentConnection for NativeAgentConnection { let resolved = project_state .skills .iter() - .find(|skill| { - skill.name == prompt_name - && matches!(skill.source, SkillSource::ProjectLocal { .. }) - }) - .or_else(|| { - project_state - .skills - .iter() - .find(|skill| skill.name == prompt_name) + .filter(|skill| skill.name == prompt_name) + .reduce(|best, candidate| { + if candidate.source.precedence() > best.source.precedence() { + candidate + } else { + best + } }); if let Some(skill) = resolved { let skill = skill.clone(); @@ -2960,7 +2965,9 @@ fn combine_skills( global: Vec>, project: impl Iterator>, ) -> (Vec, Vec) { - let mut skills = Vec::new(); + // Built-in skills go first (lowest priority) so that global and + // project-local skills with the same name shadow them. + let mut skills = builtin_skills(); let mut errors = Vec::new(); for result in global.into_iter().chain(project) { match result { @@ -2979,17 +2986,16 @@ fn log_skill_conflicts(skills: &[Skill]) { let mut by_name: HashMap<&str, &Skill> = HashMap::default(); for skill in skills { match by_name.get(skill.name.as_str()) { - Some(existing) => match (&existing.source, &skill.source) { - (SkillSource::Global, SkillSource::ProjectLocal { .. }) => { + Some(existing) => { + if skill.source.precedence() > existing.source.precedence() { log::warn!( - "Project skill '{}' at '{}' overrides global skill at '{}' for the model; both appear in the slash-command popup with their source", + "Skill '{}' at '{}' overrides skill at '{}' for the model; both appear in the slash-command popup with their source", skill.name, skill.skill_file_path.display(), existing.skill_file_path.display(), ); by_name.insert(skill.name.as_str(), skill); - } - _ => { + } else { log::warn!( "Skill '{}' at '{}' conflicts with skill at '{}'; the model will see the first one, but both appear in the slash-command popup with their source", skill.name, @@ -2997,7 +3003,7 @@ fn log_skill_conflicts(skills: &[Skill]) { existing.skill_file_path.display(), ); } - }, + } None => { by_name.insert(skill.name.as_str(), skill); } @@ -3024,9 +3030,7 @@ fn apply_skill_overrides(skills: &[Skill]) -> Vec { for skill in skills { match indices.get(skill.name.as_str()).copied() { Some(idx) => { - if matches!(result[idx].source, SkillSource::Global) - && matches!(skill.source, SkillSource::ProjectLocal { .. }) - { + if skill.source.precedence() > result[idx].source.precedence() { result[idx] = skill.clone(); } } @@ -3064,6 +3068,7 @@ mod internal_tests { directory_path: PathBuf::from(format!("/home/user/.agents/skills/{name}")), skill_file_path: PathBuf::from(format!("/home/user/.agents/skills/{name}/SKILL.md")), disable_model_invocation: false, + embedded_body: None, } } @@ -3078,9 +3083,30 @@ mod internal_tests { directory_path: PathBuf::from(format!("/{worktree}/.agents/skills/{name}")), skill_file_path: PathBuf::from(format!("/{worktree}/.agents/skills/{name}/SKILL.md")), disable_model_invocation: false, + embedded_body: None, } } + fn make_builtin_skill(name: &str, description: &str) -> Skill { + Skill { + name: name.to_string(), + description: description.to_string(), + source: SkillSource::BuiltIn, + directory_path: PathBuf::from(format!("/builtin/{name}")), + skill_file_path: PathBuf::from(format!("/builtin/{name}/SKILL.md")), + disable_model_invocation: false, + embedded_body: Some("built-in body"), + } + } + + /// Filter to only user-defined (non-built-in) skills for test assertions. + fn user_skills(skills: &[Skill]) -> Vec<&Skill> { + skills + .iter() + .filter(|s| !matches!(s.source, SkillSource::BuiltIn)) + .collect() + } + #[test] fn test_combine_skills_keeps_every_entry_for_autocomplete() { // The autocomplete popup needs both same-named entries so the @@ -3092,9 +3118,10 @@ mod internal_tests { let (skills, errors) = combine_skills(vec![Ok(global)], vec![Ok(project)].into_iter()); assert!(errors.is_empty()); - assert_eq!(skills.len(), 2); - assert!(matches!(skills[0].source, SkillSource::Global)); - assert!(matches!(skills[1].source, SkillSource::ProjectLocal { .. })); + let user = user_skills(&skills); + assert_eq!(user.len(), 2); + assert!(matches!(user[0].source, SkillSource::Global)); + assert!(matches!(user[1].source, SkillSource::ProjectLocal { .. })); } #[test] @@ -3130,6 +3157,51 @@ mod internal_tests { assert_eq!(resolved[0].description, "First"); } + #[test] + fn test_apply_skill_overrides_global_wins_over_builtin() { + // A global skill with the same name as a built-in must shadow + // the built-in in the model-facing projection, regardless of + // iteration order. + let built_in = make_builtin_skill("create-skill", "Built-in version"); + let global = make_global_skill("create-skill", "User override"); + + let resolved = apply_skill_overrides(&[built_in, global]); + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].description, "User override"); + assert!(matches!(resolved[0].source, SkillSource::Global)); + } + + #[test] + fn test_apply_skill_overrides_project_wins_over_builtin() { + let built_in = make_builtin_skill("create-skill", "Built-in version"); + let project = make_project_skill("create-skill", "Project override", "my-project"); + + let resolved = apply_skill_overrides(&[built_in, project]); + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].description, "Project override"); + assert!(matches!( + resolved[0].source, + SkillSource::ProjectLocal { .. } + )); + } + + #[test] + fn test_apply_skill_overrides_project_wins_over_builtin_and_global() { + // All three sources present β€” the project-local must win and + // both lower-precedence entries must be dropped from the + // model-facing projection. + let built_in = make_builtin_skill("create-skill", "Built-in"); + let global = make_global_skill("create-skill", "Global"); + let project = make_project_skill("create-skill", "Project", "my-project"); + + let resolved = apply_skill_overrides(&[built_in, global, project]); + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].description, "Project"); + } + #[test] fn test_apply_skill_overrides_preserves_unique_skills() { let global_a = make_global_skill("alpha", "a"); @@ -3201,6 +3273,7 @@ mod internal_tests { directory_path: PathBuf::from(format!("/skills/{name}")), skill_file_path: PathBuf::from(format!("/skills/{name}/SKILL.md")), disable_model_invocation: false, + embedded_body: None, }); } @@ -3275,6 +3348,7 @@ mod internal_tests { directory_path: PathBuf::from("/skills/skill-01-first"), skill_file_path: PathBuf::from("/skills/skill-01-first/SKILL.md"), disable_model_invocation: false, + embedded_body: None, }; let second = Skill { name: "skill-02-overflows".to_string(), @@ -3283,6 +3357,7 @@ mod internal_tests { directory_path: PathBuf::from("/skills/skill-02-overflows"), skill_file_path: PathBuf::from("/skills/skill-02-overflows/SKILL.md"), disable_model_invocation: false, + embedded_body: None, }; let third = Skill { name: "skill-03-would-fit".to_string(), @@ -3291,6 +3366,7 @@ mod internal_tests { directory_path: PathBuf::from("/skills/skill-03-would-fit"), skill_file_path: PathBuf::from("/skills/skill-03-would-fit/SKILL.md"), disable_model_invocation: false, + embedded_body: None, }; // Sanity-check the test setup: the third skill is small enough @@ -3346,6 +3422,7 @@ mod internal_tests { directory_path: PathBuf::from("/skills/hidden-huge"), skill_file_path: PathBuf::from("/skills/hidden-huge/SKILL.md"), disable_model_invocation: true, + embedded_body: None, }; let visible = Skill { name: "visible".to_string(), @@ -3354,6 +3431,7 @@ mod internal_tests { directory_path: PathBuf::from("/skills/visible"), skill_file_path: PathBuf::from("/skills/visible/SKILL.md"), disable_model_invocation: false, + embedded_body: None, }; let (kept, errors) = select_catalog_skills(&[hidden, visible]); @@ -3496,9 +3574,10 @@ mod internal_tests { // The pre-existing skill should be loaded into the project state. agent.read_with(cx, |agent, _cx| { let state = agent.projects.get(&project.entity_id()).unwrap(); - assert_eq!(state.skills.len(), 1); - assert_eq!(state.skills[0].name, "my-skill"); - assert_eq!(state.skills[0].description, "First version"); + let user = user_skills(&state.skills); + assert_eq!(user.len(), 1); + assert_eq!(user[0].name, "my-skill"); + assert_eq!(user[0].description, "First version"); }); // Modify the SKILL.md and verify the project context refreshes. @@ -3512,8 +3591,9 @@ mod internal_tests { agent.read_with(cx, |agent, _cx| { let state = agent.projects.get(&project.entity_id()).unwrap(); - assert_eq!(state.skills.len(), 1); - assert_eq!(state.skills[0].description, "Second version"); + let user = user_skills(&state.skills); + assert_eq!(user.len(), 1); + assert_eq!(user[0].description, "Second version"); }); } @@ -3559,8 +3639,8 @@ mod internal_tests { agent.read_with(cx, |agent, _cx| { let state = agent.projects.get(&project.entity_id()).unwrap(); assert!( - state.skills.is_empty(), - "expected no skills before the global skills dir exists, got {:?}", + user_skills(&state.skills).is_empty(), + "expected no user skills before the global skills dir exists, got {:?}", state.skills ); }); @@ -3585,9 +3665,10 @@ mod internal_tests { agent.read_with(cx, |agent, _cx| { let state = agent.projects.get(&project.entity_id()).unwrap(); - assert_eq!(state.skills.len(), 1); - assert_eq!(state.skills[0].name, "late-skill"); - assert_eq!(state.skills[0].description, "Created after startup"); + let user = user_skills(&state.skills); + assert_eq!(user.len(), 1); + assert_eq!(user[0].name, "late-skill"); + assert_eq!(user[0].description, "Created after startup"); }); } @@ -3638,8 +3719,8 @@ mod internal_tests { agent.read_with(cx, |agent, _cx| { let state = agent.projects.get(&project_id).unwrap(); assert!( - state.skills.is_empty(), - "expected no skills before the global skills dir exists, got {:?}", + user_skills(&state.skills).is_empty(), + "expected no user skills before the global skills dir exists, got {:?}", state.skills ); }); @@ -3656,7 +3737,12 @@ mod internal_tests { // empty list β€” NOT the snapshot that `Thread::new` would have // captured. cx.update(|cx| { - assert!(resolve(cx).is_empty()); + let all = resolve(cx); + let user: Vec<_> = all + .iter() + .filter(|s| !matches!(s.source, SkillSource::BuiltIn)) + .collect(); + assert!(user.is_empty()); }); // Now create a SKILL.md AFTER the session was registered. With @@ -3681,15 +3767,20 @@ mod internal_tests { // `state.skills` reflects the new skill (the watcher ran). agent.read_with(cx, |agent, _cx| { let state = agent.projects.get(&project_id).unwrap(); - assert_eq!(state.skills.len(), 1); - assert_eq!(state.skills[0].name, "my-skill"); + let user = user_skills(&state.skills); + assert_eq!(user.len(), 1); + assert_eq!(user[0].name, "my-skill"); }); // The resolver the `SkillTool` uses must see it too. This is the // crux of the regression test: the tool's view of skills is // resolved at invocation time, not at thread-construction time. cx.update(|cx| { - let snapshot = resolve(cx); + let all = resolve(cx); + let snapshot: Vec<_> = all + .iter() + .filter(|s| !matches!(s.source, SkillSource::BuiltIn)) + .collect(); assert_eq!( snapshot.len(), 1, @@ -3777,7 +3868,11 @@ mod internal_tests { let parent_resolve = cx.update(|_cx| super::skills_resolver_for_project(agent.downgrade(), project_id)); cx.update(|cx| { - let parent_skills = parent_resolve(cx); + let all = parent_resolve(cx); + let parent_skills: Vec<_> = all + .iter() + .filter(|s| !matches!(s.source, SkillSource::BuiltIn)) + .collect(); assert_eq!(parent_skills.len(), 1); assert_eq!(parent_skills[0].name, "shared-skill"); }); @@ -3823,7 +3918,11 @@ mod internal_tests { let subagent_resolve = cx .update(|_cx| super::skills_resolver_for_project(agent.downgrade(), parent_project_id)); cx.update(|cx| { - let subagent_skills = subagent_resolve(cx); + let all = subagent_resolve(cx); + let subagent_skills: Vec<_> = all + .iter() + .filter(|s| !matches!(s.source, SkillSource::BuiltIn)) + .collect(); assert_eq!(subagent_skills.len(), 1); assert_eq!(subagent_skills[0].name, "shared-skill"); }); @@ -3919,7 +4018,14 @@ mod internal_tests { .iter() .map(|s| s.name.as_str()) .collect(); - assert_eq!(catalog, vec!["visible-skill"]); + assert!( + catalog.contains(&"visible-skill"), + "visible skill missing from catalog: {catalog:?}" + ); + assert!( + !catalog.contains(&"deploy"), + "deploy should be excluded from catalog: {catalog:?}" + ); }); } @@ -3986,7 +4092,7 @@ mod internal_tests { agent.read_with(cx, |agent, cx| { let state = agent.projects.get(&project_id).unwrap(); assert!( - state.skills.is_empty(), + user_skills(&state.skills).is_empty(), "untrusted worktree skills should not load: {:?}", state .skills @@ -4019,7 +4125,8 @@ mod internal_tests { agent.read_with(cx, |agent, _cx| { let state = agent.projects.get(&project_id).unwrap(); - let names: Vec<&str> = state.skills.iter().map(|s| s.name.as_str()).collect(); + let user = user_skills(&state.skills); + let names: Vec<&str> = user.iter().map(|s| s.name.as_str()).collect(); assert_eq!(names, vec!["my-skill"]); }); diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 2fe5bc99303..ae5c5510764 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -364,11 +364,7 @@ impl UserMessage { .ok(); } MentionUri::Skill { name, source, .. } => { - let label = if source.is_empty() { - format!("{} (global)", name) - } else { - format!("{} ({})", name, source) - }; + let label = format!("{} ({})", name, source); write!(&mut skills_context, "\nSkill: {}\n{}\n", label, content).ok(); } } diff --git a/crates/agent/src/tools/skill_tool.rs b/crates/agent/src/tools/skill_tool.rs index d45633da505..978a24f6968 100644 --- a/crates/agent/src/tools/skill_tool.rs +++ b/crates/agent/src/tools/skill_tool.rs @@ -46,11 +46,12 @@ fn neutralize_envelope_tags(input: &str) -> String { /// frontmatter), not O(total file size). pub fn render_skill_envelope(skill: &Skill, body: &str) -> String { let source = match &skill.source { + agent_skills::SkillSource::BuiltIn => "built-in", agent_skills::SkillSource::Global => "global", agent_skills::SkillSource::ProjectLocal { .. } => "project-local", }; let worktree = match &skill.source { - agent_skills::SkillSource::Global => None, + agent_skills::SkillSource::BuiltIn | agent_skills::SkillSource::Global => None, agent_skills::SkillSource::ProjectLocal { worktree_root_name, .. } => Some(worktree_root_name.clone()), @@ -200,31 +201,33 @@ impl AgentTool for SkillTool { (skill.clone(), path_string) }; - // Read the body on demand. Bodies are not kept in memory - // between materializations β€” see `agent_skills::read_skill_body`. - let body = agent_skills::read_skill_body(self.fs.as_ref(), &skill.skill_file_path) - .await - .map_err(|e| SkillToolOutput::Error { - error: e.to_string(), - })?; + // For built-in skills the body is already in memory (compiled + // into the binary). For user skills, read on demand from disk. + let body = if let Some(embedded) = skill.embedded_body { + embedded.to_string() + } else { + agent_skills::read_skill_body(self.fs.as_ref(), &skill.skill_file_path) + .await + .map_err(|e| SkillToolOutput::Error { + error: e.to_string(), + })? + }; let rendered = render_skill_envelope(&skill, &body); - // Activations go through the standard tool-permission flow so - // they participate in the same Allow-Once / Always-Allow UX as - // every other built-in tool. The auth context value is the - // skill's absolute SKILL.md path so that "always allow this - // specific skill" is keyed to a specific file: editing the - // SKILL.md will change the path's content but not the path, - // so for content-change re-trust we'd want a hash too β€” but - // at minimum, two skills with the same name from different - // locations get independent trust grants. - let authorize = cx.update(|cx| { - let context = crate::ToolPermissionContext::new(Self::NAME, vec![skill_file_path]); - event_stream.authorize(self.initial_title(Ok(input), cx), context, cx) - }); - authorize.await.map_err(|e| SkillToolOutput::Error { - error: e.to_string(), - })?; + // Built-in skills ship with Zed and are trusted by default, + // so they skip the authorization prompt. User-installed skills + // go through the standard Allow-Once / Always-Allow UX. + let is_builtin = skill.source == agent_skills::SkillSource::BuiltIn; + if !is_builtin { + let authorize = cx.update(|cx| { + let context = + crate::ToolPermissionContext::new(Self::NAME, vec![skill_file_path]); + event_stream.authorize(self.initial_title(Ok(input), cx), context, cx) + }); + authorize.await.map_err(|e| SkillToolOutput::Error { + error: e.to_string(), + })?; + } Ok(SkillToolOutput::Found { rendered }) }) diff --git a/crates/agent_skills/agent_skills.rs b/crates/agent_skills/agent_skills.rs index 63c506bbf64..8f185ff4ad9 100644 --- a/crates/agent_skills/agent_skills.rs +++ b/crates/agent_skills/agent_skills.rs @@ -64,11 +64,19 @@ pub struct Skill { /// `skill` tool refuses to load it. The user can still invoke it as a /// slash command. pub disable_model_invocation: bool, + /// For built-in skills whose content is compiled into the binary, + /// this holds the full SKILL.md body so the skill tool can serve it + /// without a filesystem read. + pub embedded_body: Option<&'static str>, } /// Indicates where a skill was loaded from. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SkillSource { + /// Compiled into the Zed binary. These are always available and have + /// the lowest override priority (global and project-local skills can + /// shadow them). + BuiltIn, /// From ~/.agents/skills/ Global, /// From {project}/.agents/skills/ @@ -79,6 +87,23 @@ pub enum SkillSource { } impl SkillSource { + /// Precedence for resolving same-named skills. Higher values shadow + /// lower ones: `ProjectLocal` > `Global` > `BuiltIn`. Two sources + /// returning equal precedence (e.g. two project-local skills from + /// different worktrees) leave the winner up to the caller, which by + /// convention keeps the first one in iteration order. + /// + /// Adding a new `SkillSource` variant should be a one-line change + /// here β€” every consumer routes through this method so the hierarchy + /// stays in sync. + pub fn precedence(&self) -> u8 { + match self { + Self::BuiltIn => 0, + Self::Global => 1, + Self::ProjectLocal { .. } => 2, + } + } + /// Scope prefix used in the `/:` slash-command /// syntax that the autocomplete popup inserts. Global skills use /// an empty prefix (so the inserted text is `/:`), and @@ -91,9 +116,21 @@ impl SkillSource { /// invoked as `/:`, and the worktree's skill is invoked as /// `/global:`. The two grammars never collide on the /// inserted text. + /// Human-readable label for this source, used in the UI to + /// distinguish skills from different origins. + pub fn display_label(&self) -> &str { + match self { + Self::BuiltIn => "built-in", + Self::Global => "global", + Self::ProjectLocal { + worktree_root_name, .. + } => worktree_root_name.as_ref(), + } + } + pub fn scope_prefix(&self) -> &str { match self { - Self::Global => "", + Self::BuiltIn | Self::Global => "", Self::ProjectLocal { worktree_root_name, .. } => worktree_root_name.as_ref(), @@ -112,7 +149,7 @@ impl SkillSource { /// strictness only affects users typing by memory. pub fn matches_scope(&self, scope: &str) -> bool { match self { - Self::Global => scope.is_empty(), + Self::BuiltIn | Self::Global => scope.is_empty(), Self::ProjectLocal { worktree_root_name, .. } => !scope.is_empty() && worktree_root_name.as_ref() == scope, @@ -211,6 +248,7 @@ pub fn parse_skill_frontmatter( directory_path, skill_file_path: skill_file_path.to_path_buf(), disable_model_invocation: metadata.disable_model_invocation, + embedded_body: None, }) } @@ -600,6 +638,53 @@ pub async fn read_skill_body( Ok(body.trim().to_string()) } +/// Content of the built-in `create-skill` SKILL.md, embedded at compile time. +const CREATE_SKILL_CONTENT: &str = include_str!("builtin/create-skill/SKILL.md"); + +/// Returns the set of skills that are compiled into the Zed binary. +pub fn builtin_skills() -> Vec { + let mut skills = Vec::new(); + if let Ok(skill) = parse_builtin_skill("create-skill", CREATE_SKILL_CONTENT) { + skills.push(skill); + } + skills +} + +/// Parse a built-in skill from its embedded SKILL.md content. The skill +/// gets a synthetic `` path since it doesn't live on disk. +fn parse_builtin_skill(name: &str, content: &'static str) -> Result { + let (metadata, body) = extract_frontmatter(content)?; + validate_name(&metadata.name)?; + validate_description(&metadata.description)?; + + let synthetic_dir = PathBuf::from(format!("/{}", name)); + let synthetic_path = synthetic_dir.join(SKILL_FILE_NAME); + + Ok(Skill { + name: metadata.name, + description: metadata.description, + source: SkillSource::BuiltIn, + directory_path: synthetic_dir, + skill_file_path: synthetic_path, + disable_model_invocation: metadata.disable_model_invocation, + embedded_body: Some(body.trim()), + }) +} + +/// All built-in skills as `(name, raw_content)` pairs. Used by +/// `builtin_skill_content` to serve the full SKILL.md without disk I/O. +const BUILTIN_SKILL_ENTRIES: &[(&str, &str)] = &[("create-skill", CREATE_SKILL_CONTENT)]; + +/// Look up the full embedded content of a built-in skill by its +/// synthetic file path. Returns `None` if the path doesn't match any +/// built-in skill. +pub fn builtin_skill_content(skill_file_path: &Path) -> Option<&'static str> { + BUILTIN_SKILL_ENTRIES.iter().find_map(|(name, content)| { + let expected = PathBuf::from(format!("/{}", name)).join(SKILL_FILE_NAME); + (expected == skill_file_path).then_some(*content) + }) +} + /// Returns the global skills directory: `~/.agents/skills`. /// /// Other agents (e.g. Claude Code) already write skill files into this @@ -663,6 +748,34 @@ mod tests { use fs::FakeFs; use gpui::TestAppContext; + #[test] + fn test_skill_source_precedence_is_total_and_ordered() { + // Pin the hierarchy: project-local > global > built-in. Every + // override and conflict-resolution site routes through this, + // so the rest of the codebase relies on it being correct. + let built_in = SkillSource::BuiltIn.precedence(); + let global = SkillSource::Global.precedence(); + let project = SkillSource::ProjectLocal { + worktree_id: SkillScopeId(1), + worktree_root_name: "my-project".into(), + } + .precedence(); + + assert!(built_in < global, "global must shadow built-in"); + assert!(global < project, "project-local must shadow global"); + + // Two project-local skills from different worktrees tie. The + // "first wins" convention is enforced by the callers, but the + // precedence itself must be equal so neither silently shadows + // the other. + let other_project = SkillSource::ProjectLocal { + worktree_id: SkillScopeId(2), + worktree_root_name: "other-project".into(), + } + .precedence(); + assert_eq!(project, other_project); + } + #[test] fn test_parse_valid_skill() { let content = r#"--- @@ -1532,6 +1645,7 @@ description: A skill with no body content directory_path: PathBuf::from("/skills/test-skill"), skill_file_path: PathBuf::from("/skills/test-skill/SKILL.md"), disable_model_invocation: false, + embedded_body: None, }; let summary = SkillSummary::from(&skill); diff --git a/crates/agent_skills/builtin/create-skill/SKILL.md b/crates/agent_skills/builtin/create-skill/SKILL.md new file mode 100644 index 00000000000..c5c76d80b8f --- /dev/null +++ b/crates/agent_skills/builtin/create-skill/SKILL.md @@ -0,0 +1,95 @@ +--- +name: create-skill +description: Helps users create new agent skills for Zed. Use this when a user wants to create a skill, asks about SKILL.md structure, or wants to package reusable agent instructions. +--- + +# Creating a Zed Agent Skill + +Use this skill when the user wants to create, edit, or understand agent skills in Zed. + +## What is a Skill? + +A skill is a reusable set of instructions that an agent can load on demand. Each skill lives in its own directory and is defined by a `SKILL.md` file with YAML frontmatter. + +## Where Skills Live + +Skills can be placed in two locations: + +| Scope | Path | When to use | +|-------|------|-------------| +| Global | `~/.agents/skills//SKILL.md` | Personal skills, available in all projects | +| Project-local | `/.agents/skills//SKILL.md` | Project-specific skills, shared with collaborators through version control | + +Prefer project-local when the skill is specific to a repository. Prefer global when the skill is a personal workflow the user wants everywhere. + +## SKILL.md Format + +Every `SKILL.md` must start with YAML frontmatter between `---` delimiters: + +```markdown +--- +name: my-skill-name +description: A clear, specific description of what this skill does and when to use it. +--- + +# Skill Title + +Instructions for the agent go here. Write them as if you're telling the agent +what to do when this skill is activated. +``` + +### Required Frontmatter Fields + +- **`name`** (required): Must be 1–64 characters, lowercase alphanumeric with single-hyphen separators. Must match the containing directory name exactly. Regex: `^[a-z0-9]+(-[a-z0-9]+)*$` +- **`description`** (required): Must be 1–1024 characters. This is what the agent sees when deciding whether to use the skill β€” make it specific and actionable. + +### Optional Frontmatter Fields + +- **`disable-model-invocation`**: When set to `true`, the skill is hidden from the agent's automatic catalog. The user can still invoke it manually via the `/` slash command menu. Useful for skills that should only run when explicitly requested. + +## Naming Rules + +The skill name must: +- Be lowercase letters and numbers only, with single hyphens as separators +- Not start or end with `-` +- Not contain consecutive `--` +- Match the directory name that contains the `SKILL.md` + +Good: `git-release`, `pr-review`, `rust-patterns` +Bad: `Git-Release`, `pr--review`, `-my-skill`, `my_skill` + +## Writing Good Skill Instructions + +The body of the SKILL.md (after the frontmatter) contains the instructions the agent will follow. Guidelines: + +1. **Be direct**: Write instructions as if talking to the agent. "Do X", "Check Y", "Ask the user about Z". +2. **Be specific**: Include concrete file paths, commands, formats, and patterns. +3. **Include when-to-use guidance**: Help the agent understand the right context for this skill. +4. **Reference supporting files**: Skills can include additional files in their directory. Reference them with relative paths (e.g., `templates/component.tsx`). The agent can read these files when the skill is activated. +5. **Keep descriptions actionable**: The `description` field is the agent's primary signal for whether to load this skill. "Helps with code" is too vague. "Generate React components following the project's design system patterns" is specific. + +## Supporting Files + +A skill directory can contain additional files beyond `SKILL.md`: + +``` +~/.agents/skills/react-component/ +β”œβ”€β”€ SKILL.md +β”œβ”€β”€ templates/ +β”‚ β”œβ”€β”€ component.tsx +β”‚ └── test.tsx +└── examples/ + └── button.tsx +``` + +Reference these in the skill body. The agent can read them using the file path shown in the `` tag of the skill envelope. + +## Step-by-Step: Creating a Skill + +1. Decide on scope (global vs project-local) based on the user's needs. +2. Choose a descriptive, hyphenated name. +3. Create the directory structure. +4. Write the `SKILL.md` with frontmatter and instructions. +5. Optionally add supporting files (templates, examples, references). + +After creating the skill, it will be automatically discovered by Zed's agent on the next conversation (no restart needed for global skills if the `~/.agents/skills/` directory already exists). diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 31bb31c046c..d1335d31811 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -491,6 +491,14 @@ impl MentionSet { skill_file_path: PathBuf, cx: &mut Context, ) -> Task> { + // Built-in skills have synthetic paths that don't exist on disk; + // serve their content directly from the compiled-in data. + if let Some(content) = agent_skills::builtin_skill_content(&skill_file_path) { + return Task::ready(Ok(Mention::Text { + content: content.to_string(), + tracked_buffers: Vec::new(), + })); + } cx.background_spawn(async move { let content = std::fs::read_to_string(&skill_file_path).map_err(|e| { anyhow!( diff --git a/crates/agent_ui/src/ui/mention_crease.rs b/crates/agent_ui/src/ui/mention_crease.rs index 4d4b282cf0b..f71ecedae21 100644 --- a/crates/agent_ui/src/ui/mention_crease.rs +++ b/crates/agent_ui/src/ui/mention_crease.rs @@ -203,6 +203,43 @@ fn open_skill_file( window: &mut Window, cx: &mut Context, ) { + // Built-in skills have synthetic paths that don't exist on disk. + // Open a read-only buffer with the embedded content instead. + if let Some(content) = agent_skills::builtin_skill_content(&skill_file_path) { + let project = workspace.project().clone(); + let languages = project.read(cx).languages().clone(); + let buffer = project.update(cx, |project, cx| { + project.create_local_buffer(content, None, false, cx) + }); + // Set markdown highlighting asynchronously β€” the buffer + // opens instantly and the highlighting appears once loaded. + cx.spawn({ + let buffer = buffer.clone(); + async move |_, cx| { + if let Ok(markdown) = languages.language_for_name("Markdown").await { + buffer.update(cx, |buffer, cx| buffer.set_language(Some(markdown), cx)); + } + } + }) + .detach(); + let editor = cx.new(|cx| { + let mut editor = Editor::for_buffer(buffer, None, window, cx); + editor.set_read_only(true); + let title = skill_file_path + .parent() + .and_then(|p| p.file_name()) + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "built-in skill".into()); + editor + .buffer() + .update(cx, |buffer, cx| buffer.set_title(title, cx)); + editor + }); + let pane = workspace.active_pane().clone(); + workspace.add_item(pane, Box::new(editor), None, true, true, window, cx); + return; + } + workspace .open_abs_path( skill_file_path, diff --git a/crates/prompt_store/src/prompts.rs b/crates/prompt_store/src/prompts.rs index b3194dd1d61..6417f49f85b 100644 --- a/crates/prompt_store/src/prompts.rs +++ b/crates/prompt_store/src/prompts.rs @@ -163,6 +163,7 @@ mod tests { directory_path: PathBuf::from("/skills/oversized"), skill_file_path: PathBuf::from("/skills/oversized/SKILL.md"), disable_model_invocation: false, + embedded_body: None, }; let summary = SkillSummary::from(&skill); From 14befe215158182be6b505b26bccf25538831213 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Tue, 19 May 2026 02:44:48 -0400 Subject: [PATCH 032/289] agent: Fix a panic when splitting streamed-in edits inside of a multibyte character (#57100) This PR fixes a panic that could occur in the `edit_file` tool where streaming in text could split in the middle of a multibyte character. Closes FR-3 and [ZED-7ZX](https://zed-dev.sentry.io/issues/7480598098). Release Notes: - Fixed a panic that could occur when streaming in text with the `edit_file` tool. --- .../tools/edit_session/streaming_parser.rs | 113 ++++++++++++++++-- 1 file changed, 106 insertions(+), 7 deletions(-) diff --git a/crates/agent/src/tools/edit_session/streaming_parser.rs b/crates/agent/src/tools/edit_session/streaming_parser.rs index 3961edf564c..71dbc2c9bba 100644 --- a/crates/agent/src/tools/edit_session/streaming_parser.rs +++ b/crates/agent/src/tools/edit_session/streaming_parser.rs @@ -113,7 +113,7 @@ impl StreamingParser { { if partial.new_text.is_some() && !state.buffer_new_text_until_old_text_done { // new_text appeared after old_text, so old_text is done β€” emit everything. - let start = state.old_text_emitted_len.min(old_text.len()); + let start = find_char_boundary(old_text, state.old_text_emitted_len); let chunk = normalize_done_chunk(old_text[start..].to_string()); state.old_text_done = true; state.old_text_emitted_len = old_text.len(); @@ -124,9 +124,10 @@ impl StreamingParser { }); } else { let safe_end = safe_emit_end_for_edit_text(old_text); + let safe_start = find_char_boundary(old_text, state.old_text_emitted_len); - if safe_end > state.old_text_emitted_len { - let chunk = old_text[state.old_text_emitted_len..safe_end].to_string(); + if safe_end > safe_start { + let chunk = old_text[safe_start..safe_end].to_string(); state.old_text_emitted_len = safe_end; events.push(EditEvent::OldTextChunk { edit_index: index, @@ -143,9 +144,10 @@ impl StreamingParser { && !state.new_text_done { let safe_end = safe_emit_end_for_edit_text(new_text); + let safe_start = find_char_boundary(new_text, state.new_text_emitted_len); - if safe_end > state.new_text_emitted_len { - let chunk = new_text[state.new_text_emitted_len..safe_end].to_string(); + if safe_end > safe_start { + let chunk = new_text[safe_start..safe_end].to_string(); state.new_text_emitted_len = safe_end; events.push(EditEvent::NewTextChunk { edit_index: index, @@ -343,8 +345,10 @@ impl StreamingParser { /// held back because it may be an artifact of the partial JSON fixer closing /// an incomplete escape sequence (e.g. turning a half-received `\n` into `\\`). /// The next partial will reveal the correct character. +/// +/// The returned position is always a valid UTF-8 character boundary. fn safe_emit_end(text: &str) -> usize { - if text.as_bytes().last() == Some(&b'\\') { + if text.ends_with('\\') { text.len() - 1 } else { text.len() @@ -353,13 +357,35 @@ fn safe_emit_end(text: &str) -> usize { fn safe_emit_end_for_edit_text(text: &str) -> usize { let safe_end = safe_emit_end(text); - if safe_end > 0 && text.as_bytes()[safe_end - 1] == b'\n' { + // Use string slicing to check the last character, ensuring we respect UTF-8 boundaries. + if safe_end > 0 && text[..safe_end].ends_with('\n') { safe_end - 1 } else { safe_end } } +/// Finds a valid UTF-8 character boundary at or before the target position. +/// +/// When streaming partial JSON, the text structure can change between updates +/// (e.g., an escape sequence being completed). This means a byte position that +/// was valid in one partial may land inside a multi-byte character in the next. +/// This function finds the nearest valid boundary at or before the target. +fn find_char_boundary(text: &str, target: usize) -> usize { + if target >= text.len() { + return text.len(); + } + if text.is_char_boundary(target) { + return target; + } + // Walk backwards to find a valid boundary. + let mut pos = target; + while pos > 0 && !text.is_char_boundary(pos) { + pos -= 1; + } + pos +} + fn normalize_done_chunk(mut chunk: String) -> String { if chunk.ends_with('\n') { chunk.pop(); @@ -1146,4 +1172,77 @@ mod tests { }] ); } + + #[test] + fn test_multibyte_char_with_trailing_backslash() { + // Reproduces a panic where the stored `old_text_emitted_len` from a previous + // partial lands inside a multi-byte UTF-8 character in the current partial. + // + // Scenario: The JSON fixer produces a literal backslash when the stream cuts + // mid-escape. If the *next* partial replaces that backslash with a multi-byte + // character (e.g., em-dash 'β€”'), the stored byte position is no longer valid. + let mut parser = StreamingParser::default(); + + // First partial: text ends with backslash (held back by safe_emit_end). + // "abc" = 3 bytes, backslash held back, so emitted_len = 3. + let events = parser.push_edits(&[PartialEdit { + old_text: Some("abc\\".into()), + new_text: None, + }]); + assert_eq!( + events.as_slice(), + &[EditEvent::OldTextChunk { + edit_index: 0, + chunk: "abc".into(), + done: false, + }] + ); + + // Second partial: the backslash is replaced by em-dash 'β€”' (3 bytes: E2 80 94). + // "abβ€”" = 2 + 3 = 5 bytes total, with em-dash at bytes 2..5. + // The stored emitted_len (3) is inside the em-dash! + // This should NOT panic. + let events = parser.push_edits(&[PartialEdit { + old_text: Some("abβ€”".into()), + new_text: None, + }]); + // The parser should handle this gracefully. + let _ = events; + } + + #[test] + fn test_emitted_len_inside_multibyte_char_boundary() { + // More direct reproduction: emitted_len points inside a multi-byte character. + // + // This can happen when: + // 1. First partial has text where byte N is a valid boundary + // 2. Second partial has *different* text where byte N is inside a multi-byte char + let mut parser = StreamingParser::default(); + + // First partial: "ab" (2 bytes), backslash held back. + // After processing: emitted_len = 2 + let events = parser.push_edits(&[PartialEdit { + old_text: Some("ab\\".into()), + new_text: None, + }]); + assert_eq!( + events.as_slice(), + &[EditEvent::OldTextChunk { + edit_index: 0, + chunk: "ab".into(), + done: false, + }] + ); + + // Second partial: "aβ€”" where em-dash starts at byte 1 and spans bytes 1-3. + // Stored emitted_len = 2, but byte 2 is inside the em-dash! + // This should NOT panic. + let events = parser.push_edits(&[PartialEdit { + old_text: Some("aβ€”".into()), + new_text: None, + }]); + // The parser should handle this gracefully. + // We don't care exactly what it emits, just that it doesn't panic. + let _ = events; + } } From b8dce970fa49fca372d1a86188715a5665dbe561 Mon Sep 17 00:00:00 2001 From: "zed-zippy[bot]" <234243425+zed-zippy[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 09:01:38 +0200 Subject: [PATCH 033/289] extension_ci: Bump extension CLI version to `2a00db0` (#57098) This PR bumps the extension CLI version used in the extension workflows to `2a00db06ce6d01089bfafd207b6348078e980df9`. Release Notes: - N/A Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> --- .github/workflows/extension_bump.yml | 2 +- .github/workflows/extension_tests.yml | 2 +- tooling/xtask/src/tasks/workflows/extension_tests.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/extension_bump.yml b/.github/workflows/extension_bump.yml index 4757db43437..11a3a709022 100644 --- a/.github/workflows/extension_bump.yml +++ b/.github/workflows/extension_bump.yml @@ -5,7 +5,7 @@ env: CARGO_TERM_COLOR: always RUST_BACKTRACE: '1' CARGO_INCREMENTAL: '0' - ZED_EXTENSION_CLI_SHA: 1fa7f1a3ec28ea1eae6db2e937d7a538fb10c0c7 + ZED_EXTENSION_CLI_SHA: 2a00db06ce6d01089bfafd207b6348078e980df9 on: workflow_call: inputs: diff --git a/.github/workflows/extension_tests.yml b/.github/workflows/extension_tests.yml index 4003f41c273..c3503590e60 100644 --- a/.github/workflows/extension_tests.yml +++ b/.github/workflows/extension_tests.yml @@ -5,7 +5,7 @@ env: CARGO_TERM_COLOR: always RUST_BACKTRACE: '1' CARGO_INCREMENTAL: '0' - ZED_EXTENSION_CLI_SHA: 1fa7f1a3ec28ea1eae6db2e937d7a538fb10c0c7 + ZED_EXTENSION_CLI_SHA: 2a00db06ce6d01089bfafd207b6348078e980df9 RUSTUP_TOOLCHAIN: stable CARGO_BUILD_TARGET: wasm32-wasip2 on: diff --git a/tooling/xtask/src/tasks/workflows/extension_tests.rs b/tooling/xtask/src/tasks/workflows/extension_tests.rs index 56aeb677eac..f93415f2077 100644 --- a/tooling/xtask/src/tasks/workflows/extension_tests.rs +++ b/tooling/xtask/src/tasks/workflows/extension_tests.rs @@ -14,7 +14,7 @@ use crate::tasks::workflows::{ vars::{PathCondition, StepOutput, WorkflowInput, one_workflow_per_non_main_branch_and_token}, }; -pub(crate) const ZED_EXTENSION_CLI_SHA: &str = "1fa7f1a3ec28ea1eae6db2e937d7a538fb10c0c7"; +pub(crate) const ZED_EXTENSION_CLI_SHA: &str = "2a00db06ce6d01089bfafd207b6348078e980df9"; // This should follow the set target in crates/extension/src/extension_builder.rs const EXTENSION_RUST_TARGET: &str = "wasm32-wasip2"; From 8708a6fa749e0be85d378377369c539e9da244a7 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Tue, 19 May 2026 11:51:42 +0200 Subject: [PATCH 034/289] agent: Do not decode images during render (#56866) Turns out we were creating an ImageDecoder on every frame (added in #46167) when a tool returned an image as output, because we were trying to get its dimensions. That is now cached on `ContentBlock::Image`. Release Notes: - N/A --- crates/acp_thread/Cargo.toml | 4 +- crates/acp_thread/src/acp_thread.rs | 49 ++++++++--- .../src/conversation_view/thread_view.rs | 85 +++++++++---------- 3 files changed, 79 insertions(+), 59 deletions(-) diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index 987db1dcf8e..9123c301079 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -13,7 +13,7 @@ path = "src/acp_thread.rs" doctest = false [features] -test-support = ["gpui/test-support", "project/test-support", "dep:parking_lot", "dep:image"] +test-support = ["gpui/test-support", "project/test-support", "dep:parking_lot"] [dependencies] action_log.workspace = true @@ -35,7 +35,7 @@ language_model.workspace = true log.workspace = true markdown.workspace = true parking_lot = { workspace = true, optional = true } -image = { workspace = true, optional = true } +image.workspace = true portable-pty.workspace = true project.workspace = true prompt_store.workspace = true diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index afd8aeda5f3..4e6be0fe6a1 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -648,9 +648,16 @@ impl Display for ToolCallStatus { #[derive(Debug, PartialEq, Clone)] pub enum ContentBlock { Empty, - Markdown { markdown: Entity }, - ResourceLink { resource_link: acp::ResourceLink }, - Image { image: Arc }, + Markdown { + markdown: Entity, + }, + ResourceLink { + resource_link: acp::ResourceLink, + }, + Image { + image: Arc, + dimensions: Option>, + }, } impl ContentBlock { @@ -692,8 +699,8 @@ impl ContentBlock { }; } (ContentBlock::Empty, acp::ContentBlock::Image(image_content)) => { - if let Some(image) = Self::decode_image(image_content) { - *self = ContentBlock::Image { image }; + if let Some((image, dimensions)) = Self::decode_image(image_content) { + *self = ContentBlock::Image { image, dimensions }; } else { let new_content = Self::image_md(image_content); *self = Self::create_markdown_block(new_content, language_registry, cx); @@ -721,14 +728,36 @@ impl ContentBlock { } } - fn decode_image(image_content: &acp::ImageContent) -> Option> { + fn decode_image( + image_content: &acp::ImageContent, + ) -> Option<(Arc, Option>)> { use base64::Engine as _; let bytes = base64::engine::general_purpose::STANDARD .decode(image_content.data.as_bytes()) .ok()?; let format = gpui::ImageFormat::from_mime_type(&image_content.mime_type)?; - Some(Arc::new(gpui::Image::from_bytes(format, bytes))) + let dimensions = Self::image_dimensions(&bytes, format); + Some((Arc::new(gpui::Image::from_bytes(format, bytes)), dimensions)) + } + + fn image_dimensions(bytes: &[u8], format: gpui::ImageFormat) -> Option> { + let format = match format { + gpui::ImageFormat::Png => image::ImageFormat::Png, + gpui::ImageFormat::Jpeg => image::ImageFormat::Jpeg, + gpui::ImageFormat::Webp => image::ImageFormat::WebP, + gpui::ImageFormat::Gif => image::ImageFormat::Gif, + gpui::ImageFormat::Svg => return None, + gpui::ImageFormat::Bmp => image::ImageFormat::Bmp, + gpui::ImageFormat::Tiff => image::ImageFormat::Tiff, + gpui::ImageFormat::Ico => image::ImageFormat::Ico, + gpui::ImageFormat::Pnm => image::ImageFormat::Pnm, + }; + + image::ImageReader::with_format(std::io::Cursor::new(bytes), format) + .into_dimensions() + .ok() + .map(|(width, height)| gpui::Size { width, height }) } fn create_markdown_block( @@ -808,9 +837,9 @@ impl ContentBlock { } } - pub fn image(&self) -> Option<&Arc> { + pub fn image(&self) -> Option<(&Arc, Option>)> { match self { - ContentBlock::Image { image } => Some(image), + ContentBlock::Image { image, dimensions } => Some((image, *dimensions)), _ => None, } } @@ -895,7 +924,7 @@ impl ToolCallContent { } } - pub fn image(&self) -> Option<&Arc> { + pub fn image(&self) -> Option<(&Arc, Option>)> { match self { Self::ContentBlock(content) => content.image(), _ => None, diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 9d78baf826c..6b63abd50ea 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -6446,7 +6446,6 @@ impl ThreadView { content_ix, tool_call, use_card_layout, - has_image_content, failed_or_canceled, focus_handle, window, @@ -6578,7 +6577,6 @@ impl ThreadView { content_ix, tool_call, use_card_layout, - has_image_content, failed_or_canceled, focus_handle, window, @@ -7570,7 +7568,6 @@ impl ThreadView { context_ix: usize, tool_call: &ToolCall, card_layout: bool, - is_image_tool_call: bool, has_failed: bool, focus_handle: &FocusHandle, window: &Window, @@ -7589,14 +7586,14 @@ impl ThreadView { window, cx, ) - } else if let Some(image) = content.image() { + } else if let Some((image, dimensions)) = content.image() { let location = tool_call.locations.first().cloned(); self.render_image_output( entry_ix, image.clone(), + dimensions, location, card_layout, - is_image_tool_call, cx, ) } else { @@ -7778,30 +7775,26 @@ impl ThreadView { &self, entry_ix: usize, image: Arc, + dimensions: Option>, location: Option, card_layout: bool, - show_dimensions: bool, cx: &Context, ) -> AnyElement { - let dimensions_label = if show_dimensions { - let format_name = match image.format() { - gpui::ImageFormat::Png => "PNG", - gpui::ImageFormat::Jpeg => "JPEG", - gpui::ImageFormat::Webp => "WebP", - gpui::ImageFormat::Gif => "GIF", - gpui::ImageFormat::Svg => "SVG", - gpui::ImageFormat::Bmp => "BMP", - gpui::ImageFormat::Tiff => "TIFF", - gpui::ImageFormat::Ico => "ICO", - gpui::ImageFormat::Pnm => "PNM", - }; - let dimensions = image::ImageReader::new(std::io::Cursor::new(image.bytes())) - .with_guessed_format() - .ok() - .and_then(|reader| reader.into_dimensions().ok()); - dimensions.map(|(w, h)| format!("{}Γ—{} {}", w, h, format_name)) + let format_name = match image.format() { + gpui::ImageFormat::Png => "PNG", + gpui::ImageFormat::Jpeg => "JPEG", + gpui::ImageFormat::Webp => "WebP", + gpui::ImageFormat::Gif => "GIF", + gpui::ImageFormat::Svg => "SVG", + gpui::ImageFormat::Bmp => "BMP", + gpui::ImageFormat::Tiff => "TIFF", + gpui::ImageFormat::Ico => "ICO", + gpui::ImageFormat::Pnm => "PNM", + }; + let dimensions_label = if let Some(size) = dimensions { + format!("{}Γ—{} {}", size.width, size.height, format_name) } else { - None + format_name.into() }; v_flex() @@ -7816,29 +7809,27 @@ impl ThreadView { .border_color(self.tool_card_border_color(cx)) } }) - .when(dimensions_label.is_some() || location.is_some(), |this| { - this.child( - h_flex() - .w_full() - .justify_between() - .items_center() - .children(dimensions_label.map(|label| { - Label::new(label) - .size(LabelSize::XSmall) - .color(Color::Muted) - .buffer_font(cx) - })) - .when_some(location, |this, _loc| { - this.child( - Button::new(("go-to-file", entry_ix), "Go to File") - .label_size(LabelSize::Small) - .on_click(cx.listener(move |this, _, window, cx| { - this.open_tool_call_location(entry_ix, 0, window, cx); - })), - ) - }), - ) - }) + .child( + h_flex() + .w_full() + .justify_between() + .items_center() + .child( + Label::new(dimensions_label) + .size(LabelSize::XSmall) + .color(Color::Muted) + .buffer_font(cx), + ) + .when_some(location, |this, _loc| { + this.child( + Button::new(("go-to-file", entry_ix), "Go to File") + .label_size(LabelSize::Small) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_tool_call_location(entry_ix, 0, window, cx); + })), + ) + }), + ) .child( img(image) .max_w_96() From 938490639a9980f84902a916801a3cb79304b7c8 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 19 May 2026 11:55:02 +0200 Subject: [PATCH 035/289] agent_ui: Activate workspace from terminal notifications (#57096) We weren't activating and focusing the right thing before. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/agent_ui/src/agent_panel.rs | 127 ++++++++++++++++++++++++++++- 1 file changed, 124 insertions(+), 3 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 8401b521abd..f13415d0cd5 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -2153,10 +2153,35 @@ impl AgentPanel { let event_subscription = cx.subscribe_in(&pop_up, window, { move |this, _, event: &AgentNotificationEvent, window, cx| match event { AgentNotificationEvent::Accepted => { + let Some(handle) = window.window_handle().downcast::() else { + log::error!("root view should be a MultiWorkspace"); + return; + }; cx.activate(true); - window.activate_window(); - this.activate_terminal(terminal_id, true, window, cx); - this.dismiss_terminal_notifications(terminal_id, cx); + + let workspace = this.workspace.clone(); + cx.defer(move |cx| { + handle + .update(cx, |multi_workspace, window, cx| { + window.activate_window(); + + let Some(workspace) = workspace.upgrade() else { + return; + }; + multi_workspace.activate(workspace.clone(), None, window, cx); + + workspace.update(cx, |workspace, cx| { + workspace.reveal_panel::(window, cx); + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.activate_terminal(terminal_id, true, window, cx); + }); + } + workspace.focus_panel::(window, cx); + }); + }) + .log_err(); + }); } AgentNotificationEvent::Dismissed => { this.dismiss_terminal_notifications(terminal_id, cx); @@ -7758,6 +7783,102 @@ mod tests { }); } + #[gpui::test] + async fn test_terminal_notification_view_activates_terminal_workspace(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + cx.update_flags(true, vec!["agent-panel-terminal".to_string()]); + AgentSettings::override_global( + AgentSettings { + notify_when_agent_waiting: NotifyWhenAgentWaiting::PrimaryScreen, + ..AgentSettings::get_global(cx).clone() + }, + cx, + ); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project_a", json!({ "file.txt": "" })) + .await; + fs.insert_tree("/project_b", json!({ "file.txt": "" })) + .await; + let project_a = Project::test(fs.clone(), [Path::new("/project_a")], cx).await; + let project_b = Project::test(fs, [Path::new("/project_b")], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + let workspace_a = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + let workspace_b = multi_workspace + .update(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(project_b.clone(), window, cx) + }) + .unwrap(); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + let panel_a = workspace_a.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + let first_terminal_id = panel_a + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Build", true, window, cx) + }) + .expect("first test terminal should be inserted"); + let second_terminal_id = panel_a + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Server", true, window, cx) + }) + .expect("second test terminal should be inserted"); + cx.run_until_parked(); + + multi_workspace + .read_with(cx, |multi_workspace, _cx| { + assert_eq!(multi_workspace.workspace(), &workspace_b); + }) + .unwrap(); + panel_a.read_with(cx, |panel, _cx| { + assert_eq!(panel.active_terminal_id(), Some(second_terminal_id)); + }); + + panel_a.update(cx, |panel, cx| { + panel.emit_test_terminal_bell(first_terminal_id, cx); + }); + cx.run_until_parked(); + + let notification = cx + .windows() + .iter() + .find_map(|window| window.downcast::()) + .expect("terminal bell should show a notification"); + notification + .update(cx, |notification, _window, cx| notification.accept(cx)) + .unwrap(); + cx.run_until_parked(); + + multi_workspace + .read_with(cx, |multi_workspace, _cx| { + assert_eq!(multi_workspace.workspace(), &workspace_a); + }) + .unwrap(); + panel_a.read_with(cx, |panel, cx| { + assert_eq!(panel.active_terminal_id(), Some(first_terminal_id)); + let first_terminal = panel + .terminals(cx) + .into_iter() + .find(|terminal| terminal.id == first_terminal_id) + .expect("first terminal should remain in the panel"); + assert!(!first_terminal.has_notification); + }); + } + #[gpui::test] async fn test_running_thread_retained_when_navigating_away(cx: &mut TestAppContext) { let (panel, mut cx) = setup_panel(cx).await; From 85f410004cf583d886dd37f4ffdb7606950678b1 Mon Sep 17 00:00:00 2001 From: Kunall Banerjee Date: Tue, 19 May 2026 06:05:49 -0400 Subject: [PATCH 036/289] agent_ui: Trigger @-mention menu after opening brackets (#55504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing `@` immediately after `(`, `[`, or `{` did not open the Agent Panel’s @-mention completion menu, so `(@file)`, `[@file]`, and `{@file}` were unusable. This has been bothering me for quite some time now. Overall, I believe this is a QoL improvement, albeit a small one. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Release Notes: - Fixed the Agent Panel’s @-mention menu not appearing when `@` immediately follows `(`, `[`, or `{`. --------- Co-authored-by: Bennet Bo Fenner --- crates/agent_ui/src/completion_provider.rs | 41 +++++++++++++++++++--- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index 3a4ae6ecc2b..acc08541100 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -1868,10 +1868,11 @@ impl MentionCompletion { offset_to_line: usize, supported_modes: &[PromptContextType], ) -> Option { - // Find the rightmost '@' that has a word boundary before it and no whitespace immediately after + // Find the rightmost '@' that has a boundary before it and no whitespace immediately after. + // A boundary is the start of the line, whitespace, or an opening bracket. let mut last_mention_start = None; for (idx, _) in line.rmatch_indices('@') { - // No whitespace immediately after '@' + // No whitespace immediately after '@'. if line[idx + 1..] .chars() .next() @@ -1880,12 +1881,11 @@ impl MentionCompletion { continue; } - // Must be a word boundary before '@' if idx > 0 && line[..idx] .chars() .last() - .is_some_and(|c| !c.is_whitespace()) + .is_some_and(|c| !c.is_whitespace() && !matches!(c, '(' | '[' | '{')) { continue; } @@ -2960,6 +2960,39 @@ mod tests { }), "Should parse URL ending with @ (even if URL is incomplete)" ); + + // Bracketed mentions: opening brackets count as a boundary before '@' so + // typing `(@`, `[@`, or `{@` still opens the completion menu. + + assert_eq!( + MentionCompletion::try_parse("(@", 0, &supported_modes), + Some(MentionCompletion { + source_range: 1..2, + mode: None, + argument: None, + }), + "Should parse mention immediately after '('" + ); + + assert_eq!( + MentionCompletion::try_parse("[@", 0, &supported_modes), + Some(MentionCompletion { + source_range: 1..2, + mode: None, + argument: None, + }), + "Should parse mention immediately after '['" + ); + + assert_eq!( + MentionCompletion::try_parse("{@", 0, &supported_modes), + Some(MentionCompletion { + source_range: 1..2, + mode: None, + argument: None, + }), + "Should parse mention immediately after '{{'" + ); } #[gpui::test] From 4557ad7ad12852ffddbe4a25350173197812bdf4 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 19 May 2026 13:29:00 +0200 Subject: [PATCH 037/289] eval_cli: Initialize themes in eval headless mode (#57139) Also fix patch generation Release Notes: - N/A --- Cargo.lock | 2 ++ crates/eval_cli/Cargo.toml | 2 ++ crates/eval_cli/src/headless.rs | 1 + crates/eval_cli/zed_eval/agent.py | 13 +++++++++---- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 51a1d750fa4..f976aefdf3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5964,6 +5964,8 @@ dependencies = [ "settings", "shellexpand", "terminal_view", + "theme", + "theme_settings", "util", "watch", ] diff --git a/crates/eval_cli/Cargo.toml b/crates/eval_cli/Cargo.toml index cac5dc6aa28..ed1f24d75d2 100644 --- a/crates/eval_cli/Cargo.toml +++ b/crates/eval_cli/Cargo.toml @@ -47,5 +47,7 @@ serde_json.workspace = true settings.workspace = true shellexpand.workspace = true terminal_view.workspace = true +theme.workspace = true +theme_settings.workspace = true util.workspace = true watch.workspace = true diff --git a/crates/eval_cli/src/headless.rs b/crates/eval_cli/src/headless.rs index a5b86f8eec8..9423f86a312 100644 --- a/crates/eval_cli/src/headless.rs +++ b/crates/eval_cli/src/headless.rs @@ -40,6 +40,7 @@ pub fn init(cx: &mut App) -> Arc { let settings_store = SettingsStore::new(cx, &settings::default_settings()); cx.set_global(settings_store); + theme_settings::init(theme::LoadThemes::JustBase, cx); let user_agent = format!( "Zed Agent CLI/{} ({}; {})", diff --git a/crates/eval_cli/zed_eval/agent.py b/crates/eval_cli/zed_eval/agent.py index 4543dd9497d..4720a7dbc13 100644 --- a/crates/eval_cli/zed_eval/agent.py +++ b/crates/eval_cli/zed_eval/agent.py @@ -443,16 +443,21 @@ class ZedAgent(BaseInstalledAgent): env=env, ) - # Only generate a patch if the workdir is a git repo - # (SWE-bench style). Terminal-bench containers aren't git repos. + # Only generate a patch if the workdir is a git repo with a valid HEAD + # (SWE-bench style). Terminal-bench containers aren't git repos, and + # some harnesses mount an initialized repo before creating the first commit. await self.exec_as_agent( environment, command=( - 'if [ -d ".git" ]; then ' + "if git rev-parse --git-dir >/dev/null 2>&1; then " "git add -A && " - "git diff --cached HEAD > /logs/agent/patch.diff && " + "if git rev-parse --verify HEAD >/dev/null 2>&1; then " + "git diff --cached HEAD -- > /logs/agent/patch.diff && " 'echo "Patch size: $(wc -c < /logs/agent/patch.diff) bytes"; ' "else " + 'echo "Git repo has no valid HEAD, skipping patch generation"; ' + "fi; " + "else " 'echo "No git repo found, skipping patch generation"; ' "fi" ), From 46b08f9d7d6f68875cee692f4ae88c902a05ab4a Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Tue, 19 May 2026 05:46:05 -0600 Subject: [PATCH 038/289] gpui: Trim trailing whitespace and punctuation before ellipsis (#57106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When truncating text at the end with an ellipsis, the truncation point can land right after a space or punctuation character, producing results like `"some text …"` or `"some text-…"`. This trims trailing whitespace and ASCII punctuation from the truncated prefix before appending the ellipsis affix, so you get clean results like `"some text…"` instead. Release Notes: - Improved text truncation to avoid trailing spaces or punctuation before the ellipsis. --- crates/gpui/src/text_system/line_wrapper.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index 67e4a971344..0524bad84f0 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -201,9 +201,11 @@ impl LineWrapper { "{truncation_affix}{}", &line[line.ceil_char_boundary(truncate_ix + 1)..] )), - TruncateFrom::End => { - SharedString::from(format!("{}{truncation_affix}", &line[..truncate_ix])) - } + TruncateFrom::End => SharedString::from(format!( + "{}{truncation_affix}", + line[..truncate_ix] + .trim_end_matches(|c: char| c.is_whitespace() || c.is_ascii_punctuation()) + )), }; let mut runs = runs.to_vec(); update_runs_after_truncation(&result, truncation_affix, &mut runs, truncate_from); From c352cad16946d9685d580ae0381984247526e1e2 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Tue, 19 May 2026 14:29:35 +0200 Subject: [PATCH 039/289] agent: Replay image output (#57143) Release Notes: - agent: Fix image output from tools not being reloaded when restoring thread --- crates/agent/src/thread.rs | 188 +++++++++++++++++++++++++++++++++++-- 1 file changed, 180 insertions(+), 8 deletions(-) diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index ae5c5510764..55d7b62b99b 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -1215,10 +1215,10 @@ impl Thread { stream: &ThreadEventStream, cx: &mut Context, ) { - // Extract saved output and status first, so they're available even if tool is not found let output = tool_result .as_ref() .and_then(|result| result.output.clone()); + let replay_content = tool_result.and_then(Self::tool_result_content_for_replay); let status = tool_result .as_ref() .map_or(acp::ToolCallStatus::Failed, |result| { @@ -1255,13 +1255,13 @@ impl Thread { .raw_input(tool_use.input.clone()), ))) .ok(); - stream.update_tool_call_fields( - &tool_use.id, - acp::ToolCallUpdateFields::new() - .status(status) - .raw_output(output), - None, - ); + let mut fields = acp::ToolCallUpdateFields::new() + .status(status) + .raw_output(output); + if let Some(content) = replay_content { + fields = fields.content(content); + } + stream.update_tool_call_fields(&tool_use.id, fields, None); return; }; @@ -1275,6 +1275,14 @@ impl Thread { tool_use.input.clone(), ); + if let Some(content) = replay_content { + stream.update_tool_call_fields( + &tool_use.id, + acp::ToolCallUpdateFields::new().content(content), + None, + ); + } + if let Some(output) = output.clone() { // For replay, we use a dummy cancellation receiver since the tool already completed let (_cancellation_tx, cancellation_rx) = watch::channel(false); @@ -1297,6 +1305,45 @@ impl Thread { ); } + fn tool_result_content_for_replay( + tool_result: &LanguageModelToolResult, + ) -> Option> { + let has_image = tool_result + .content + .iter() + .any(|part| matches!(part, LanguageModelToolResultContent::Image(_))); + if !has_image && tool_result.output.is_some() { + return None; + } + + let content = tool_result + .content + .iter() + .filter_map(|part| match part { + LanguageModelToolResultContent::Text(text) => { + if text.is_empty() { + None + } else { + Some(acp::ToolCallContent::Content(acp::Content::new( + acp::ContentBlock::Text(acp::TextContent::new(text.to_string())), + ))) + } + } + LanguageModelToolResultContent::Image(image) => Some( + acp::ToolCallContent::Content(acp::Content::new(acp::ContentBlock::Image( + acp::ImageContent::new(image.source.clone(), "image/png"), + ))), + ), + }) + .collect::>(); + + if content.is_empty() { + None + } else { + Some(content) + } + } + pub fn from_db( id: acp::SessionId, db_thread: DbThread, @@ -4454,6 +4501,131 @@ mod tests { }) } + struct ReplayImageTool; + + impl AgentTool for ReplayImageTool { + type Input = (); + type Output = String; + + const NAME: &'static str = "registered_image_tool"; + + fn kind() -> acp::ToolKind { + acp::ToolKind::Other + } + + fn initial_title( + &self, + _input: Result, + _cx: &mut App, + ) -> SharedString { + "Registered Image Tool".into() + } + + fn run( + self: Arc, + _input: ToolInput, + _event_stream: ToolCallEventStream, + _cx: &mut App, + ) -> Task> { + Task::ready(Ok(String::new())) + } + } + + #[gpui::test] + async fn test_replay_tool_call_replays_image_content(cx: &mut TestAppContext) { + let (thread, _event_stream) = setup_thread_for_test(cx).await; + + let registered_tool_use_id = LanguageModelToolUseId::from("registered_tool_id"); + let missing_tool_use_id = LanguageModelToolUseId::from("missing_tool_id"); + let image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; + let image = LanguageModelImage { + source: image_data.into(), + }; + + let mut replay_events = cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.add_tool(ReplayImageTool); + + let registered_tool_use = LanguageModelToolUse { + id: registered_tool_use_id.clone(), + name: ReplayImageTool::NAME.into(), + raw_input: "null".to_string(), + input: json!(null), + is_input_complete: true, + thought_signature: None, + }; + let missing_tool_use = LanguageModelToolUse { + id: missing_tool_use_id.clone(), + name: "missing_image_tool".into(), + raw_input: "{}".to_string(), + input: json!({}), + is_input_complete: true, + thought_signature: None, + }; + + let mut tool_results = IndexMap::default(); + tool_results.insert( + registered_tool_use_id.clone(), + LanguageModelToolResult { + tool_use_id: registered_tool_use_id.clone(), + tool_name: ReplayImageTool::NAME.into(), + is_error: false, + content: vec![ + LanguageModelToolResultContent::Text("before".into()), + LanguageModelToolResultContent::Image(image.clone()), + LanguageModelToolResultContent::Text("after".into()), + ], + output: Some(json!("raw output")), + }, + ); + tool_results.insert( + missing_tool_use_id.clone(), + LanguageModelToolResult { + tool_use_id: missing_tool_use_id.clone(), + tool_name: "missing_image_tool".into(), + is_error: false, + content: vec![LanguageModelToolResultContent::Image(image.clone())], + output: Some(json!("raw output")), + }, + ); + + thread.messages.push(Message::Agent(AgentMessage { + content: vec![ + AgentMessageContent::ToolUse(registered_tool_use), + AgentMessageContent::ToolUse(missing_tool_use), + ], + tool_results, + reasoning_details: None, + })); + + thread.replay(cx) + }) + }); + + let mut tool_use_ids_with_image_content = HashSet::default(); + while let Some(event) = replay_events.next().await { + let event = event.unwrap(); + if let ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update)) = + event + && let Some(content) = &update.fields.content + && content.iter().any(|content| { + matches!( + content, + acp::ToolCallContent::Content(acp::Content { + content: acp::ContentBlock::Image(_), + .. + }) + ) + }) + { + tool_use_ids_with_image_content.insert(update.tool_call_id.to_string()); + } + } + + assert!(tool_use_ids_with_image_content.contains(®istered_tool_use_id.to_string())); + assert!(tool_use_ids_with_image_content.contains(&missing_tool_use_id.to_string())); + } + #[gpui::test] async fn test_set_model_propagates_to_subagents(cx: &mut TestAppContext) { let (parent, _event_stream) = setup_thread_for_test(cx).await; From da43bdb648202474bc7402590f0d6042a62c6ba2 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Tue, 19 May 2026 14:51:42 +0200 Subject: [PATCH 040/289] agent: Support image output from MCP tools (#57134) Release Notes: - agent: Support image output from MCP tools --- crates/agent/src/tests/mod.rs | 25 +- .../src/tools/context_server_registry.rs | 47 +++- .../src/conversation_view/thread_view.rs | 44 ++-- crates/language_model/src/request.rs | 230 +++++++++++------- crates/language_model_core/src/request.rs | 1 - 5 files changed, 225 insertions(+), 122 deletions(-) diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 7713907f893..9592d8f7928 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -26,10 +26,10 @@ use gpui::{ use indoc::indoc; use language_model::{ CompletionIntent, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelId, LanguageModelProviderId, LanguageModelProviderName, LanguageModelRegistry, - LanguageModelRequest, LanguageModelRequestMessage, LanguageModelToolResult, - LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, Role, StopReason, - TokenUsage, + LanguageModelId, LanguageModelImageExt, LanguageModelProviderId, LanguageModelProviderName, + LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, + LanguageModelToolResult, LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, + Role, StopReason, TokenUsage, fake_provider::{FakeLanguageModel, FakeLanguageModelProvider}, }; use pretty_assertions::assert_eq; @@ -1656,6 +1656,7 @@ async fn test_mcp_tool_multi_content_response(cx: &mut TestAppContext) { let (tool_call_params, tool_call_response) = mcp_tool_calls.next().await.unwrap(); assert_eq!(tool_call_params.name, "screenshot"); + let image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; tool_call_response .send(context_server::types::CallToolResponse { content: vec![ @@ -1663,7 +1664,7 @@ async fn test_mcp_tool_multi_content_response(cx: &mut TestAppContext) { text: "Some text".into(), }, context_server::types::ToolResponseContent::Image { - data: "aGVsbG8=".into(), + data: image_data.into(), mime_type: "image/png".into(), }, context_server::types::ToolResponseContent::Text { @@ -1691,13 +1692,25 @@ async fn test_mcp_tool_multi_content_response(cx: &mut TestAppContext) { }) .expect("expected a tool result"); assert_eq!(tool_result.tool_use_id, "tool_1".into()); - assert_eq!(tool_result.content.len(), 2); + assert_eq!(tool_result.content.len(), 3); + assert_eq!( + tool_result.content[0], + language_model::LanguageModelToolResultContent::Text(Arc::from("Some text")) + ); + let expected_image = + language_model::LanguageModelImage::from_base64_image(image_data, "image/png") + .expect("image conversion should not error") + .expect("image conversion should succeed"); assert_eq!( tool_result.content[0], language_model::LanguageModelToolResultContent::Text(Arc::from("Some text")) ); assert_eq!( tool_result.content[1], + language_model::LanguageModelToolResultContent::Image(expected_image) + ); + assert_eq!( + tool_result.content[2], language_model::LanguageModelToolResultContent::Text(Arc::from("Some more text")) ); fake_model.end_last_completion_stream(); diff --git a/crates/agent/src/tools/context_server_registry.rs b/crates/agent/src/tools/context_server_registry.rs index 01601679c90..6c0e8d31557 100644 --- a/crates/agent/src/tools/context_server_registry.rs +++ b/crates/agent/src/tools/context_server_registry.rs @@ -5,7 +5,7 @@ use collections::{BTreeMap, HashMap}; use context_server::{ContextServerId, client::NotificationSubscription}; use futures::FutureExt as _; use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task}; -use language_model::LanguageModelToolResultContent; +use language_model::{LanguageModelImage, LanguageModelImageExt, LanguageModelToolResultContent}; use project::context_server_store::{ContextServerStatus, ContextServerStore}; use std::sync::Arc; use util::ResultExt; @@ -346,7 +346,7 @@ impl AnyAgentTool for ContextServerTool { let authorize = event_stream.authorize_third_party_tool(initial_title, tool_id, display_name, cx); - cx.spawn(async move |_cx| { + cx.spawn(async move |cx| { let input = input .recv() .await @@ -394,15 +394,50 @@ impl AnyAgentTool for ContextServerTool { } let mut llm_output = Vec::new(); + let mut tool_call_content = Vec::new(); let mut concatenated_text = String::new(); for content in response.content { match content { context_server::types::ToolResponseContent::Text { text } => { concatenated_text.push_str(&text); + tool_call_content.push(acp::ToolCallContent::Content(acp::Content::new( + acp::ContentBlock::Text(acp::TextContent::new(text.clone())), + ))); llm_output.push(LanguageModelToolResultContent::Text(text.into())); } - context_server::types::ToolResponseContent::Image { .. } => { - log::warn!("Ignoring image content from tool response"); + context_server::types::ToolResponseContent::Image { data, mime_type } => { + tool_call_content.push(acp::ToolCallContent::Content(acp::Content::new( + acp::ContentBlock::Image(acp::ImageContent::new( + data.clone(), + mime_type.clone(), + )), + ))); + let language_model_image = cx + .background_spawn({ + let mime_type = mime_type.clone(); + async move { + LanguageModelImage::from_base64_image(&data, &mime_type) + } + }) + .await; + match language_model_image { + Ok(Some(image)) => { + llm_output.push(LanguageModelToolResultContent::Image(image)); + } + Ok(None) => { + log::warn!( + "Skipping MCP tool response image with MIME type `{}` because it cannot be converted for language model input", + mime_type + ); + } + Err(error) => { + log::warn!( + "Failed to convert MCP tool response image with MIME type `{}` for language model input: {:#}", + mime_type, + error + ); + } + } } context_server::types::ToolResponseContent::Audio { .. } => { log::warn!("Ignoring audio content from tool response"); @@ -415,6 +450,10 @@ impl AnyAgentTool for ContextServerTool { } } } + if !tool_call_content.is_empty() { + event_stream + .update_fields(acp::ToolCallUpdateFields::new().content(tool_call_content)); + } let raw_output = serde_json::Value::String(concatenated_text); Ok(AgentToolOutput { raw_output, diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 6b63abd50ea..bb8d65fa868 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -6585,6 +6585,32 @@ impl ThreadView { ) }), ) + .when(!use_card_layout, |this| { + let button_id = + SharedString::from(format!("tool_output-collapse-{:?}", tool_call.id)); + let tool_call_id = tool_call.id.clone(); + + this.child( + div() + .ml(rems(0.4)) + .px_3p5() + .pt_2() + .border_l_1() + .border_color(self.tool_card_border_color(cx)) + .child( + IconButton::new(button_id, IconName::ChevronUp) + .full_width() + .style(ButtonStyle::Outlined) + .icon_color(Color::Muted) + .on_click(cx.listener({ + move |this: &mut Self, _, _, cx: &mut Context| { + this.expanded_tool_calls.remove(&tool_call_id); + cx.notify(); + } + })), + ), + ) + }) .into_any(), ToolCallStatus::Rejected => Empty.into_any(), } @@ -7580,7 +7606,6 @@ impl ThreadView { } else if let Some(markdown) = content.markdown() { self.render_markdown_output( markdown.clone(), - tool_call.id.clone(), context_ix, card_layout, window, @@ -7724,14 +7749,11 @@ impl ThreadView { fn render_markdown_output( &self, markdown: Entity, - tool_call_id: acp::ToolCallId, context_ix: usize, card_layout: bool, window: &Window, cx: &Context, ) -> AnyElement { - let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id)); - v_flex() .gap_2() .map(|this| { @@ -7754,20 +7776,6 @@ impl ThreadView { MarkdownStyle::themed(MarkdownFont::Agent, window, cx), cx, )) - .when(!card_layout, |this| { - this.child( - IconButton::new(button_id, IconName::ChevronUp) - .full_width() - .style(ButtonStyle::Outlined) - .icon_color(Color::Muted) - .on_click(cx.listener({ - move |this: &mut Self, _, _, cx: &mut Context| { - this.expanded_tool_calls.remove(&tool_call_id); - cx.notify(); - } - })), - ) - }) .into_any_element() } diff --git a/crates/language_model/src/request.rs b/crates/language_model/src/request.rs index edb5645a8d1..b4dedbbd7ba 100644 --- a/crates/language_model/src/request.rs +++ b/crates/language_model/src/request.rs @@ -1,8 +1,8 @@ use std::io::{Cursor, Write}; use std::sync::Arc; -use anyhow::Result; -use base64::write::EncoderWriter; +use anyhow::{Result, anyhow}; +use base64::{Engine as _, write::EncoderWriter}; use gpui::{ App, AppContext as _, DevicePixels, Image, ImageFormat, ObjectFit, Size, Task, point, px, size, }; @@ -29,6 +29,7 @@ const MAX_IMAGE_DOWNSCALE_PASSES: usize = 8; pub trait LanguageModelImageExt { const FORMAT: ImageFormat; fn from_image(data: Arc, cx: &mut App) -> Task>; + fn from_base64_image(data: &str, mime_type: &str) -> Result>; } impl LanguageModelImageExt for LanguageModelImage { @@ -36,93 +37,104 @@ impl LanguageModelImageExt for LanguageModelImage { fn from_image(data: Arc, cx: &mut App) -> Task> { cx.background_spawn(async move { - let image_bytes = Cursor::new(data.bytes()); - let dynamic_image = match data.format() { - ImageFormat::Png => image::codecs::png::PngDecoder::new(image_bytes) - .and_then(image::DynamicImage::from_decoder), - ImageFormat::Jpeg => image::codecs::jpeg::JpegDecoder::new(image_bytes) - .and_then(image::DynamicImage::from_decoder), - ImageFormat::Webp => image::codecs::webp::WebPDecoder::new(image_bytes) - .and_then(image::DynamicImage::from_decoder), - ImageFormat::Gif => image::codecs::gif::GifDecoder::new(image_bytes) - .and_then(image::DynamicImage::from_decoder), - ImageFormat::Bmp => image::codecs::bmp::BmpDecoder::new(image_bytes) - .and_then(image::DynamicImage::from_decoder), - ImageFormat::Tiff => image::codecs::tiff::TiffDecoder::new(image_bytes) - .and_then(image::DynamicImage::from_decoder), - _ => return None, - } - .log_err()?; - - let width = dynamic_image.width(); - let height = dynamic_image.height(); - let image_size = size(DevicePixels(width as i32), DevicePixels(height as i32)); - - // First apply any provider-specific dimension constraints we know about (Anthropic). - let mut processed_image = if image_size.width.0 > ANTHROPIC_SIZE_LIMIT as i32 - || image_size.height.0 > ANTHROPIC_SIZE_LIMIT as i32 - { - let new_bounds = ObjectFit::ScaleDown.get_bounds( - gpui::Bounds { - origin: point(px(0.0), px(0.0)), - size: size(px(ANTHROPIC_SIZE_LIMIT), px(ANTHROPIC_SIZE_LIMIT)), - }, - image_size, - ); - dynamic_image.resize( - new_bounds.size.width.into(), - new_bounds.size.height.into(), - image::imageops::FilterType::Triangle, - ) - } else { - dynamic_image + let format = match data.format() { + ImageFormat::Png => image::ImageFormat::Png, + ImageFormat::Jpeg => image::ImageFormat::Jpeg, + ImageFormat::Webp => image::ImageFormat::WebP, + ImageFormat::Gif => image::ImageFormat::Gif, + ImageFormat::Bmp => image::ImageFormat::Bmp, + ImageFormat::Tiff => image::ImageFormat::Tiff, + ImageFormat::Ico => image::ImageFormat::Ico, + ImageFormat::Pnm => image::ImageFormat::Pnm, + ImageFormat::Svg => return None, }; - - // Then enforce a default per-image size cap on the encoded PNG bytes. - // - // We always send PNG bytes (either original PNG bytes, or re-encoded PNG) base64'd. - // The upstream provider limit we want to respect is effectively on the binary image - // payload size, so we enforce against the encoded PNG bytes before base64 encoding. - let mut encoded_png = encode_png_bytes(&processed_image).log_err()?; - for _pass in 0..MAX_IMAGE_DOWNSCALE_PASSES { - if encoded_png.len() <= DEFAULT_IMAGE_MAX_BYTES { - break; - } - - // Scale down geometrically to converge quickly. We don't know the final PNG size - // as a function of pixels, so we iteratively shrink. - let (w, h) = processed_image.dimensions(); - if w <= 1 || h <= 1 { - break; - } - - // Shrink by ~15% each pass (0.85). This is a compromise between speed and - // preserving image detail. - let new_w = ((w as f32) * 0.85).round().max(1.0) as u32; - let new_h = ((h as f32) * 0.85).round().max(1.0) as u32; - - processed_image = - processed_image.resize(new_w, new_h, image::imageops::FilterType::Triangle); - encoded_png = encode_png_bytes(&processed_image).log_err()?; - } - - if encoded_png.len() > DEFAULT_IMAGE_MAX_BYTES { - // Still too large after multiple passes; treat as non-convertible for now. - // (Provider-specific handling can be introduced later.) - return None; - } - - // Now base64 encode the PNG bytes. - let base64_image = encode_bytes_as_base64(encoded_png.as_slice()).log_err()?; - - // SAFETY: The base64 encoder should not produce non-UTF8. - let source = unsafe { String::from_utf8_unchecked(base64_image) }; - - Some(LanguageModelImage { - source: source.into(), - }) + let dynamic_image = + image::load_from_memory_with_format(data.bytes(), format).log_err()?; + language_model_image_from_dynamic_image(dynamic_image) + .log_err() + .flatten() }) } + + fn from_base64_image(data: &str, mime_type: &str) -> Result> { + let format = image::ImageFormat::from_mime_type(mime_type) + .ok_or_else(|| anyhow!("unsupported image MIME type `{}`", mime_type))?; + let bytes = base64::engine::general_purpose::STANDARD.decode(data.as_bytes())?; + let dynamic_image = image::load_from_memory_with_format(&bytes, format)?; + language_model_image_from_dynamic_image(dynamic_image) + } +} + +fn language_model_image_from_dynamic_image( + dynamic_image: image::DynamicImage, +) -> Result> { + let width = dynamic_image.width(); + let height = dynamic_image.height(); + let image_size = size(DevicePixels(width as i32), DevicePixels(height as i32)); + + // First apply any provider-specific dimension constraints we know about (Anthropic). + let mut processed_image = if image_size.width.0 > ANTHROPIC_SIZE_LIMIT as i32 + || image_size.height.0 > ANTHROPIC_SIZE_LIMIT as i32 + { + let new_bounds = ObjectFit::ScaleDown.get_bounds( + gpui::Bounds { + origin: point(px(0.0), px(0.0)), + size: size(px(ANTHROPIC_SIZE_LIMIT), px(ANTHROPIC_SIZE_LIMIT)), + }, + image_size, + ); + dynamic_image.resize( + new_bounds.size.width.into(), + new_bounds.size.height.into(), + image::imageops::FilterType::Triangle, + ) + } else { + dynamic_image + }; + + // Then enforce a default per-image size cap on the encoded PNG bytes. + // + // We always send PNG bytes (either original PNG bytes, or re-encoded PNG) base64'd. + // The upstream provider limit we want to respect is effectively on the binary image + // payload size, so we enforce against the encoded PNG bytes before base64 encoding. + let mut encoded_png = encode_png_bytes(&processed_image)?; + for _pass in 0..MAX_IMAGE_DOWNSCALE_PASSES { + if encoded_png.len() <= DEFAULT_IMAGE_MAX_BYTES { + break; + } + + // Scale down geometrically to converge quickly. We don't know the final PNG size + // as a function of pixels, so we iteratively shrink. + let (width, height) = processed_image.dimensions(); + if width <= 1 || height <= 1 { + break; + } + + // Shrink by ~15% each pass (0.85). This is a compromise between speed and + // preserving image detail. + let new_width = ((width as f32) * 0.85).round().max(1.0) as u32; + let new_height = ((height as f32) * 0.85).round().max(1.0) as u32; + + processed_image = + processed_image.resize(new_width, new_height, image::imageops::FilterType::Triangle); + encoded_png = encode_png_bytes(&processed_image)?; + } + + if encoded_png.len() > DEFAULT_IMAGE_MAX_BYTES { + // Still too large after multiple passes; treat as non-convertible for now. + // (Provider-specific handling can be introduced later.) + return Ok(None); + } + + // Now base64 encode the PNG bytes. + let base64_image = encode_bytes_as_base64(encoded_png.as_slice())?; + + // SAFETY: The base64 encoder should not produce non-UTF8. + let source = unsafe { String::from_utf8_unchecked(base64_image) }; + + Ok(Some(LanguageModelImage { + source: source.into(), + })) } fn encode_png_bytes(image: &image::DynamicImage) -> Result> { @@ -162,7 +174,6 @@ pub fn gpui_size_to_image_size(size: Size) -> ImageSize { #[cfg(test)] mod tests { use super::*; - use base64::Engine as _; use gpui::TestAppContext; fn base64_to_png_bytes(base64: &str) -> Vec { @@ -202,13 +213,46 @@ mod tests { raw_png.len() ); - let image = Arc::new(gpui::Image::from_bytes(ImageFormat::Png, raw_png)); + let image = Arc::new(gpui::Image::from_bytes(ImageFormat::Png, raw_png.clone())); let lm_image = cx .update(|cx| LanguageModelImage::from_image(Arc::clone(&image), cx)) .await .expect("from_image should succeed"); - let decoded_png = base64_to_png_bytes(lm_image.source.as_ref()); + assert_downscaled_from_original(lm_image.source.as_ref(), 4096, 4096); + + let base64_png = base64::engine::general_purpose::STANDARD.encode(raw_png); + let lm_image = LanguageModelImage::from_base64_image(&base64_png, "image/png") + .expect("from_base64_image should not error") + .expect("from_base64_image should succeed"); + + assert_downscaled_from_original(lm_image.source.as_ref(), 4096, 4096); + } + + #[test] + fn test_from_base64_image_converts_jpeg_to_png() { + use image::ImageEncoder as _; + + let mut jpeg_bytes = Vec::new(); + image::codecs::jpeg::JpegEncoder::new(&mut jpeg_bytes) + .write_image(&[255, 0, 0], 1, 1, image::ExtendedColorType::Rgb8) + .expect("encode jpeg"); + let jpeg_data = base64::engine::general_purpose::STANDARD.encode(jpeg_bytes); + + let image = LanguageModelImage::from_base64_image(&jpeg_data, "image/jpeg") + .expect("from_base64_image should not error") + .expect("from_base64_image should succeed"); + let png_bytes = base64_to_png_bytes(image.source.as_ref()); + + assert_eq!( + image::guess_format(&png_bytes).expect("guess image format"), + image::ImageFormat::Png + ); + assert_eq!(png_dimensions(&png_bytes), (1, 1)); + } + + fn assert_downscaled_from_original(base64_png: &str, width: u32, height: u32) { + let decoded_png = base64_to_png_bytes(base64_png); assert!( decoded_png.len() <= DEFAULT_IMAGE_MAX_BYTES, "Encoded PNG should be ≀ {} bytes after downscale, but was {} bytes", @@ -216,12 +260,12 @@ mod tests { decoded_png.len() ); - let (w, h) = png_dimensions(&decoded_png); + let (downsized_width, downsized_height) = png_dimensions(&decoded_png); assert!( - w < 4096 && h < 4096, + downsized_width < width && downsized_height < height, "Dimensions should have shrunk: got {}Γ—{}", - w, - h + downsized_width, + downsized_height ); } } diff --git a/crates/language_model_core/src/request.rs b/crates/language_model_core/src/request.rs index 7f8f7c7d764..19a642ba01b 100644 --- a/crates/language_model_core/src/request.rs +++ b/crates/language_model_core/src/request.rs @@ -438,7 +438,6 @@ mod tests { // Test image object let json = serde_json::json!({ "source": "base64encodedimagedata", - "size": {"width": 100, "height": 200} }); let content: LanguageModelToolResultContent = serde_json::from_value(json).unwrap(); match content { From 3a821765e51b2cb3d4764ecafbbbbd6acbff5923 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 19 May 2026 10:28:45 -0300 Subject: [PATCH 041/289] icons: Update some icon SVGs (#57151) Just some house-keeping here, aligning and fixing size on some SVGs. Release Notes: - N/A --- assets/icons/acp_registry.svg | 6 +++--- assets/icons/ai_lm_studio.svg | 26 +++++++++++++------------- assets/icons/ai_ollama.svg | 10 +++++----- assets/icons/ai_open_ai.svg | 2 +- assets/icons/ai_x_ai.svg | 2 +- assets/icons/ai_zed.svg | 2 +- assets/icons/editor_atom.svg | 2 +- assets/icons/editor_cursor.svg | 2 +- assets/icons/editor_emacs.svg | 12 +++++------- assets/icons/editor_jet_brains.svg | 2 +- assets/icons/editor_sublime.svg | 6 +++--- assets/icons/editor_vs_code.svg | 2 +- assets/icons/share.svg | 5 +++++ crates/icons/src/icons.rs | 1 + 14 files changed, 42 insertions(+), 38 deletions(-) create mode 100644 assets/icons/share.svg diff --git a/assets/icons/acp_registry.svg b/assets/icons/acp_registry.svg index fb64ea6fbcf..d98728fbbd0 100644 --- a/assets/icons/acp_registry.svg +++ b/assets/icons/acp_registry.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/assets/icons/ai_lm_studio.svg b/assets/icons/ai_lm_studio.svg index 5cfdeb5578c..eef6bfcdb86 100644 --- a/assets/icons/ai_lm_studio.svg +++ b/assets/icons/ai_lm_studio.svg @@ -1,15 +1,15 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/assets/icons/ai_ollama.svg b/assets/icons/ai_ollama.svg index 36a88c1ad6d..93071a78730 100644 --- a/assets/icons/ai_ollama.svg +++ b/assets/icons/ai_ollama.svg @@ -1,7 +1,7 @@ - - - - - + + + + + diff --git a/assets/icons/ai_open_ai.svg b/assets/icons/ai_open_ai.svg index e45ac315a01..857a03091bd 100644 --- a/assets/icons/ai_open_ai.svg +++ b/assets/icons/ai_open_ai.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/ai_x_ai.svg b/assets/icons/ai_x_ai.svg index d3400fbe9cd..dabee6f54df 100644 --- a/assets/icons/ai_x_ai.svg +++ b/assets/icons/ai_x_ai.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/ai_zed.svg b/assets/icons/ai_zed.svg index 6d78efacd5f..5ba2dbed183 100644 --- a/assets/icons/ai_zed.svg +++ b/assets/icons/ai_zed.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/editor_atom.svg b/assets/icons/editor_atom.svg index cc5fa83843f..ca9c3380c43 100644 --- a/assets/icons/editor_atom.svg +++ b/assets/icons/editor_atom.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/editor_cursor.svg b/assets/icons/editor_cursor.svg index e20013917d3..28eea301f7b 100644 --- a/assets/icons/editor_cursor.svg +++ b/assets/icons/editor_cursor.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/editor_emacs.svg b/assets/icons/editor_emacs.svg index 951d7b2be16..3dbb2683969 100644 --- a/assets/icons/editor_emacs.svg +++ b/assets/icons/editor_emacs.svg @@ -1,10 +1,8 @@ - - + + + + + - - - - - diff --git a/assets/icons/editor_jet_brains.svg b/assets/icons/editor_jet_brains.svg index 7d9cf0c65cd..94d30903f6c 100644 --- a/assets/icons/editor_jet_brains.svg +++ b/assets/icons/editor_jet_brains.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/editor_sublime.svg b/assets/icons/editor_sublime.svg index 95a04f6b541..92bf14977d4 100644 --- a/assets/icons/editor_sublime.svg +++ b/assets/icons/editor_sublime.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/editor_vs_code.svg b/assets/icons/editor_vs_code.svg index 2a71ad52af2..d1aef6fce4b 100644 --- a/assets/icons/editor_vs_code.svg +++ b/assets/icons/editor_vs_code.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/share.svg b/assets/icons/share.svg new file mode 100644 index 00000000000..00d2d09b93b --- /dev/null +++ b/assets/icons/share.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index d67c4f76e62..83a7a1e9c4e 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -218,6 +218,7 @@ pub enum IconName { Send, Server, Settings, + Share, Shift, SignalHigh, SignalLow, From 589dc95c87e8a39c4a7d1f146df393bd2ef6d639 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 19 May 2026 16:07:09 +0200 Subject: [PATCH 042/289] agent_ui: Restore last active agent panel entry (#57150) Makes sure we can reload the last terminal, and also keeps track more globally what your last agent type was so we can carry that over to new workspaces Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/agent_ui/src/agent_panel.rs | 686 ++++++++++++++++-- .../src/terminal_thread_metadata_store.rs | 55 +- crates/agent_ui/src/thread_metadata_store.rs | 6 + 3 files changed, 649 insertions(+), 98 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index f13415d0cd5..151a04d8bf3 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -94,6 +94,7 @@ use workspace::{ const AGENT_PANEL_KEY: &str = "agent_panel"; const MIN_PANEL_WIDTH: Pixels = px(300.); const LAST_USED_AGENT_KEY: &str = "agent_panel__last_used_external_agent"; +const LAST_CREATED_ENTRY_KIND_KEY: &str = "agent_panel__last_created_entry_kind"; const TERMINAL_AGENT_TELEMETRY_ID: &str = "terminal"; /// Maximum number of idle threads kept in the agent panel's retained list. @@ -145,6 +146,11 @@ struct LastUsedAgent { agent: Agent, } +#[derive(Serialize, Deserialize)] +struct LastCreatedEntryKind { + entry_kind: AgentPanelEntryKind, +} + /// Reads the most recently used agent across all workspaces. Used as a fallback /// when opening a workspace that has no per-workspace agent preference yet. fn read_global_last_used_agent(kvp: &KeyValueStore) -> Option { @@ -163,6 +169,22 @@ async fn write_global_last_used_agent(kvp: KeyValueStore, agent: Agent) { } } +fn read_global_last_created_entry_kind(kvp: &KeyValueStore) -> Option { + kvp.read_kvp(LAST_CREATED_ENTRY_KIND_KEY) + .log_err() + .flatten() + .and_then(|json| serde_json::from_str::(&json).log_err()) + .map(|entry| entry.entry_kind) +} + +async fn write_global_last_created_entry_kind(kvp: KeyValueStore, entry_kind: AgentPanelEntryKind) { + if let Some(json) = serde_json::to_string(&LastCreatedEntryKind { entry_kind }).log_err() { + kvp.write_kvp(LAST_CREATED_ENTRY_KIND_KEY.to_string(), json) + .await + .log_err(); + } +} + fn read_serialized_panel( workspace_id: workspace::WorkspaceId, kvp: &KeyValueStore, @@ -211,6 +233,8 @@ struct SerializedAgentPanel { #[serde(default)] last_active_thread: Option, #[serde(default)] + last_active_terminal_id: Option, + #[serde(default)] new_draft_thread_id: Option, } @@ -844,6 +868,7 @@ pub struct AgentPanel { draft_thread: Option>, retained_threads: HashMap>, terminals: HashMap, + pending_terminal_spawn: Option, new_thread_menu_handle: PopoverMenuHandle, agent_panel_menu_handle: PopoverMenuHandle, _extension_subscription: Option, @@ -872,52 +897,58 @@ impl AgentPanel { let selected_agent = self.selected_agent.clone(); let last_created_entry_kind = self.last_created_entry_kind; + let last_active_terminal_id = self + .active_terminal_id() + .map(|terminal_id| terminal_id.to_key_string()); - let is_draft_active = self.active_thread_is_draft(cx); - let active_thread_id = self.active_thread_id(cx); - let active_thread_agent = self - .active_conversation_view() - .map(|cv| cv.read(cx).agent_key().clone()) - .unwrap_or_else(|| self.selected_agent.clone()); - let last_active_thread = self - .active_agent_thread(cx) - .map(|thread| { - let thread = thread.read(cx); + let last_active_thread = if last_active_terminal_id.is_some() { + None + } else { + let is_draft_active = self.active_thread_is_draft(cx); + let active_thread_id = self.active_thread_id(cx); + let active_thread_agent = self + .active_conversation_view() + .map(|cv| cv.read(cx).agent_key().clone()) + .unwrap_or_else(|| self.selected_agent.clone()); + self.active_agent_thread(cx) + .map(|thread| { + let thread = thread.read(cx); - let title = thread.title(); - let work_dirs = thread.work_dirs().cloned(); - SerializedActiveThread { - session_id: (!is_draft_active).then(|| thread.session_id().0.to_string()), - thread_id: active_thread_id, - agent_type: active_thread_agent.clone(), - title: title.map(|t| t.to_string()), - work_dirs: work_dirs.map(|dirs| dirs.serialize()), - } - }) - .or_else(|| { - // The active view may be in `Loading` or `LoadError` β€” for - // example, while a restored thread is waiting for a custom - // agent to finish registering. Without this fallback, a - // stray `serialize()` triggered during that window would - // write `session_id=None` and wipe the restored session - if is_draft_active { - return None; - } - let conversation_view = self.active_conversation_view()?; - let session_id = conversation_view.read(cx).root_session_id.clone()?; - let metadata = ThreadMetadataStore::try_global(cx) - .and_then(|store| store.read(cx).entry_by_session(&session_id).cloned()); - Some(SerializedActiveThread { - session_id: Some(session_id.0.to_string()), - thread_id: active_thread_id, - agent_type: active_thread_agent.clone(), - title: metadata - .as_ref() - .and_then(|m| m.title.as_ref()) - .map(|t| t.to_string()), - work_dirs: metadata.map(|m| m.folder_paths().serialize()), + let title = thread.title(); + let work_dirs = thread.work_dirs().cloned(); + SerializedActiveThread { + session_id: (!is_draft_active).then(|| thread.session_id().0.to_string()), + thread_id: active_thread_id, + agent_type: active_thread_agent.clone(), + title: title.map(|t| t.to_string()), + work_dirs: work_dirs.map(|dirs| dirs.serialize()), + } }) - }); + .or_else(|| { + // The active view may be in `Loading` or `LoadError` β€” for + // example, while a restored thread is waiting for a custom + // agent to finish registering. Without this fallback, a + // stray `serialize()` triggered during that window would + // write `session_id=None` and wipe the restored session + if is_draft_active { + return None; + } + let conversation_view = self.active_conversation_view()?; + let session_id = conversation_view.read(cx).root_session_id.clone()?; + let metadata = ThreadMetadataStore::try_global(cx) + .and_then(|store| store.read(cx).entry_by_session(&session_id).cloned()); + Some(SerializedActiveThread { + session_id: Some(session_id.0.to_string()), + thread_id: active_thread_id, + agent_type: active_thread_agent.clone(), + title: metadata + .as_ref() + .and_then(|m| m.title.as_ref()) + .map(|t| t.to_string()), + work_dirs: metadata.map(|m| m.folder_paths().serialize()), + }) + }) + }; let new_draft_thread_id = self .draft_thread @@ -932,6 +963,7 @@ impl AgentPanel { selected_agent: Some(selected_agent), last_created_entry_kind, last_active_thread, + last_active_terminal_id, new_draft_thread_id, }, kvp, @@ -957,7 +989,7 @@ impl AgentPanel { .ok() .flatten(); - let (serialized_panel, global_last_used_agent) = cx + let (serialized_panel, global_last_used_agent, global_last_created_entry_kind) = cx .background_spawn(async move { match kvp { Some(kvp) => { @@ -965,9 +997,10 @@ impl AgentPanel { .and_then(|id| read_serialized_panel(id, &kvp)) .or_else(|| read_legacy_serialized_panel(&kvp)); let global_agent = read_global_last_used_agent(&kvp); - (panel, global_agent) + let global_entry_kind = read_global_last_created_entry_kind(&kvp); + (panel, global_agent, global_entry_kind) } - None => (None, None), + None => (None, None, None), } }) .await; @@ -975,33 +1008,15 @@ impl AgentPanel { let has_open_project = workspace .read_with(cx, |workspace, cx| !workspace.root_paths(cx).is_empty()) .unwrap_or(false); - let thread_to_restore = if has_open_project { + let terminal_id_to_restore = if has_open_project { serialized_panel .as_ref() - .and_then(|panel| panel.last_active_thread.as_ref()) - .and_then(|info| { - let lookup = cx.update(|_window, cx| { - let store = ThreadMetadataStore::global(cx); - let store = store.read(cx); - let primary = info.thread_id.and_then(|tid| store.entry(tid)); - let fallback = info.session_id.as_ref().and_then(|sid| { - store.entry_by_session(&acp::SessionId::new(sid.clone())) - }); - primary - .or(fallback) - .filter(|entry| !entry.archived) - .map(|entry| entry.thread_id) - }); - match lookup { - Ok(Some(thread_id)) => Some((info, thread_id)), - Ok(None) => { - log::info!( - "last active thread is archived or missing, skipping restoration" - ); - None - } - Err(err) => { - log::warn!("failed to look up last active thread metadata: {err}"); + .and_then(|panel| panel.last_active_terminal_id.as_deref()) + .and_then(|terminal_id| { + match TerminalId::from_key_string(terminal_id) { + Ok(terminal_id) => Some(terminal_id), + Err(error) => { + log::warn!("failed to parse last active terminal id: {error}"); None } } @@ -1009,6 +1024,88 @@ impl AgentPanel { } else { None }; + let terminal_to_restore = if let Some(terminal_id) = terminal_id_to_restore { + match cx.update(|_window, cx| { + TerminalThreadMetadataStore::try_global(cx).map(|store| { + let reload_task = store.read(cx).reload_task(); + (store, reload_task) + }) + }) { + Ok(Some((store, reload_task))) => { + reload_task.await; + match store + .read_with(cx, |store, _cx| store.entry(terminal_id).cloned()) + { + Some(metadata) => Some(metadata), + None => { + log::info!( + "last active terminal is missing, skipping restoration" + ); + None + } + } + } + Ok(None) => { + log::warn!("failed to restore active terminal: metadata store missing"); + None + } + Err(err) => { + log::warn!("failed to access terminal metadata store: {err}"); + None + } + } + } else { + None + }; + + let thread_to_restore = if has_open_project && terminal_to_restore.is_none() { + if let Some(info) = serialized_panel + .as_ref() + .and_then(|panel| panel.last_active_thread.as_ref()) + { + match cx.update(|_window, cx| { + ThreadMetadataStore::try_global(cx).map(|store| { + let reload_task = store.read(cx).reload_task(); + (store, reload_task) + }) + }) { + Ok(Some((store, reload_task))) => { + reload_task.await; + let thread_id = store.read_with(cx, |store, _cx| { + let primary = info.thread_id.and_then(|tid| store.entry(tid)); + let fallback = info.session_id.as_ref().and_then(|sid| { + store.entry_by_session(&acp::SessionId::new(sid.clone())) + }); + primary + .or(fallback) + .filter(|entry| !entry.archived) + .map(|entry| entry.thread_id) + }); + match thread_id { + Some(thread_id) => Some((info, thread_id)), + None => { + log::info!( + "last active thread is archived or missing, skipping restoration" + ); + None + } + } + } + Ok(None) => { + log::warn!("failed to restore active thread: metadata store missing"); + None + } + Err(err) => { + log::warn!("failed to access thread metadata store: {err}"); + None + } + } + } else { + None + } + } else { + None + }; let panel = workspace.update_in(cx, |workspace, window, cx| { let panel = cx.new(|cx| Self::new(workspace, prompt_store, window, cx)); @@ -1030,6 +1127,8 @@ impl AgentPanel { if let Some(serialized_panel) = &serialized_panel { panel.last_created_entry_kind = serialized_panel.last_created_entry_kind; + } else if let Some(entry_kind) = global_last_created_entry_kind { + panel.last_created_entry_kind = entry_kind; } // The thread being restored may have been bound to an @@ -1051,7 +1150,16 @@ impl AgentPanel { panel.selected_agent = agent; } - if let Some((info, thread_id)) = thread_to_restore { + if let Some(metadata) = terminal_to_restore { + panel.restore_terminal_for_panel_load( + metadata, + false, + AgentThreadSource::AgentPanel, + Some(workspace), + window, + cx, + ); + } else if let Some((info, thread_id)) = thread_to_restore { let agent = panel.selected_agent.clone(); panel.load_agent_thread( agent, @@ -1178,6 +1286,7 @@ impl AgentPanel { draft_thread: None, retained_threads: HashMap::default(), terminals: HashMap::default(), + pending_terminal_spawn: None, new_thread_menu_handle: PopoverMenuHandle::default(), agent_panel_menu_handle: PopoverMenuHandle::default(), @@ -1392,7 +1501,7 @@ impl AgentPanel { return; } - self.set_last_created_entry_kind(AgentPanelEntryKind::Thread, cx); + self.set_last_created_entry_kind_from_user_action(AgentPanelEntryKind::Thread, cx); // If the user is viewing a *parked* draft and the ephemeral // new-draft slot is occupied, pressing `+` should just focus the @@ -1545,6 +1654,7 @@ impl AgentPanel { if !self.supports_terminal(cx) { return; } + self.set_last_created_entry_kind_from_user_action(AgentPanelEntryKind::Terminal, cx); let working_directory = self.terminal_working_directory(workspace, cx); self.spawn_terminal( TerminalId::new(), @@ -1579,7 +1689,7 @@ impl AgentPanel { && self.project.read(cx).supports_terminal(cx) } - fn set_last_created_entry_kind( + fn set_last_created_entry_kind_from_user_action( &mut self, entry_kind: AgentPanelEntryKind, cx: &mut Context, @@ -1588,6 +1698,14 @@ impl AgentPanel { self.last_created_entry_kind = entry_kind; self.serialize(cx); } + + cx.background_spawn({ + let kvp = KeyValueStore::global(cx); + async move { + write_global_last_created_entry_kind(kvp, entry_kind).await; + } + }) + .detach(); } fn spawn_terminal( @@ -1619,6 +1737,13 @@ impl AgentPanel { workspace .update(cx, |workspace, cx| workspace.show_error(&error, cx)) .log_err(); + this.update(cx, |this, cx| { + if this.pending_terminal_spawn == Some(terminal_id) { + this.pending_terminal_spawn = None; + cx.notify(); + } + }) + .log_err(); return anyhow::Ok(()); } }; @@ -1711,7 +1836,9 @@ impl AgentPanel { notification_subscriptions: Vec::new(), _subscriptions: vec![view_subscription, terminal_subscription], }; - self.set_last_created_entry_kind(AgentPanelEntryKind::Terminal, cx); + if self.pending_terminal_spawn == Some(terminal_id) { + self.pending_terminal_spawn = None; + } terminal.refresh_metadata(cx); self.terminals.insert(terminal_id, terminal); self.persist_terminal_metadata(terminal_id, cx); @@ -1773,6 +1900,9 @@ impl AgentPanel { ) { let was_active = self.active_terminal_id() == Some(terminal_id); + if self.pending_terminal_spawn == Some(terminal_id) { + self.pending_terminal_spawn = None; + } self.dismiss_terminal_notifications(terminal_id, cx); if self.terminals.remove(&terminal_id).is_none() { return; @@ -1882,6 +2012,7 @@ impl AgentPanel { return; } + self.pending_terminal_spawn = Some(metadata.terminal_id); let working_directory = self.terminal_restore_working_directory(&metadata, workspace, cx); let initial_title = Self::terminal_restore_initial_title(&metadata); self.spawn_terminal( @@ -1898,6 +2029,23 @@ impl AgentPanel { ); } + fn restore_terminal_for_panel_load( + &mut self, + metadata: TerminalThreadMetadata, + focus: bool, + source: AgentThreadSource, + workspace: Option<&Workspace>, + window: &mut Window, + cx: &mut Context, + ) { + #[cfg(test)] + self.restore_test_terminal(metadata, focus, source, workspace, window, cx) + .log_err(); + + #[cfg(not(test))] + self.restore_terminal(metadata, focus, source, workspace, window, cx); + } + fn terminal_restore_working_directory( &self, metadata: &TerminalThreadMetadata, @@ -4050,7 +4198,99 @@ impl Panel for AgentPanel { impl AgentPanel { fn ensure_thread_initialized(&mut self, window: &mut Window, cx: &mut Context) { if matches!(self.base_view, BaseView::Uninitialized) { - self.activate_draft(false, AgentThreadSource::AgentPanel, window, cx); + if self.pending_terminal_spawn.is_some() { + return; + } + if self.should_create_terminal_for_new_entry(cx) { + let terminal_id = TerminalId::new(); + self.pending_terminal_spawn = Some(terminal_id); + cx.defer_in(window, move |this, window, cx| { + if matches!(this.base_view, BaseView::Uninitialized) + && this.pending_terminal_spawn == Some(terminal_id) + && this.should_create_terminal_for_new_entry(cx) + { + this.create_initial_terminal( + terminal_id, + AgentThreadSource::AgentPanel, + window, + cx, + ); + } else if this.pending_terminal_spawn == Some(terminal_id) { + this.pending_terminal_spawn = None; + } + }); + } else { + self.activate_draft(false, AgentThreadSource::AgentPanel, window, cx); + } + } + } + + fn create_initial_terminal( + &mut self, + terminal_id: TerminalId, + source: AgentThreadSource, + window: &mut Window, + cx: &mut Context, + ) { + if !self.supports_terminal(cx) { + if self.pending_terminal_spawn == Some(terminal_id) { + self.pending_terminal_spawn = None; + } + return; + } + let working_directory = self.terminal_working_directory(None, cx); + self.spawn_initial_terminal(terminal_id, working_directory, source, window, cx); + } + + #[cfg(not(test))] + fn spawn_initial_terminal( + &mut self, + terminal_id: TerminalId, + working_directory: Option, + source: AgentThreadSource, + window: &mut Window, + cx: &mut Context, + ) { + self.spawn_terminal( + terminal_id, + working_directory, + None, + None, + None, + true, + false, + source, + window, + cx, + ); + } + + #[cfg(test)] + fn spawn_initial_terminal( + &mut self, + terminal_id: TerminalId, + working_directory: Option, + source: AgentThreadSource, + window: &mut Window, + cx: &mut Context, + ) { + if let Err(error) = self.insert_display_only_terminal( + terminal_id, + working_directory, + None, + None, + None, + true, + false, + source, + window, + cx, + ) { + log::error!("failed to spawn test agent panel terminal: {error:#}"); + if self.pending_terminal_spawn == Some(terminal_id) { + self.pending_terminal_spawn = None; + cx.notify(); + } } } @@ -5528,6 +5768,7 @@ impl AgentPanel { cx: &mut Context, ) -> Result { let terminal_id = TerminalId::new(); + self.set_last_created_entry_kind_from_user_action(AgentPanelEntryKind::Terminal, cx); self.insert_display_only_terminal( terminal_id, None, @@ -5909,6 +6150,27 @@ mod tests { panel_b.update(cx, |panel, cx| panel.serialize(cx)); cx.run_until_parked(); + let workspace_a_id = workspace_a + .read_with(cx, |workspace, _cx| workspace.database_id()) + .expect("workspace A should have a database id"); + let kvp = cx.update(|_window, cx| KeyValueStore::global(cx)); + let serialized_a: SerializedAgentPanel = cx + .background_spawn(async move { read_serialized_panel(workspace_a_id, &kvp) }) + .await + .expect("workspace A should serialize panel state"); + assert!( + serialized_a.last_active_thread.is_some(), + "active thread should be the thread restore target" + ); + assert!( + serialized_a.last_active_terminal_id.is_none(), + "active thread serialization should not also include a terminal restore target" + ); + + cx.update(|_window, cx| { + ThreadMetadataStore::init_global(cx); + }); + // Load fresh panels for each workspace and verify independent state. let async_cx = cx.update(|window, cx| window.to_async(cx)); let loaded_a = AgentPanel::load(workspace_a.downgrade(), async_cx) @@ -5950,6 +6212,278 @@ mod tests { }); } + #[gpui::test] + async fn test_active_terminal_serialize_and_load_round_trip(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + agent::ThreadStore::init_global(cx); + TerminalThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project", json!({ "file.txt": "" })).await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + workspace.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + let panel = workspace.update_in(cx, |workspace, window, cx| { + cx.new(|cx| AgentPanel::new(workspace, None, window, cx)) + }); + + panel.update_in(cx, |panel, window, cx| { + panel.activate_new_thread(false, AgentThreadSource::AgentPanel, window, cx); + }); + let terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + panel.update(cx, |panel, cx| panel.serialize(cx)); + cx.run_until_parked(); + + let workspace_id = workspace + .read_with(cx, |workspace, _cx| workspace.database_id()) + .expect("workspace should have a database id"); + let kvp = cx.update(|_window, cx| KeyValueStore::global(cx)); + let serialized: SerializedAgentPanel = cx + .background_spawn(async move { read_serialized_panel(workspace_id, &kvp) }) + .await + .expect("workspace should serialize panel state"); + assert_eq!( + serialized.last_active_terminal_id, + Some(terminal_id.to_key_string()) + ); + assert!( + serialized.last_active_thread.is_none(), + "active terminal serialization should not also include a thread restore target" + ); + + cx.update(|_window, cx| { + TerminalThreadMetadataStore::init_global(cx); + }); + let async_cx = cx.update(|window, cx| window.to_async(cx)); + let loaded = AgentPanel::load(workspace.downgrade(), async_cx) + .await + .expect("panel load should succeed"); + for _ in 0..8 { + cx.run_until_parked(); + } + + loaded.read_with(cx, |panel, cx| { + assert_eq!(panel.active_terminal_id(), Some(terminal_id)); + assert!( + panel.active_conversation_view().is_none(), + "the restored terminal should remain active instead of falling back to a draft" + ); + assert!( + panel + .terminals(cx) + .into_iter() + .any(|terminal| terminal.id == terminal_id), + "active terminal metadata should be restored into the loaded panel" + ); + }); + } + + #[gpui::test] + async fn test_pending_terminal_restore_prevents_initial_terminal_creation( + cx: &mut TestAppContext, + ) { + let (panel, mut cx) = setup_panel(cx).await; + + panel.update_in(&mut cx, |panel, window, cx| { + panel.last_created_entry_kind = AgentPanelEntryKind::Terminal; + panel.pending_terminal_spawn = Some(TerminalId::new()); + panel.set_active(true, window, cx); + }); + for _ in 0..4 { + cx.run_until_parked(); + } + + panel.read_with(&cx, |panel, cx| { + assert!( + panel.terminals(cx).is_empty(), + "activation while a terminal restore is pending should not create a second terminal" + ); + assert!( + panel.active_conversation_view().is_none(), + "activation while a terminal restore is pending should not fall back to a draft" + ); + }); + } + + #[gpui::test] + async fn test_repeated_activation_only_creates_one_initial_terminal(cx: &mut TestAppContext) { + let (panel, mut cx) = setup_panel(cx).await; + + panel.update_in(&mut cx, |panel, window, cx| { + panel.last_created_entry_kind = AgentPanelEntryKind::Terminal; + panel.set_active(true, window, cx); + panel.set_active(true, window, cx); + }); + for _ in 0..8 { + cx.run_until_parked(); + } + + panel.read_with(&cx, |panel, cx| { + assert_eq!( + panel.terminals(cx).len(), + 1, + "repeated activation should only enqueue one initial terminal" + ); + assert!( + panel.active_terminal_id().is_some(), + "the single initial terminal should become active" + ); + }); + } + + #[gpui::test] + async fn test_restored_terminal_does_not_update_global_entry_kind(cx: &mut TestAppContext) { + let (panel, mut cx) = setup_panel(cx).await; + cx.update(|_, cx| { + TerminalThreadMetadataStore::init_global(cx); + }); + + panel.update_in(&mut cx, |panel, window, cx| { + panel.activate_new_thread(false, AgentThreadSource::AgentPanel, window, cx); + }); + cx.run_until_parked(); + cx.update(|_, cx| { + assert_eq!( + read_global_last_created_entry_kind(&KeyValueStore::global(cx)), + Some(AgentPanelEntryKind::Thread) + ); + }); + + let metadata = TerminalThreadMetadata { + terminal_id: TerminalId::new(), + title: "Restored Terminal".into(), + custom_title: None, + created_at: Utc::now(), + worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from( + "/project", + )])), + remote_connection: None, + working_directory: None, + }; + panel + .update_in(&mut cx, |panel, window, cx| { + panel.restore_test_terminal( + metadata, + true, + AgentThreadSource::AgentPanel, + None, + window, + cx, + ) + }) + .expect("test terminal should be restored"); + cx.run_until_parked(); + + cx.update(|_, cx| { + assert_eq!( + read_global_last_created_entry_kind(&KeyValueStore::global(cx)), + Some(AgentPanelEntryKind::Thread), + "restoring a terminal should not change the global new-entry default" + ); + }); + } + + #[gpui::test] + async fn test_new_workspace_load_uses_global_terminal_entry_kind(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + agent::ThreadStore::init_global(cx); + TerminalThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", json!({ "file.txt": "" })) + .await; + fs.insert_tree("/project-b", json!({ "file.txt": "" })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = Project::test(fs.clone(), [Path::new("/project-a")], cx).await; + let project_b = Project::test(fs.clone(), [Path::new("/project-b")], cx).await; + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + let multi_workspace_entity = multi_workspace.root(cx).unwrap(); + let workspace_a = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + workspace_a.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + let panel_a = workspace_a.update_in(cx, |workspace, window, cx| { + cx.new(|cx| AgentPanel::new(workspace, None, window, cx)) + }); + panel_a + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + cx.update(|_window, cx| { + assert_eq!( + read_global_last_created_entry_kind(&KeyValueStore::global(cx)), + Some(AgentPanelEntryKind::Terminal) + ); + }); + + let workspace_b = multi_workspace_entity.update_in(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(project_b.clone(), window, cx) + }); + workspace_b.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + + let async_cx = cx.update(|window, cx| window.to_async(cx)); + let loaded = AgentPanel::load(workspace_b.downgrade(), async_cx) + .await + .expect("panel load should succeed"); + workspace_b.update_in(cx, |workspace, window, cx| { + workspace.add_panel(loaded.clone(), window, cx); + }); + loaded.update_in(cx, |panel, window, cx| { + panel.set_active(true, window, cx); + }); + for _ in 0..8 { + cx.run_until_parked(); + } + + loaded.read_with(cx, |panel, cx| { + assert!( + panel.active_terminal_id().is_some(), + "new workspace should initialize to a terminal when terminal was the globally last used entry kind" + ); + assert!( + panel.active_conversation_view().is_none(), + "new workspace should not initialize to a draft when terminal is the global entry kind" + ); + assert!(panel.should_create_terminal_for_new_entry(cx)); + }); + } + #[gpui::test] async fn test_non_native_thread_without_metadata_is_not_restored(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent_ui/src/terminal_thread_metadata_store.rs b/crates/agent_ui/src/terminal_thread_metadata_store.rs index e3d5c9fdef8..c5e2dbbdfba 100644 --- a/crates/agent_ui/src/terminal_thread_metadata_store.rs +++ b/crates/agent_ui/src/terminal_thread_metadata_store.rs @@ -10,6 +10,7 @@ use db::{ }, sqlez_macros::sql, }; +use futures::{FutureExt, future::Shared}; use gpui::{AppContext as _, Entity, Global, Task}; use remote::{RemoteConnectionOptions, same_remote_connection_identity}; use ui::{App, Context, SharedString}; @@ -69,6 +70,7 @@ pub struct TerminalThreadMetadataStore { terminals: HashMap, terminals_by_paths: HashMap>, terminals_by_main_paths: HashMap>, + reload_task: Option>>, pending_terminal_ops_tx: async_channel::Sender, _db_operations_task: Task<()>, } @@ -125,6 +127,12 @@ impl TerminalThreadMetadataStore { self.terminals.values() } + pub fn reload_task(&self) -> Shared> { + self.reload_task + .clone() + .unwrap_or_else(|| Task::ready(()).shared()) + } + pub fn entries_for_path<'a>( &'a self, path_list: &PathList, @@ -312,6 +320,7 @@ impl TerminalThreadMetadataStore { terminals: HashMap::default(), terminals_by_paths: HashMap::default(), terminals_by_main_paths: HashMap::default(), + reload_task: None, pending_terminal_ops_tx: tx, _db_operations_task, }; @@ -332,30 +341,32 @@ impl TerminalThreadMetadataStore { fn reload(&mut self, cx: &mut Context) { let db = self.db.clone(); - cx.spawn(async move |this, cx| { - let rows = cx - .background_spawn(async move { - db.list() - .context("Failed to fetch terminal thread metadata") + self.reload_task = Some( + cx.spawn(async move |this, cx| { + let rows = cx + .background_spawn(async move { + db.list() + .context("Failed to fetch terminal thread metadata") + }) + .await + .log_err() + .unwrap_or_default(); + + this.update(cx, |this, cx| { + this.terminals.clear(); + this.terminals_by_paths.clear(); + this.terminals_by_main_paths.clear(); + + for row in rows { + this.cache_terminal_metadata(row); + } + + cx.notify(); }) - .await - .log_err() - .unwrap_or_default(); - - this.update(cx, |this, cx| { - this.terminals.clear(); - this.terminals_by_paths.clear(); - this.terminals_by_main_paths.clear(); - - for row in rows { - this.cache_terminal_metadata(row); - } - - cx.notify(); + .ok(); }) - .ok(); - }) - .detach(); + .shared(), + ); } } diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index 9787d3d9d7d..594b0d00b83 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -587,6 +587,12 @@ impl ThreadMetadataStore { self.threads.values() } + pub fn reload_task(&self) -> Shared> { + self.reload_task + .clone() + .unwrap_or_else(|| Task::ready(()).shared()) + } + /// Returns all archived threads. pub fn archived_entries(&self) -> impl Iterator + '_ { self.entries().filter(|t| t.archived) From c5f6fca756fecbfb177f4f8b791483a7e1ebb9bb Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 19 May 2026 11:32:46 -0300 Subject: [PATCH 043/289] Don't show trust modal for linked worktrees if main is trusted (#57153) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow up to https://github.com/zed-industries/zed/pull/57056 β€” This PR ensures we're refreshing the security modal so that it consumes the trust given to the main worktree when creating a new linked (Git) worktree. Release Notes: - N/A --- crates/git_ui/src/worktree_service.rs | 120 +++++++++++++++++++++++++ crates/workspace/src/security_modal.rs | 7 +- crates/workspace/src/workspace.rs | 2 +- 3 files changed, 127 insertions(+), 2 deletions(-) diff --git a/crates/git_ui/src/worktree_service.rs b/crates/git_ui/src/worktree_service.rs index 1eda4219092..e5301af3457 100644 --- a/crates/git_ui/src/worktree_service.rs +++ b/crates/git_ui/src/worktree_service.rs @@ -282,6 +282,15 @@ fn maybe_propagate_worktree_trust( } }) .ok(); + + // After trust propagation, refresh the security modal on the new workspace + // so it dismisses itself if there are no more restricted worktrees. + cx.update(|window, cx| { + new_workspace.update(cx, |workspace, cx| { + workspace.show_worktree_trust_security_modal(false, window, cx); + }); + }) + .ok(); } /// Handles the `CreateWorktree` action generically, without any agent panel involvement. @@ -1011,4 +1020,115 @@ mod tests { "switching back to the main worktree should not rerun create_worktree hooks" ); } + + #[gpui::test] + async fn test_linked_worktree_inherits_trust_from_main_worktree(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + project::trusted_worktrees::init(collections::HashMap::default(), cx); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + cx.update(|cx| ::set_global(fs.clone(), cx)); + fs.insert_tree( + "/root", + json!({ + "project": { + ".git": {}, + "src": { + "main.rs": "fn main() {}", + }, + }, + }), + ) + .await; + + let main_project_root = PathBuf::from(path!("/root/project")); + let project = + Project::test_with_worktree_trust(fs.clone(), [main_project_root.as_path()], cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + // The main worktree starts restricted; trust it explicitly + let worktree_store = project.read_with(cx, |project, _| project.worktree_store()); + let main_worktree_id = worktree_store.read_with(cx, |store, cx| { + store + .worktrees() + .next() + .map(|wt| wt.read(cx).id()) + .expect("should have a worktree") + }); + let trusted_store = cx + .read(|cx| project::trusted_worktrees::TrustedWorktrees::try_get_global(cx)) + .expect("trust store should exist"); + trusted_store.update(cx, |store, cx| { + store.trust( + &worktree_store, + collections::HashSet::from_iter([project::trusted_worktrees::PathTrust::Worktree( + main_worktree_id, + )]), + cx, + ); + }); + + // Verify main worktree is now trusted + let has_restricted = cx.read(|cx| { + project::trusted_worktrees::TrustedWorktrees::has_restricted_worktrees( + &worktree_store, + cx, + ) + }); + assert!( + !has_restricted, + "main worktree should be trusted after explicit trust" + ); + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.retain_active_workspace(cx); + }); + + // Create a linked worktree from the trusted main worktree + let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + main_workspace.update_in(cx, |workspace, window, cx| { + handle_create_worktree( + workspace, + &zed_actions::CreateWorktree { + worktree_name: Some("feature".to_string()), + branch_target: NewWorktreeBranchTarget::CurrentBranch, + }, + window, + None, + cx, + ); + }); + cx.run_until_parked(); + + // The new workspace (linked worktree) should inherit trust + let new_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let new_worktree_store = + new_workspace.read_with(cx, |ws, cx| ws.project().read(cx).worktree_store()); + let new_has_restricted = cx.read(|cx| { + project::trusted_worktrees::TrustedWorktrees::has_restricted_worktrees( + &new_worktree_store, + cx, + ) + }); + assert!( + !new_has_restricted, + "linked worktree should inherit trust from the main worktree" + ); + + // The security modal should not be showing + let has_modal = new_workspace.read_with(cx, |ws, cx| { + ws.active_modal::(cx) + .is_some() + }); + assert!( + !has_modal, + "security modal should not show for a linked worktree created from a trusted main worktree" + ); + } } diff --git a/crates/workspace/src/security_modal.rs b/crates/workspace/src/security_modal.rs index 89ce2abfd66..378968fd1c0 100644 --- a/crates/workspace/src/security_modal.rs +++ b/crates/workspace/src/security_modal.rs @@ -364,7 +364,12 @@ impl SecurityModal { if self.restricted_paths != new_restricted_worktrees { self.trust_parents = false; self.restricted_paths = new_restricted_worktrees; - cx.notify(); + if self.restricted_paths.is_empty() { + self.trusted = Some(true); + self.dismiss(cx); + } else { + cx.notify(); + } } } } else if !self.restricted_paths.is_empty() { diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 599a2d23681..0f529fc9362 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -15,7 +15,7 @@ pub mod path_list { } mod persistence; pub mod searchable; -mod security_modal; +pub mod security_modal; pub mod shared_screen; pub use shared_screen::SharedScreen; pub mod focus_follows_mouse; From ad437c93c23f356f1466be3efa4b25a176f76636 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 19 May 2026 16:43:38 +0200 Subject: [PATCH 044/289] sidebar: Fix stale sidebar thread header state (#57017) There was a case where if you archived or closed all threads, you wouldn't see the empty state again. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/agent_ui/src/draft_prompt_store.rs | 2 +- crates/sidebar/src/sidebar.rs | 87 +++++++++++++++------ crates/sidebar/src/sidebar_tests.rs | 92 +++++++++++++++++++++++ 3 files changed, 155 insertions(+), 26 deletions(-) diff --git a/crates/agent_ui/src/draft_prompt_store.rs b/crates/agent_ui/src/draft_prompt_store.rs index b32b8e811cf..34d17d49994 100644 --- a/crates/agent_ui/src/draft_prompt_store.rs +++ b/crates/agent_ui/src/draft_prompt_store.rs @@ -12,6 +12,7 @@ use agent_client_protocol::schema as acp; use anyhow::Context as _; use db::kvp::KeyValueStore; use gpui::{App, AppContext as _, Entity, Task}; +use itertools::Itertools; use ui::SharedString; use util::ResultExt as _; use workspace::Workspace; @@ -128,7 +129,6 @@ pub fn display_label_for_draft( acp::ContentBlock::ResourceLink(link) => Some(link.uri.as_str()), _ => None, }) - .collect::>() .join(" "); truncate_draft_label(&raw) } diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 735421f858d..a86960f3fb5 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -220,6 +220,32 @@ impl ThreadEntryWorkspace { } } +fn draft_display_label_for_thread_metadata( + metadata: &ThreadMetadata, + workspace: &ThreadEntryWorkspace, + cx: &App, +) -> Option { + let workspace = match workspace { + ThreadEntryWorkspace::Open(workspace) => Some(workspace), + ThreadEntryWorkspace::Closed { .. } => None, + }; + agent_ui::draft_prompt_store::display_label_for_draft(workspace, metadata.thread_id, cx) +} + +fn thread_metadata_would_render_sidebar_row( + metadata: &ThreadMetadata, + workspace: &ThreadEntryWorkspace, + hidden_draft_thread_ids: &HashSet, + cx: &App, +) -> bool { + if !metadata.is_draft() { + return true; + } + + !hidden_draft_thread_ids.contains(&metadata.thread_id) + && draft_display_label_for_thread_metadata(metadata, workspace, cx).is_some() +} + #[derive(Clone)] struct ThreadEntry { metadata: ThreadMetadata, @@ -1385,19 +1411,18 @@ impl Sidebar { let mut has_running_threads = false; let mut waiting_thread_count: usize = 0; let group_host = group_key.host(); + let hidden_draft_thread_ids: HashSet = group_workspaces + .iter() + .filter_map(|ws| { + ws.read(cx) + .panel::(cx) + .and_then(|panel| panel.read(cx).ephemeral_draft_thread_id(cx)) + }) + .collect(); if should_load_threads { let thread_store = ThreadMetadataStore::global(cx); - let ephemeral_drafts: HashSet = group_workspaces - .iter() - .filter_map(|ws| { - ws.read(cx) - .panel::(cx) - .and_then(|panel| panel.read(cx).ephemeral_draft_thread_id(cx)) - }) - .collect(); - let make_thread_entry = |row: ThreadMetadata, workspace: ThreadEntryWorkspace| -> ThreadEntry { let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id); @@ -1503,20 +1528,18 @@ impl Sidebar { } } - if !ephemeral_drafts.is_empty() { - threads.retain(|thread| !ephemeral_drafts.contains(&thread.metadata.thread_id)); + if !hidden_draft_thread_ids.is_empty() { + threads.retain(|thread| { + !hidden_draft_thread_ids.contains(&thread.metadata.thread_id) + }); } for thread in &mut threads { if !thread.is_draft { continue; } - let workspace = match &thread.workspace { - ThreadEntryWorkspace::Open(workspace) => Some(workspace), - ThreadEntryWorkspace::Closed { .. } => None, - }; - thread.metadata.title = agent_ui::draft_prompt_store::display_label_for_draft( - workspace, - thread.metadata.thread_id, + thread.metadata.title = draft_display_label_for_thread_metadata( + &thread.metadata, + &thread.workspace, cx, ); } @@ -1582,19 +1605,33 @@ impl Sidebar { } } - let has_threads = if !threads.is_empty() || !terminals.is_empty() { - true - } else { + let has_visible_rows = !threads.is_empty() || !terminals.is_empty(); + let has_stored_thread_rows = !should_load_threads && !has_visible_rows && { let store = ThreadMetadataStore::global(cx).read(cx); store .entries_for_main_worktree_path(group_key.path_list(), group_host.as_ref()) - .next() - .is_some() + .any(|metadata| { + let workspace = resolve_workspace(metadata.folder_paths()); + thread_metadata_would_render_sidebar_row( + metadata, + &workspace, + &hidden_draft_thread_ids, + cx, + ) + }) || store .entries_for_path(group_key.path_list(), group_host.as_ref()) - .next() - .is_some() + .any(|metadata| { + let workspace = resolve_workspace(metadata.folder_paths()); + thread_metadata_would_render_sidebar_row( + metadata, + &workspace, + &hidden_draft_thread_ids, + cx, + ) + }) }; + let has_threads = has_visible_rows || has_stored_thread_rows; if !query.is_empty() { let workspace_highlight_positions = diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index cb385828dc6..982f0c6cd24 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -95,6 +95,34 @@ fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool { .any(|entry| matches!(entry, ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(session_id))) } +#[track_caller] +fn assert_project_header_has_threads( + sidebar: &Entity, + project_name: &str, + expected_has_threads: bool, + cx: &mut gpui::VisualTestContext, +) { + sidebar.read_with(cx, |sidebar, _cx| { + let has_threads = sidebar.contents.entries.iter().find_map(|entry| { + if let ListEntry::ProjectHeader { + label, has_threads, .. + } = entry + && label.as_ref() == project_name + { + Some(*has_threads) + } else { + None + } + }); + + assert_eq!( + has_threads, + Some(expected_has_threads), + "expected project header `{project_name}` to have has_threads={expected_has_threads}, got {has_threads:?}" + ); + }); +} + #[track_caller] fn assert_remote_project_integration_sidebar_state( sidebar: &mut Sidebar, @@ -1540,6 +1568,70 @@ async fn test_agent_panel_terminals_appear_in_sidebar_and_search(cx: &mut TestAp ); } +#[gpui::test] +async fn test_closing_last_agent_panel_terminal_restores_empty_header(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + assert_project_header_has_threads(&sidebar, "my-project", false, cx); + + let terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + assert_project_header_has_threads(&sidebar, "my-project", true, cx); + + let (terminal_metadata, terminal_workspace) = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .find_map(|entry| match entry { + ListEntry::Terminal(terminal) if terminal.metadata.terminal_id == terminal_id => { + Some((terminal.metadata.clone(), terminal.workspace.clone())) + } + _ => None, + }) + .expect("terminal should be visible in sidebar") + }); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.close_terminal(&terminal_metadata, &terminal_workspace, window, cx); + }); + cx.run_until_parked(); + + panel.read_with(cx, |panel, cx| { + assert!(!panel.has_terminal(terminal_id)); + assert!( + panel.active_view_is_new_draft(cx), + "closing the active terminal should leave the panel on a hidden empty draft" + ); + }); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]"] + ); + assert_project_header_has_threads(&sidebar, "my-project", false, cx); + + let project_group_key = multi_workspace.read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace().read(cx).project_group_key(cx) + }); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.toggle_collapse(&project_group_key, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project]"] + ); + assert_project_header_has_threads(&sidebar, "my-project", false, cx); +} + #[gpui::test] async fn test_agent_panel_terminal_metadata_remains_visible_after_panel_is_removed( cx: &mut TestAppContext, From ae47ec9ac088483119b286bbc3e27c95afd88d27 Mon Sep 17 00:00:00 2001 From: Daniel Strobusch <1847260+dastrobu@users.noreply.github.com> Date: Tue, 19 May 2026 17:56:34 +0200 Subject: [PATCH 045/289] language_models: Fix Gemini tool parameter nullability and multi-type schema (#49292) Transform JSON schemas for Google AI tools to use `nullable: true` instead of `type: ["type", "null"]`, which is not supported by the Gemini API. Additionally, convert multi-type arrays (e.g., `type: ["string", "number"]`) to `anyOf` constraints, as Gemini expects a single string for the `type` field. This handles recursive transformation of properties, items, definitions, and logical operators, safely merging conflicting `anyOf` and `allOf` constraints. Closes https://github.com/zed-industries/zed/issues/44875 Closes https://github.com/zed-industries/zed/issues/32429 Release Notes: - Fixed a bug where using Gemini with certain tools (especially via MCP) resulted in "Invalid JSON payload received" errors due to incompatible JSON schema formats. ## Testing Added unit tests in `crates/language_model/src/tool_schema.rs` covering nullability, multi-types, and `oneOf` conversions. ### Manual Testing with MCP Test Server The [MCP Test Server](https://github.com/dastrobu/mcp-test-server) was used to verify these edge cases with Gemini 3 Flash. #### Setup 1. Install the test server: `cargo install --git https://github.com/dastrobu/mcp-test-server` 2. Add to Zed `settings.json`: ```json "context_servers": [ { "command": "mcp-test-server" } ] ``` Use the following pattern in a chat window: ``` call the add_tool function to create a new tool: weather with input schema: { "type": "object", "properties": { "city": { "type": ["string", "null"] } } } ``` Afterwards: ... ``` call it ``` Without fix: image With fix: image #### Cases verified manually: **1. Nullability in properties** - **Input:** ```json { "type": "object", "properties": { "city": { "type": ["string", "null"] } } } ``` - **Converted:** ```json { "type": "object", "properties": { "city": { "type": "string", "nullable": true } } } ``` **2. Multi-type properties** - **Input:** ```json { "type": "object", "properties": { "city": { "type": ["string", "number"] } } } ``` - **Converted:** ```json { "type": "object", "properties": { "city": { "anyOf": [ { "type": "string" }, { "type": "number" } ] } } } ``` **3. Explicit `anyOf` with nullability** - **Input:** ```json { "type": "object", "properties": { "city": { "anyOf": [ { "type": "string" }, { "type": "null" } ] } } } ``` - **Converted:** ```json { "type": "object", "properties": { "city": { "anyOf": [ { "type": "string" }, { "nullable": true } ] } } } ``` **4. Conflicting `anyOf` sources (Multi-type + existing `anyOf`)** - **Input:** ```json { "type": "object", "properties": { "city": { "type": ["string", "number"], "anyOf": [ { "minLength": 5 } ] } } } ``` - **Converted:** ```json { "type": "object", "properties": { "city": { "allOf": [ { "anyOf": [{ "minLength": 5 }] }, { "anyOf": [{ "type": "string" }, { "type": "number" }] } ] } } } ``` Co-authored-by: Richard Feldman --- Cargo.lock | 1 + crates/language_model_core/Cargo.toml | 1 + crates/language_model_core/src/tool_schema.rs | 357 +++++++++++++++++- 3 files changed, 351 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f976aefdf3d..c9f821f7f17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9651,6 +9651,7 @@ dependencies = [ "futures 0.3.32", "gpui_shared_string", "http_client", + "log", "partial-json-fixer", "schemars 1.0.4", "serde", diff --git a/crates/language_model_core/Cargo.toml b/crates/language_model_core/Cargo.toml index e9aa06400b6..c254989b4d5 100644 --- a/crates/language_model_core/Cargo.toml +++ b/crates/language_model_core/Cargo.toml @@ -19,6 +19,7 @@ cloud_llm_client.workspace = true futures.workspace = true gpui_shared_string.workspace = true http_client.workspace = true +log.workspace = true partial-json-fixer.workspace = true schemars.workspace = true serde.workspace = true diff --git a/crates/language_model_core/src/tool_schema.rs b/crates/language_model_core/src/tool_schema.rs index 86e6d6d137e..729d625939c 100644 --- a/crates/language_model_core/src/tool_schema.rs +++ b/crates/language_model_core/src/tool_schema.rs @@ -4,7 +4,7 @@ use schemars::{ generate::SchemaSettings, transform::{Transform, transform_subschemas}, }; -use serde_json::Value; +use serde_json::{Map, Value, json}; /// Indicates the format used to define the input schema for a language model tool. #[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)] @@ -64,6 +64,8 @@ pub fn adapt_schema_to_format( json: &mut Value, format: LanguageModelToolSchemaFormat, ) -> Result<()> { + log::trace!("Adapting schema to format {:?}: {}", format, json); + if let Value::Object(obj) = json { obj.remove("$schema"); obj.remove("title"); @@ -73,7 +75,10 @@ pub fn adapt_schema_to_format( match format { LanguageModelToolSchemaFormat::JsonSchema => preprocess_json_schema(json), LanguageModelToolSchemaFormat::JsonSchemaSubset => adapt_to_json_schema_subset(json), - } + }?; + + log::trace!("Adapted schema: {}", json); + Ok(()) } fn preprocess_json_schema(json: &mut Value) -> Result<()> { @@ -118,6 +123,9 @@ fn adapt_to_json_schema_subset(json: &mut Value) -> Result<()> { } } + convert_null_in_types_to_nullable(obj); + convert_types_to_any_of_defs(obj); + // Ensure that the type field is not an array. This can happen with MCP tool // schemas that use multiple types (e.g. `["string", "number"]` or `["string", "null"]`). if let Some(type_value) = obj.get_mut("type") @@ -126,7 +134,6 @@ fn adapt_to_json_schema_subset(json: &mut Value) -> Result<()> { { *type_value = first_type; } - if matches!(obj.get("description"), Some(Value::String(_))) && !obj.contains_key("type") && !(obj.contains_key("anyOf") @@ -141,7 +148,7 @@ fn adapt_to_json_schema_subset(json: &mut Value) -> Result<()> { { let subschemas_clone = subschemas.clone(); obj.remove("oneOf"); - obj.insert("anyOf".to_string(), subschemas_clone); + push_any_of_constraint(obj, subschemas_clone); } for (_, value) in obj.iter_mut() { @@ -157,11 +164,278 @@ fn adapt_to_json_schema_subset(json: &mut Value) -> Result<()> { Ok(()) } +fn convert_null_in_types_to_nullable(obj: &mut Map) { + let mut nullable_found_in_type = false; + + if let Some(type_entry) = obj.get_mut("type") { + if let Some(types) = type_entry.as_array_mut() { + let mut had_null_type = false; + types.retain(|t| { + if t.as_str() == Some("null") { + had_null_type = true; + false + } else { + true + } + }); + + if had_null_type { + nullable_found_in_type = true; + if types.len() == 1 { + *type_entry = types.remove(0); + } else if types.is_empty() { + obj.remove("type"); + } + } + } else if let Some(type_str) = type_entry.as_str() { + if type_str == "null" { + nullable_found_in_type = true; + obj.remove("type"); + } + } + } + if nullable_found_in_type { + obj.insert("nullable".to_string(), Value::Bool(true)); + } +} + +fn convert_types_to_any_of_defs(obj: &mut Map) { + if let Some(type_entry) = obj.get_mut("type") { + if let Some(types) = type_entry.as_array_mut() { + if types.len() > 1 { + let remaining_types = std::mem::take(types); + let mut any_of_schemas = Vec::new(); + for t in remaining_types { + any_of_schemas.push(json!({"type": t})); + } + obj.remove("type"); + push_any_of_constraint(obj, Value::Array(any_of_schemas)); + } + } + } +} + +fn push_any_of_constraint(obj: &mut Map, any_of_schemas: Value) { + if let Some(existing_any_of) = obj.remove("anyOf") { + let mut all_of = obj + .remove("allOf") + .and_then(|v| v.as_array().cloned()) + .unwrap_or_default(); + if all_of.is_empty() { + all_of.push(json!({"anyOf": existing_any_of})); + } + all_of.push(json!({"anyOf": any_of_schemas})); + obj.insert("allOf".to_string(), Value::Array(all_of)); + } else if let Some(all_of) = obj.get_mut("allOf").and_then(|v| v.as_array_mut()) { + all_of.push(json!({"anyOf": any_of_schemas})); + } else { + obj.insert("anyOf".to_string(), any_of_schemas); + } +} + #[cfg(test)] mod tests { use super::*; use serde_json::json; + #[test] + fn test_convert_null_in_types_to_nullable() { + // ["string", "null"] -> "string", nullable: true + let mut obj = json!({"type": ["string", "null"]}) + .as_object_mut() + .unwrap() + .to_owned(); + convert_null_in_types_to_nullable(&mut obj); + assert_eq!( + obj, + json!({"type": "string", "nullable": true}) + .as_object() + .unwrap() + .to_owned() + ); + + // "null" -> nullable: true + let mut obj = json!({"type": "null"}).as_object_mut().unwrap().to_owned(); + convert_null_in_types_to_nullable(&mut obj); + assert_eq!( + obj, + json!({"nullable": true}).as_object().unwrap().to_owned() + ); + + // ["string", "number", "null"] -> ["string", "number"], nullable: true (anyOf handled elsewhere) + let mut obj = json!({"type": ["string", "number", "null"]}) + .as_object_mut() + .unwrap() + .to_owned(); + convert_null_in_types_to_nullable(&mut obj); + assert_eq!( + obj, + json!({"type": ["string", "number"], "nullable": true}) + .as_object() + .unwrap() + .to_owned() + ); + + // "string" (no change, not nullable) + let mut obj = json!({"type": "string"}) + .as_object_mut() + .unwrap() + .to_owned(); + convert_null_in_types_to_nullable(&mut obj); + assert_eq!( + obj, + json!({"type": "string"}).as_object().unwrap().to_owned() + ); + + // ["string", "number"] (no change, not nullable) + let mut obj = json!({"type": ["string", "number"]}) + .as_object_mut() + .unwrap() + .to_owned(); + convert_null_in_types_to_nullable(&mut obj); + assert_eq!( + obj, + json!({"type": ["string", "number"]}) + .as_object() + .unwrap() + .to_owned() + ); + + // object with other properties, ["boolean", "null"] + let mut obj = json!({ + "description": "A test field", + "type": ["boolean", "null"] + }) + .as_object_mut() + .unwrap() + .to_owned(); + convert_null_in_types_to_nullable(&mut obj); + assert_eq!( + obj, + json!({ + "description": "A test field", + "type": "boolean", + "nullable": true + }) + .as_object() + .unwrap() + .to_owned() + ); + } + + #[test] + fn test_convert_types_to_any_of_defs() { + // ["string", "number"] -> anyOf with string and number + let mut obj = json!({"type": ["string", "number"]}) + .as_object_mut() + .unwrap() + .to_owned(); + convert_types_to_any_of_defs(&mut obj); + assert_eq!( + obj, + json!({ + "anyOf": [ + {"type": "string"}, + {"type": "number"} + ] + }) + .as_object() + .unwrap() + .to_owned() + ); + + // "string" (no change) + let mut obj = json!({"type": "string"}) + .as_object_mut() + .unwrap() + .to_owned(); + convert_types_to_any_of_defs(&mut obj); + assert_eq!( + obj, + json!({"type": "string"}).as_object().unwrap().to_owned() + ); + + // object with other properties, ["string", "number"] + let mut obj = json!({ + "description": "A test field", + "type": ["string", "number"] + }) + .as_object_mut() + .unwrap() + .to_owned(); + convert_types_to_any_of_defs(&mut obj); + assert_eq!( + obj, + json!({ + "description": "A test field", + "anyOf": [ + {"type": "string"}, + {"type": "number"} + ] + }) + .as_object() + .unwrap() + .to_owned() + ); + + // anyOf already present (no change) + let mut obj = json!({ + "anyOf": [ + {"type": "string"}, + {"type": "number"} + ] + }) + .as_object_mut() + .unwrap() + .to_owned(); + convert_types_to_any_of_defs(&mut obj); + assert_eq!( + obj, + json!({ + "anyOf": [ + {"type": "string"}, + {"type": "number"} + ] + }) + .as_object() + .unwrap() + .to_owned() + ); + + // both type array and anyOf present + let mut obj = json!({ + "type": ["string", "number"], + "anyOf": [ + {"format": "email"} + ] + }) + .as_object_mut() + .unwrap() + .to_owned(); + convert_types_to_any_of_defs(&mut obj); + assert_eq!( + obj, + json!({ + "allOf": [ + { + "anyOf": [ + {"format": "email"} + ] + }, + { + "anyOf": [ + {"type": "string"}, + {"type": "number"} + ] + } + ] + }) + .as_object() + .unwrap() + .to_owned() + ); + } + #[test] fn test_transform_adds_type_when_missing() { let mut json = json!({ @@ -259,6 +533,69 @@ mod tests { ); } + #[test] + fn test_transform_null_in_any_of() { + let mut json = json!({ + "anyOf": [ + { "type": "string" }, + { "type": "null" } + ] + }); + + adapt_to_json_schema_subset(&mut json).unwrap(); + + assert_eq!( + json, + json!({ + "anyOf": [ + { "type": "string" }, + { "nullable": true } + ] + }) + ); + } + + #[test] + fn test_transform_conflicting_any_of_sources() { + let mut json = json!({ + "type": ["string", "number"], + "anyOf": [ + { "minLength": 5 } + ], + "oneOf": [ + { "pattern": "^a" }, + { "pattern": "^b" } + ] + }); + + adapt_to_json_schema_subset(&mut json).unwrap(); + + assert_eq!( + json, + json!({ + "allOf": [ + { + "anyOf": [ + { "minLength": 5 }, + ] + }, + { + "anyOf": [ + {"type": "string"}, + {"type": "number"} + ] + }, + { + "anyOf": [ + { "pattern": "^a" }, + { "pattern": "^b" } + ] + } + ] + }) + ); + } + #[test] fn test_transform_one_of_to_any_of() { let mut json = json!({ @@ -308,8 +645,8 @@ mod tests { "nested": { "anyOf": [ { "type": "string" }, - { "type": "null" } - ] + { "nullable": true } + ], } } }) @@ -340,12 +677,16 @@ mod tests { "type": "object", "properties": { "projectSlugOrId": { - "type": "string", + "anyOf": [ + {"type": "string"}, + {"type": "number"} + ], "description": "Project slug or numeric ID" }, "optionalName": { "type": "string", - "description": "An optional name" + "description": "An optional name", + "nullable": true } } }) From a949cabb327ddab4427ad753b172c6653845711f Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Tue, 19 May 2026 20:04:29 +0300 Subject: [PATCH 046/289] Fix crash in manipulate_text on multibuffers (#57165) In `Editor::manipulate_text`, we computed selection boundaries for the updated text assuming the requested edit would be applied exactly. This is not always true. As a result, we could produce an invalid selection range and panic. This change replaces manual selection boundary computation with anchors. It also skips edits when `new_text == old_text`. Closes FR-10. Release Notes: - N/A --- crates/editor/src/editor.rs | 20 +++++------ crates/editor/src/editor_tests.rs | 55 +++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 1b1345d4a28..1a470ebeb12 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -6771,7 +6771,6 @@ impl Editor { let mut new_selections = Vec::new(); let mut edits = Vec::new(); - let mut selection_adjustment = 0isize; for selection in self.selections.all_adjusted(&self.display_snapshot(cx)) { let selection_is_empty = selection.is_empty(); @@ -6786,23 +6785,24 @@ impl Editor { ) }; - let text = buffer.text_for_range(start..end).collect::(); - let old_length = text.len() as isize; - let text = callback(&text); + let old_text = buffer.text_for_range(start..end).collect::(); + let new_text = callback(&old_text); new_selections.push(Selection { - start: MultiBufferOffset((start.0 as isize - selection_adjustment) as usize), - end: MultiBufferOffset( - ((start.0 + text.len()) as isize - selection_adjustment) as usize, - ), + start: buffer.anchor_before(start), + end: buffer.anchor_after(end), goal: SelectionGoal::None, id: selection.id, reversed: selection.reversed, }); - selection_adjustment += old_length - text.len() as isize; + if new_text != old_text { + edits.push((start..end, new_text)); + } + } - edits.push((start..end, text)); + if edits.is_empty() { + return; } self.transact(window, cx, |this, window, cx| { diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 61410ee677c..257122307e6 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -6898,6 +6898,61 @@ async fn test_convert_to_base64(cx: &mut TestAppContext) { "}); } +#[gpui::test] +fn test_manipulate_text_handles_cross_excerpt_edit_that_applies_differently( + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let buffer_1 = cx.new(|cx| { + let mut buffer = Buffer::local("ab", cx); + // The selected multibuffer range starts in this excerpt, but edits to + // it are skipped because the underlying buffer is read-only. + buffer.set_capability(language::Capability::ReadOnly, cx); + buffer + }); + let buffer_2 = cx.new(|cx| Buffer::local("cd", cx)); + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(ReadWrite); + multibuffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer_1.clone(), + [Point::new(0, 0)..Point::new(0, 2)], + 0, + cx, + ); + multibuffer.set_excerpts_for_path( + PathKey::sorted(1), + buffer_2.clone(), + [Point::new(0, 0)..Point::new(0, 2)], + 0, + cx, + ); + multibuffer + }); + + cx.add_window(|window, cx| { + let mut editor = build_editor(multibuffer, window, cx); + let len = editor.buffer().read(cx).len(cx); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges([MultiBufferOffset(0)..len]) + }); + + // No-op transformations should not be sent through `MultiBuffer::edit`. + editor.manipulate_text(window, cx, |text| text.to_string()); + assert_eq!(buffer_1.read(cx).text(), "ab"); + assert_eq!(buffer_2.read(cx).text(), "cd"); + + // A real replacement can apply differently than requested; selection + // remapping should follow the actual edit instead of predicted offsets. + editor.manipulate_text(window, cx, |_| "replacement".to_string()); + assert_eq!(buffer_1.read(cx).text(), "ab"); + assert_eq!(buffer_2.read(cx).text(), ""); + + editor + }); +} + #[gpui::test] async fn test_manipulate_text(cx: &mut TestAppContext) { init_test(cx, |_| {}); From c0596fade73036c6d2ab6f8a7caeac6722739e65 Mon Sep 17 00:00:00 2001 From: alkinun Date: Tue, 19 May 2026 20:20:24 +0300 Subject: [PATCH 047/289] markdown: Fix escaping non-ASCII chars (#55782) Fixes #55704 The `escape` function in `crates/markdown/src/markdown.rs` was calling `c as u8` on the `char`s before passing to `MarkdownEscaper::next()`. This strips non ASCII Unicode codepoints down to just their low 8 bits which might be in the ASCII punctuation range and thus cause an extra backslash to be added in front of these non ASCII chars. Release Notes: - Fixed a bug where non-ASCII chars in diagnostic messages were incorrectly rendered with spurious `\` characters --- crates/markdown/src/markdown.rs | 52 +++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 69feee416da..44e87f55677 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -401,12 +401,12 @@ enum EscapeAction { } impl EscapeAction { - fn output_len(&self) -> usize { + fn output_len(&self, c: char) -> usize { match self { - Self::PassThrough => 1, + Self::PassThrough => c.len_utf8(), Self::Nbsp(count) => count * '\u{00A0}'.len_utf8(), Self::DoubleNewline => 2, - Self::PrefixBackslash => 2, + Self::PrefixBackslash => '\\'.len_utf8() + c.len_utf8(), } } @@ -431,8 +431,6 @@ impl EscapeAction { } } -// Valid to operate on raw bytes since multi-byte UTF-8 -// sequences never contain ASCII-range bytes. struct MarkdownEscaper { in_leading_whitespace: bool, } @@ -446,21 +444,21 @@ impl MarkdownEscaper { } } - fn next(&mut self, byte: u8) -> EscapeAction { - let action = if self.in_leading_whitespace && byte == b'\t' { + fn next(&mut self, c: char) -> EscapeAction { + let action = if self.in_leading_whitespace && c == '\t' { EscapeAction::Nbsp(Self::TAB_SIZE) - } else if self.in_leading_whitespace && byte == b' ' { + } else if self.in_leading_whitespace && c == ' ' { EscapeAction::Nbsp(1) - } else if byte == b'\n' { + } else if c == '\n' { EscapeAction::DoubleNewline - } else if byte.is_ascii_punctuation() { + } else if c.is_ascii_punctuation() { EscapeAction::PrefixBackslash } else { EscapeAction::PassThrough }; self.in_leading_whitespace = - byte == b'\n' || (self.in_leading_whitespace && (byte == b' ' || byte == b'\t')); + c == '\n' || (self.in_leading_whitespace && (c == ' ' || c == '\t')); action } } @@ -675,7 +673,7 @@ impl Markdown { pub fn escape(s: &str) -> Cow<'_, str> { let output_len: usize = { let mut escaper = MarkdownEscaper::new(); - s.bytes().map(|byte| escaper.next(byte).output_len()).sum() + s.chars().map(|c| escaper.next(c).output_len(c)).sum() }; if output_len == s.len() { @@ -685,7 +683,7 @@ impl Markdown { let mut escaper = MarkdownEscaper::new(); let mut output = String::with_capacity(output_len); for c in s.chars() { - escaper.next(c as u8).write_to(c, &mut output); + escaper.next(c).write_to(c, &mut output); } output.into() } @@ -3931,6 +3929,30 @@ mod tests { ); } + #[test] + fn test_escape_non_ascii() { + // Cyrillic characters should not have backslashes added before them, + // but ASCII punctuation should still be escaped. + assert_eq!(Markdown::escape("ΠŸΡ€ΠΈΠ²Π΅Ρ‚, ΠΌΠΈΡ€"), r"ΠŸΡ€ΠΈΠ²Π΅Ρ‚\, ΠΌΠΈΡ€"); + // Test with markdown special characters mixed in + assert_eq!(Markdown::escape("ΠŸΡ€ΠΈΠ²Π΅Ρ‚, *ΠΌΠΈΡ€*"), r"ΠŸΡ€ΠΈΠ²Π΅Ρ‚\, \*ΠΌΠΈΡ€\*"); + // Test with the exact example from the issue (single quotes are also ASCII punctuation) + assert_eq!( + Markdown::escape("ΠžΡ‚ΡΡƒΡ‚ΡΡ‚Π²ΡƒΠ΅Ρ‚ ΠΏΡ€ΠΎΠ±Π΅Π» справа ΠΎΡ‚ ','"), + r"ΠžΡ‚ΡΡƒΡ‚ΡΡ‚Π²ΡƒΠ΅Ρ‚ ΠΏΡ€ΠΎΠ±Π΅Π» справа ΠΎΡ‚ \'\,\'" + ); + // Test more non-ASCII scripts + assert_eq!( + Markdown::escape("こんにけは *world*"), + r"こんにけは \*world\*" + ); + assert_eq!(Markdown::escape("Ψ§Ω„ΨΉΨ±Ψ¨ΩŠΩ‘Ψ© [link]"), r"Ψ§Ω„ΨΉΨ±Ψ¨ΩŠΩ‘Ψ© \[link\]"); + assert_eq!(Markdown::escape("Ελληνικά _text_"), r"Ελληνικά \_text\_"); + assert_eq!(Markdown::escape("Χ’Χ‘Χ¨Χ™Χͺ `code`"), r"Χ’Χ‘Χ¨Χ™Χͺ \`code\`"); + // Non-ASCII followed by ASCII punctuation + assert_eq!(Markdown::escape("Test: тСст"), r"Test\: тСст"); + } + fn has_code_block(markdown: &str) -> bool { let parsed_data = parse_markdown_with_options(markdown, false, false); parsed_data @@ -3959,12 +3981,12 @@ mod tests { ]; for input in cases { let mut escaper = MarkdownEscaper::new(); - let precomputed: usize = input.bytes().map(|b| escaper.next(b).output_len()).sum(); + let precomputed: usize = input.chars().map(|c| escaper.next(c).output_len(c)).sum(); let mut escaper = MarkdownEscaper::new(); let mut output = String::new(); for c in input.chars() { - escaper.next(c as u8).write_to(c, &mut output); + escaper.next(c).write_to(c, &mut output); } assert_eq!(precomputed, output.len(), "length mismatch for {:?}", input); From 0d832bc6d5498f545c5f05ba1f1fc84285434eb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Houl=C3=A9?= <13155277+tomhoule@users.noreply.github.com> Date: Tue, 19 May 2026 19:45:07 +0200 Subject: [PATCH 048/289] Implement MCP OAuth client preregistration (#52900) In the interactive MCP OAuth flow, the MCP client registers itself with the authorization in one of three ways: - Client ID Metadata Document aka CIMD (recommended default). This is already implemented: https://zed.dev/oauth/client-metadata.json. - Dynamic Client Registration (DCR). This is the traditional method. Also already implemented in Zed. - Pre-registration: the client is registered out of band, typically in the IdP or SaaS provider's UI. You get a client id and maybe a client secret, that have to be provided by the MCP client when it wants to exchange an access token. This is what this pull request is about. This PR has two main parts: - Allow users to configure a client id and optional client secret for an MCP server in their configuration, under a new `oauth` key, and take it into account - Make the MCP server state and the configuration modal aware of the intermediate states (client secret missing) and error cases stemming from client pre-registration. The client secret can be stored either in the system keychain or in plain text in the MCP server configuration. The UI tries to steer user towards the more secure option: the keychain. Screenshot 2026-04-10 at 16 48 06 Screenshot 2026-04-10 at 16 47 07 Screenshot 2026-04-10 at 16 47 23 Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes https://github.com/issues/assigned?issue=zed-industries%7Czed%7C52198 **Note for the reviewer: I know how busy the AI team is at the moment so please treat this as low priority, we don't have signal that this is a highly desired feature. It's a rather large PR, so I'm happy to pair review / walk through it.** Release Notes: - Added support for OAuth client pre-registration (client id, client secret) to the built-in MCP client. --- .../src/tools/context_server_registry.rs | 3 +- crates/agent_servers/src/acp.rs | 1 + crates/agent_ui/src/agent_configuration.rs | 51 ++- .../configure_context_server_modal.rs | 368 ++++++++++++++++-- crates/context_server/src/oauth.rs | 101 +++-- crates/project/src/context_server_store.rs | 284 +++++++++++++- crates/project/src/project_settings.rs | 28 ++ .../tests/integration/context_server_store.rs | 4 + crates/settings_content/src/project.rs | 18 + .../ui/src/components/ai/ai_setting_item.rs | 4 +- 10 files changed, 797 insertions(+), 65 deletions(-) diff --git a/crates/agent/src/tools/context_server_registry.rs b/crates/agent/src/tools/context_server_registry.rs index 6c0e8d31557..d9dc972e24f 100644 --- a/crates/agent/src/tools/context_server_registry.rs +++ b/crates/agent/src/tools/context_server_registry.rs @@ -261,7 +261,8 @@ impl ContextServerRegistry { } ContextServerStatus::Stopped | ContextServerStatus::Error(_) - | ContextServerStatus::AuthRequired => { + | ContextServerStatus::AuthRequired + | ContextServerStatus::ClientSecretRequired { .. } => { if let Some(registered_server) = self.registered_servers.remove(server_id) { if !registered_server.tools.is_empty() { cx.emit(ContextServerRegistryEvent::ToolsChanged); diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index ff5519b7240..3a718c7a9e8 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -3844,6 +3844,7 @@ fn mcp_servers_for_project(project: &Entity, cx: &App) -> Vec Some(acp::McpServer::Http( acp::McpServerHttp::new(id.0.to_string(), url.to_string()).headers( headers diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 67d21211026..eb6ea3e81fc 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -664,8 +664,14 @@ impl AgentConfiguration { None }; let auth_required = matches!(server_status, ContextServerStatus::AuthRequired); + let client_secret_required = matches!( + server_status, + ContextServerStatus::ClientSecretRequired { .. } + ); let authenticating = matches!(server_status, ContextServerStatus::Authenticating); let context_server_store = self.context_server_store.clone(); + let workspace = self.workspace.clone(); + let language_registry = self.language_registry.clone(); let tool_count = self .context_server_registry @@ -685,6 +691,9 @@ impl AgentConfiguration { ContextServerStatus::Error(_) => AiSettingItemStatus::Error, ContextServerStatus::Stopped => AiSettingItemStatus::Stopped, ContextServerStatus::AuthRequired => AiSettingItemStatus::AuthRequired, + ContextServerStatus::ClientSecretRequired { .. } => { + AiSettingItemStatus::ClientSecretRequired + } ContextServerStatus::Authenticating => AiSettingItemStatus::Authenticating, }; @@ -886,7 +895,7 @@ impl AgentConfiguration { ), ) .child( - Button::new("error-logout-server", "Authenticate") + Button::new("authenticate-server", "Authenticate") .style(ButtonStyle::Outlined) .label_size(LabelSize::Small) .on_click({ @@ -900,6 +909,46 @@ impl AgentConfiguration { ) .into_any_element(), ) + } else if client_secret_required { + Some( + feedback_base_container() + .child( + h_flex() + .pr_4() + .min_w_0() + .w_full() + .gap_2() + .child( + Icon::new(IconName::Info) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child( + Label::new("Enter a client secret to connect this server") + .color(Color::Muted) + .size(LabelSize::Small), + ), + ) + .child( + Button::new("enter-client-secret", "Enter Client Secret") + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .on_click({ + let context_server_id = context_server_id.clone(); + move |_event, window, cx| { + ConfigureContextServerModal::show_modal_for_existing_server( + context_server_id.clone(), + language_registry.clone(), + workspace.clone(), + window, + cx, + ) + .detach(); + } + }), + ) + .into_any_element(), + ) } else if authenticating { Some( h_flex() diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs index 48d01e506bf..5ccc901b4a4 100644 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs +++ b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs @@ -17,7 +17,7 @@ use project::{ ContextServerStatus, ContextServerStore, ServerStatusChangedEvent, registry::ContextServerDescriptorRegistry, }, - project_settings::{ContextServerSettings, ProjectSettings}, + project_settings::{ContextServerSettings, OAuthClientSettings, ProjectSettings}, worktree_store::WorktreeStore, }; use serde::Deserialize; @@ -43,7 +43,9 @@ enum ConfigurationTarget { id: ContextServerId, url: String, headers: HashMap, + oauth: Option, }, + Extension { id: ContextServerId, repository_url: Option, @@ -121,15 +123,17 @@ impl ConfigurationSource { id, url, headers: auth, + oauth, } => ConfigurationSource::Existing { editor: create_editor( - context_server_http_input(Some((id, url, auth))), + context_server_http_input(Some((id, url, auth, oauth))), jsonc_language, window, cx, ), is_http: true, }, + ConfigurationTarget::Extension { id, repository_url, @@ -168,7 +172,7 @@ impl ConfigurationSource { ConfigurationSource::New { editor, is_http } | ConfigurationSource::Existing { editor, is_http } => { if *is_http { - parse_http_input(&editor.read(cx).text(cx)).map(|(id, url, auth)| { + parse_http_input(&editor.read(cx).text(cx)).map(|(id, url, auth, oauth)| { ( id, ContextServerSettings::Http { @@ -176,6 +180,7 @@ impl ConfigurationSource { url, headers: auth, timeout: None, + oauth, }, ) }) @@ -256,11 +261,16 @@ fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand) } fn context_server_http_input( - existing: Option<(ContextServerId, String, HashMap)>, + existing: Option<( + ContextServerId, + String, + HashMap, + Option, + )>, ) -> String { - let (name, url, headers) = match existing { - Some((id, url, headers)) => { - let header = if headers.is_empty() { + let (name, url, headers, oauth) = match existing { + Some((id, url, headers, oauth)) => { + let headers = if headers.is_empty() { r#"// "Authorization": "Bearer "#.to_string() } else { let json = serde_json::to_string_pretty(&headers).unwrap(); @@ -274,15 +284,48 @@ fn context_server_http_input( .map(|line| format!(" {}", line)) .collect::() }; - (id.0.to_string(), url, header) + (id.0.to_string(), url, headers, oauth) } None => ( "some-remote-server".to_string(), "https://example.com/mcp".to_string(), r#"// "Authorization": "Bearer "#.to_string(), + None, ), }; + let oauth = oauth.map_or_else( + || { + r#" + /// Uncomment to use a pre-registered OAuth client. You can include the client secret here as well, otherwise it will be prompted interactively and saved in the system keychain. + // "oauth": { + // "client_id": "your-client-id", + // },"# + .to_string() + }, + + |oauth| { + let mut lines = vec![ + String::from("\n \"oauth\": {"), + + format!(" \"client_id\": {},", serde_json::to_string(&oauth.client_id).unwrap()), + ]; + if let Some(client_secret) = oauth.client_secret { + lines.push(format!( + " \"client_secret\": {}", + serde_json::to_string(&client_secret).unwrap() + )); + } else { + lines.push(String::from( + " /// Optional client secret for confidential clients\n // \"client_secret\": \"your-client-secret\"", + )); + } + lines.push(String::from(" },")); + + lines.join("\n") + }, + ); + format!( r#"{{ /// Configure an MCP server that you connect to over HTTP @@ -290,7 +333,7 @@ fn context_server_http_input( /// The name of your remote MCP server "{name}": {{ /// The URL of the remote MCP server - "url": "{url}", + "url": "{url}",{oauth} "headers": {{ /// Any headers to send along {headers} @@ -300,12 +343,21 @@ fn context_server_http_input( ) } -fn parse_http_input(text: &str) -> Result<(ContextServerId, String, HashMap)> { +fn parse_http_input( + text: &str, +) -> Result<( + ContextServerId, + String, + HashMap, + Option, +)> { #[derive(Deserialize)] struct Temp { url: String, #[serde(default)] headers: HashMap, + #[serde(default)] + oauth: Option, } let value: HashMap = serde_json_lenient::from_str(text)?; if value.len() != 1 { @@ -314,7 +366,12 @@ fn parse_http_input(text: &str) -> Result<(ContextServerId, String, HashMap, + }, + Authenticating { + server_id: ContextServerId, + }, Error(SharedString), } @@ -361,10 +426,47 @@ pub struct ConfigureContextServerModal { state: State, original_server_id: Option, scroll_handle: ScrollHandle, + secret_editor: Entity, _auth_subscription: Option, } impl ConfigureContextServerModal { + fn initial_state( + context_server_store: &Entity, + target: &ConfigurationTarget, + cx: &App, + ) -> State { + let Some(server_id) = (match target { + ConfigurationTarget::Existing { id, .. } + | ConfigurationTarget::ExistingHttp { id, .. } + | ConfigurationTarget::Extension { id, .. } => Some(id), + ConfigurationTarget::New => None, + }) else { + return State::Idle; + }; + + match context_server_store.read(cx).status_for_server(server_id) { + Some(ContextServerStatus::AuthRequired) => State::AuthRequired { + server_id: server_id.clone(), + }, + Some(ContextServerStatus::ClientSecretRequired { error }) => { + State::ClientSecretRequired { + server_id: server_id.clone(), + error: error.map(SharedString::from), + } + } + Some(ContextServerStatus::Authenticating) => State::Authenticating { + server_id: server_id.clone(), + }, + Some(ContextServerStatus::Error(error)) => State::Error(error.into()), + + Some(ContextServerStatus::Starting) + | Some(ContextServerStatus::Running) + | Some(ContextServerStatus::Stopped) + | None => State::Idle, + } + } + pub fn register( workspace: &mut Workspace, language_registry: Arc, @@ -426,12 +528,14 @@ impl ConfigureContextServerModal { url, headers, timeout: _, - .. + oauth, } => Some(ConfigurationTarget::ExistingHttp { id: server_id, url, headers, + oauth, }), + ContextServerSettings::Extension { .. } => { match workspace .update(cx, |workspace, cx| { @@ -468,9 +572,10 @@ impl ConfigureContextServerModal { let workspace_handle = cx.weak_entity(); let context_server_store = workspace.project().read(cx).context_server_store(); workspace.toggle_modal(window, cx, |window, cx| Self { - context_server_store, + context_server_store: context_server_store.clone(), workspace: workspace_handle, - state: State::Idle, + state: Self::initial_state(&context_server_store, &target, cx), + original_server_id: match &target { ConfigurationTarget::Existing { id, .. } => Some(id.clone()), ConfigurationTarget::ExistingHttp { id, .. } => Some(id.clone()), @@ -485,6 +590,16 @@ impl ConfigureContextServerModal { cx, ), scroll_handle: ScrollHandle::new(), + secret_editor: cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text( + "Enter client secret (leave empty for public clients)", + window, + cx, + ); + editor.set_masked(true, cx); + editor + }), _auth_subscription: None, }) }) @@ -497,13 +612,12 @@ impl ConfigureContextServerModal { } fn confirm(&mut self, _: &menu::Confirm, cx: &mut Context) { - if matches!( - self.state, - State::Waiting | State::AuthRequired { .. } | State::Authenticating { .. } - ) { + if matches!(self.state, State::Waiting | State::Authenticating { .. }) { return; } + self._auth_subscription = None; + self.state = State::Idle; let Some(workspace) = self.workspace.upgrade() else { return; @@ -519,7 +633,7 @@ impl ConfigureContextServerModal { self.state = State::Waiting; - let existing_server = self.context_server_store.read(cx).get_running_server(&id); + let existing_server = self.context_server_store.read(cx).get_server(&id); if existing_server.is_some() { self.context_server_store.update(cx, |store, cx| { store.stop_server(&id, cx).log_err(); @@ -542,6 +656,13 @@ impl ConfigureContextServerModal { this.state = State::AuthRequired { server_id: id }; cx.notify(); } + Ok(ContextServerStatus::ClientSecretRequired { error }) => { + this.state = State::ClientSecretRequired { + server_id: id, + error: error.map(SharedString::from), + }; + cx.notify(); + } Err(err) => { this.set_error(err, cx); } @@ -581,13 +702,33 @@ impl ConfigureContextServerModal { cx.emit(DismissEvent); } + fn cancel_authentication(&mut self, server_id: &ContextServerId, cx: &mut Context) { + self._auth_subscription = None; + self.context_server_store.update(cx, |store, cx| { + store.stop_server(server_id, cx).log_err(); + }); + self.state = State::Idle; + cx.notify(); + } + fn authenticate(&mut self, server_id: ContextServerId, cx: &mut Context) { self.context_server_store.update(cx, |store, cx| { store.authenticate_server(&server_id, cx).log_err(); }); + self.await_auth_outcome(server_id, cx); + } + fn submit_client_secret(&mut self, server_id: ContextServerId, cx: &mut Context) { + let secret = self.secret_editor.read(cx).text(cx); + self.context_server_store.update(cx, |store, cx| { + store.submit_client_secret(&server_id, secret, cx).log_err(); + }); + self.await_auth_outcome(server_id, cx); + } + + fn await_auth_outcome(&mut self, server_id: ContextServerId, cx: &mut Context) { self.state = State::Authenticating { - _server_id: server_id.clone(), + server_id: server_id.clone(), }; self._auth_subscription = Some(cx.subscribe( @@ -610,6 +751,14 @@ impl ConfigureContextServerModal { }; cx.notify(); } + ContextServerStatus::ClientSecretRequired { error } => { + this._auth_subscription = None; + this.state = State::ClientSecretRequired { + server_id: event.server_id.clone(), + error: error.clone().map(SharedString::from), + }; + cx.notify(); + } ContextServerStatus::Error(error) => { this._auth_subscription = None; this.set_error(error.clone(), cx); @@ -814,10 +963,7 @@ impl ConfigureContextServerModal { fn render_modal_footer(&self, cx: &mut Context) -> ModalFooter { let focus_handle = self.focus_handle(cx); - let is_busy = matches!( - self.state, - State::Waiting | State::AuthRequired { .. } | State::Authenticating { .. } - ); + let is_busy = matches!(self.state, State::Waiting | State::Authenticating { .. }); ModalFooter::new() .start_slot::