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 001/105] 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 002/105] 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 003/105] 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 004/105] 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 005/105] 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 006/105] 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 007/105] 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 008/105] 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 009/105] 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 010/105] 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 011/105] 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 012/105] 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 013/105] 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 014/105] 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 015/105] 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 016/105] 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 017/105] 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 018/105] 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 019/105] 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 020/105] 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 021/105] 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 022/105] 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 023/105] 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 024/105] 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 025/105] 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 026/105] 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 027/105] 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 028/105] 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 029/105] 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 030/105] 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 031/105] 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 032/105] 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 033/105] 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::