From 13a5598008bcefb16ec072db2acaa34bea33346b Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Wed, 20 Aug 2025 15:13:52 -0400 Subject: [PATCH 01/89] v0.201.x preview --- crates/zed/RELEASE_CHANNEL | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/zed/RELEASE_CHANNEL b/crates/zed/RELEASE_CHANNEL index 38f8e886e1..4de2f126df 100644 --- a/crates/zed/RELEASE_CHANNEL +++ b/crates/zed/RELEASE_CHANNEL @@ -1 +1 @@ -dev +preview \ No newline at end of file From e9a1404329b6fb721e778a918033367788d35e88 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 20 Aug 2025 21:17:07 +0200 Subject: [PATCH 02/89] agent2: Clean up tool descriptions (#36619) schemars was passing along the newlines from the doc comments. This should make these closer to the markdown file versions we had in the old agent. Release Notes: - N/A --- crates/agent2/src/tools/copy_path_tool.rs | 15 ++++----------- .../agent2/src/tools/create_directory_tool.rs | 7 ++----- crates/agent2/src/tools/delete_path_tool.rs | 3 +-- crates/agent2/src/tools/edit_file_tool.rs | 19 ++++++------------- crates/agent2/src/tools/find_path_tool.rs | 1 - crates/agent2/src/tools/grep_tool.rs | 3 +-- .../agent2/src/tools/list_directory_tool.rs | 6 ++---- crates/agent2/src/tools/move_path_tool.rs | 9 +++------ crates/agent2/src/tools/open_tool.rs | 10 +++------- crates/agent2/src/tools/read_file_tool.rs | 5 +---- crates/agent2/src/tools/thinking_tool.rs | 3 +-- crates/agent2/src/tools/web_search_tool.rs | 2 +- 12 files changed, 25 insertions(+), 58 deletions(-) diff --git a/crates/agent2/src/tools/copy_path_tool.rs b/crates/agent2/src/tools/copy_path_tool.rs index f973b86990..4b40a9842f 100644 --- a/crates/agent2/src/tools/copy_path_tool.rs +++ b/crates/agent2/src/tools/copy_path_tool.rs @@ -8,16 +8,11 @@ use serde::{Deserialize, Serialize}; use std::sync::Arc; use util::markdown::MarkdownInlineCode; -/// Copies a file or directory in the project, and returns confirmation that the -/// copy succeeded. -/// +/// Copies a file or directory in the project, and returns confirmation that the copy succeeded. /// Directory contents will be copied recursively (like `cp -r`). /// -/// This tool should be used when it's desirable to create a copy of a file or -/// directory without modifying the original. It's much more efficient than -/// doing this by separately reading and then writing the file or directory's -/// contents, so this tool should be preferred over that approach whenever -/// copying is the goal. +/// This tool should be used when it's desirable to create a copy of a file or directory without modifying the original. +/// It's much more efficient than doing this by separately reading and then writing the file or directory's contents, so this tool should be preferred over that approach whenever copying is the goal. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct CopyPathToolInput { /// The source path of the file or directory to copy. @@ -33,12 +28,10 @@ pub struct CopyPathToolInput { /// You can copy the first file by providing a source_path of "directory1/a/something.txt" /// pub source_path: String, - /// The destination path where the file or directory should be copied to. /// /// - /// To copy "directory1/a/something.txt" to "directory2/b/copy.txt", - /// provide a destination_path of "directory2/b/copy.txt" + /// To copy "directory1/a/something.txt" to "directory2/b/copy.txt", provide a destination_path of "directory2/b/copy.txt" /// pub destination_path: String, } diff --git a/crates/agent2/src/tools/create_directory_tool.rs b/crates/agent2/src/tools/create_directory_tool.rs index c173c5ae67..7720eb3595 100644 --- a/crates/agent2/src/tools/create_directory_tool.rs +++ b/crates/agent2/src/tools/create_directory_tool.rs @@ -9,12 +9,9 @@ use util::markdown::MarkdownInlineCode; use crate::{AgentTool, ToolCallEventStream}; -/// Creates a new directory at the specified path within the project. Returns -/// confirmation that the directory was created. +/// Creates a new directory at the specified path within the project. Returns confirmation that the directory was created. /// -/// This tool creates a directory and all necessary parent directories (similar -/// to `mkdir -p`). It should be used whenever you need to create new -/// directories within the project. +/// This tool creates a directory and all necessary parent directories (similar to `mkdir -p`). It should be used whenever you need to create new directories within the project. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct CreateDirectoryToolInput { /// The path of the new directory. diff --git a/crates/agent2/src/tools/delete_path_tool.rs b/crates/agent2/src/tools/delete_path_tool.rs index e013b3a3e7..c281f1b5b6 100644 --- a/crates/agent2/src/tools/delete_path_tool.rs +++ b/crates/agent2/src/tools/delete_path_tool.rs @@ -9,8 +9,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::sync::Arc; -/// Deletes the file or directory (and the directory's contents, recursively) at -/// the specified path in the project, and returns confirmation of the deletion. +/// Deletes the file or directory (and the directory's contents, recursively) at the specified path in the project, and returns confirmation of the deletion. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct DeletePathToolInput { /// The path of the file or directory to delete. diff --git a/crates/agent2/src/tools/edit_file_tool.rs b/crates/agent2/src/tools/edit_file_tool.rs index 24fedda4eb..f89cace9a8 100644 --- a/crates/agent2/src/tools/edit_file_tool.rs +++ b/crates/agent2/src/tools/edit_file_tool.rs @@ -34,25 +34,21 @@ const DEFAULT_UI_TEXT: &str = "Editing file"; /// - Use the `list_directory` tool to verify the parent directory exists and is the correct location #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct EditFileToolInput { - /// A one-line, user-friendly markdown description of the edit. This will be - /// shown in the UI and also passed to another model to perform the edit. + /// A one-line, user-friendly markdown description of the edit. This will be shown in the UI and also passed to another model to perform the edit. /// - /// Be terse, but also descriptive in what you want to achieve with this - /// edit. Avoid generic instructions. + /// Be terse, but also descriptive in what you want to achieve with this edit. Avoid generic instructions. /// /// NEVER mention the file path in this description. /// /// Fix API endpoint URLs /// Update copyright year in `page_footer` /// - /// Make sure to include this field before all the others in the input object - /// so that we can display it immediately. + /// Make sure to include this field before all the others in the input object so that we can display it immediately. pub display_description: String, /// The full path of the file to create or modify in the project. /// - /// WARNING: When specifying which file path need changing, you MUST - /// start each path with one of the project's root directories. + /// WARNING: When specifying which file path need changing, you MUST start each path with one of the project's root directories. /// /// The following examples assume we have two root directories in the project: /// - /a/b/backend @@ -61,22 +57,19 @@ pub struct EditFileToolInput { /// /// `backend/src/main.rs` /// - /// Notice how the file path starts with `backend`. Without that, the path - /// would be ambiguous and the call would fail! + /// Notice how the file path starts with `backend`. Without that, the path would be ambiguous and the call would fail! /// /// /// /// `frontend/db.js` /// pub path: PathBuf, - /// The mode of operation on the file. Possible values: /// - 'edit': Make granular edits to an existing file. /// - 'create': Create a new file if it doesn't exist. /// - 'overwrite': Replace the entire contents of an existing file. /// - /// When a file already exists or you just created it, prefer editing - /// it as opposed to recreating it from scratch. + /// When a file already exists or you just created it, prefer editing it as opposed to recreating it from scratch. pub mode: EditFileMode, } diff --git a/crates/agent2/src/tools/find_path_tool.rs b/crates/agent2/src/tools/find_path_tool.rs index deccf37ab7..9e11ca6a37 100644 --- a/crates/agent2/src/tools/find_path_tool.rs +++ b/crates/agent2/src/tools/find_path_tool.rs @@ -31,7 +31,6 @@ pub struct FindPathToolInput { /// You can get back the first two paths by providing a glob of "*thing*.txt" /// pub glob: String, - /// Optional starting position for paginated results (0-based). /// When not provided, starts from the beginning. #[serde(default)] diff --git a/crates/agent2/src/tools/grep_tool.rs b/crates/agent2/src/tools/grep_tool.rs index 265c26926d..955dae7235 100644 --- a/crates/agent2/src/tools/grep_tool.rs +++ b/crates/agent2/src/tools/grep_tool.rs @@ -27,8 +27,7 @@ use util::paths::PathMatcher; /// - DO NOT use HTML entities solely to escape characters in the tool parameters. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct GrepToolInput { - /// A regex pattern to search for in the entire project. Note that the regex - /// will be parsed by the Rust `regex` crate. + /// A regex pattern to search for in the entire project. Note that the regex will be parsed by the Rust `regex` crate. /// /// Do NOT specify a path here! This will only be matched against the code **content**. pub regex: String, diff --git a/crates/agent2/src/tools/list_directory_tool.rs b/crates/agent2/src/tools/list_directory_tool.rs index 61f21d8f95..31575a92e4 100644 --- a/crates/agent2/src/tools/list_directory_tool.rs +++ b/crates/agent2/src/tools/list_directory_tool.rs @@ -10,14 +10,12 @@ use std::fmt::Write; use std::{path::Path, sync::Arc}; use util::markdown::MarkdownInlineCode; -/// Lists files and directories in a given path. Prefer the `grep` or -/// `find_path` tools when searching the codebase. +/// Lists files and directories in a given path. Prefer the `grep` or `find_path` tools when searching the codebase. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct ListDirectoryToolInput { /// The fully-qualified path of the directory to list in the project. /// - /// This path should never be absolute, and the first component - /// of the path should always be a root directory in a project. + /// This path should never be absolute, and the first component of the path should always be a root directory in a project. /// /// /// If the project has the following root directories: diff --git a/crates/agent2/src/tools/move_path_tool.rs b/crates/agent2/src/tools/move_path_tool.rs index f8d5d0d176..2a173a4404 100644 --- a/crates/agent2/src/tools/move_path_tool.rs +++ b/crates/agent2/src/tools/move_path_tool.rs @@ -8,14 +8,11 @@ use serde::{Deserialize, Serialize}; use std::{path::Path, sync::Arc}; use util::markdown::MarkdownInlineCode; -/// Moves or rename a file or directory in the project, and returns confirmation -/// that the move succeeded. +/// Moves or rename a file or directory in the project, and returns confirmation that the move succeeded. /// -/// If the source and destination directories are the same, but the filename is -/// different, this performs a rename. Otherwise, it performs a move. +/// If the source and destination directories are the same, but the filename is different, this performs a rename. Otherwise, it performs a move. /// -/// This tool should be used when it's desirable to move or rename a file or -/// directory without changing its contents at all. +/// This tool should be used when it's desirable to move or rename a file or directory without changing its contents at all. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct MovePathToolInput { /// The source path of the file or directory to move/rename. diff --git a/crates/agent2/src/tools/open_tool.rs b/crates/agent2/src/tools/open_tool.rs index 36420560c1..c20369c2d8 100644 --- a/crates/agent2/src/tools/open_tool.rs +++ b/crates/agent2/src/tools/open_tool.rs @@ -8,19 +8,15 @@ use serde::{Deserialize, Serialize}; use std::{path::PathBuf, sync::Arc}; use util::markdown::MarkdownEscaped; -/// This tool opens a file or URL with the default application associated with -/// it on the user's operating system: +/// This tool opens a file or URL with the default application associated with it on the user's operating system: /// /// - On macOS, it's equivalent to the `open` command /// - On Windows, it's equivalent to `start` /// - On Linux, it uses something like `xdg-open`, `gio open`, `gnome-open`, `kde-open`, `wslview` as appropriate /// -/// For example, it can open a web browser with a URL, open a PDF file with the -/// default PDF viewer, etc. +/// For example, it can open a web browser with a URL, open a PDF file with the default PDF viewer, etc. /// -/// You MUST ONLY use this tool when the user has explicitly requested opening -/// something. You MUST NEVER assume that the user would like for you to use -/// this tool. +/// You MUST ONLY use this tool when the user has explicitly requested opening something. You MUST NEVER assume that the user would like for you to use this tool. #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct OpenToolInput { /// The path or URL to open with the default application. diff --git a/crates/agent2/src/tools/read_file_tool.rs b/crates/agent2/src/tools/read_file_tool.rs index f37dff4f47..11a57506fb 100644 --- a/crates/agent2/src/tools/read_file_tool.rs +++ b/crates/agent2/src/tools/read_file_tool.rs @@ -21,8 +21,7 @@ use crate::{AgentTool, ToolCallEventStream}; pub struct ReadFileToolInput { /// The relative path of the file to read. /// - /// This path should never be absolute, and the first component - /// of the path should always be a root directory in a project. + /// This path should never be absolute, and the first component of the path should always be a root directory in a project. /// /// /// If the project has the following root directories: @@ -34,11 +33,9 @@ pub struct ReadFileToolInput { /// If you want to access `file.txt` in `directory2`, you should use the path `directory2/file.txt`. /// pub path: String, - /// Optional line number to start reading on (1-based index) #[serde(default)] pub start_line: Option, - /// Optional line number to end reading on (1-based index, inclusive) #[serde(default)] pub end_line: Option, diff --git a/crates/agent2/src/tools/thinking_tool.rs b/crates/agent2/src/tools/thinking_tool.rs index 43647bb468..c5e9451162 100644 --- a/crates/agent2/src/tools/thinking_tool.rs +++ b/crates/agent2/src/tools/thinking_tool.rs @@ -11,8 +11,7 @@ use crate::{AgentTool, ToolCallEventStream}; /// Use this tool when you need to work through complex problems, develop strategies, or outline approaches before taking action. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct ThinkingToolInput { - /// Content to think about. This should be a description of what to think about or - /// a problem to solve. + /// Content to think about. This should be a description of what to think about or a problem to solve. content: String, } diff --git a/crates/agent2/src/tools/web_search_tool.rs b/crates/agent2/src/tools/web_search_tool.rs index d71a128bfe..ffcd4ad3be 100644 --- a/crates/agent2/src/tools/web_search_tool.rs +++ b/crates/agent2/src/tools/web_search_tool.rs @@ -14,7 +14,7 @@ use ui::prelude::*; use web_search::WebSearchRegistry; /// Search the web for information using your query. -/// Use this when you need real-time information, facts, or data that might not be in your training. \ +/// Use this when you need real-time information, facts, or data that might not be in your training. /// Results will include snippets and links from relevant web pages. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct WebSearchToolInput { From 401a60405956920226c0c3ae3e5cb5aa3e279a63 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Wed, 20 Aug 2025 17:05:53 -0300 Subject: [PATCH 03/89] acp thread view: Do not go into editing mode if unsupported (#36623) Release Notes: - N/A --- crates/agent_ui/src/acp/thread_view.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 81a56165c8..2b87144fcd 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -669,8 +669,14 @@ impl AcpThreadView { ) { match &event.view_event { ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Focus) => { - self.editing_message = Some(event.entry_index); - cx.notify(); + if let Some(thread) = self.thread() + && let Some(AgentThreadEntry::UserMessage(user_message)) = + thread.read(cx).entries().get(event.entry_index) + && user_message.id.is_some() + { + self.editing_message = Some(event.entry_index); + cx.notify(); + } } ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::Send) => { self.regenerate(event.entry_index, editor, window, cx); @@ -1116,16 +1122,18 @@ impl AcpThreadView { .when(editing && !editor_focus, |this| this.border_dashed()) .border_color(cx.theme().colors().border) .map(|this|{ - if editor_focus { + if editing && editor_focus { this.border_color(focus_border) - } else { + } else if message.id.is_some() { this.hover(|s| s.border_color(focus_border.opacity(0.8))) + } else { + this } }) .text_xs() .child(editor.clone().into_any_element()), ) - .when(editor_focus, |this| + .when(editing && editor_focus, |this| this.child( h_flex() .absolute() From 15e451cec839462ebfe091da7d41023b7ea8605a Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 20 Aug 2025 18:01:22 -0300 Subject: [PATCH 04/89] thread_view: Add recent history entries & adjust empty state (#36625) Release Notes: - N/A --- assets/icons/menu_alt.svg | 2 +- assets/icons/zed_agent.svg | 34 ++-- assets/icons/zed_assistant.svg | 4 +- crates/agent2/src/history_store.rs | 4 + crates/agent2/src/native_agent_server.rs | 2 +- crates/agent_servers/src/gemini.rs | 4 +- crates/agent_ui/src/acp/thread_history.rs | 149 ++++++++++++++- crates/agent_ui/src/acp/thread_view.rs | 198 +++++++++++++++----- crates/agent_ui/src/ui.rs | 2 - crates/agent_ui/src/ui/new_thread_button.rs | 75 -------- 10 files changed, 325 insertions(+), 149 deletions(-) delete mode 100644 crates/agent_ui/src/ui/new_thread_button.rs diff --git a/assets/icons/menu_alt.svg b/assets/icons/menu_alt.svg index 87add13216..b9cc19e22f 100644 --- a/assets/icons/menu_alt.svg +++ b/assets/icons/menu_alt.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/zed_agent.svg b/assets/icons/zed_agent.svg index b6e120a0b6..0c80e22c51 100644 --- a/assets/icons/zed_agent.svg +++ b/assets/icons/zed_agent.svg @@ -1,27 +1,27 @@ - + - - + + - - + + - + - - - - - - - - + + + + + + + + - + - - + + diff --git a/assets/icons/zed_assistant.svg b/assets/icons/zed_assistant.svg index 470eb0fede..812277a100 100644 --- a/assets/icons/zed_assistant.svg +++ b/assets/icons/zed_assistant.svg @@ -1,5 +1,5 @@ - - + + diff --git a/crates/agent2/src/history_store.rs b/crates/agent2/src/history_store.rs index 870c2607c4..2d70164a66 100644 --- a/crates/agent2/src/history_store.rs +++ b/crates/agent2/src/history_store.rs @@ -345,4 +345,8 @@ impl HistoryStore { .retain(|old_entry| old_entry != entry); self.save_recently_opened_entries(cx); } + + pub fn recent_entries(&self, limit: usize, cx: &mut Context) -> Vec { + self.entries(cx).into_iter().take(limit).collect() + } } diff --git a/crates/agent2/src/native_agent_server.rs b/crates/agent2/src/native_agent_server.rs index 74d24efb13..a1f935589a 100644 --- a/crates/agent2/src/native_agent_server.rs +++ b/crates/agent2/src/native_agent_server.rs @@ -27,7 +27,7 @@ impl AgentServer for NativeAgentServer { } fn empty_state_headline(&self) -> &'static str { - "" + "Welcome to the Agent Panel" } fn empty_state_message(&self) -> &'static str { diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index 813f8b1fe0..dcbeaa1d63 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -18,11 +18,11 @@ const ACP_ARG: &str = "--experimental-acp"; impl AgentServer for Gemini { fn name(&self) -> &'static str { - "Gemini" + "Gemini CLI" } fn empty_state_headline(&self) -> &'static str { - "Welcome to Gemini" + "Welcome to Gemini CLI" } fn empty_state_message(&self) -> &'static str { diff --git a/crates/agent_ui/src/acp/thread_history.rs b/crates/agent_ui/src/acp/thread_history.rs index 8a05801139..68a41f31d0 100644 --- a/crates/agent_ui/src/acp/thread_history.rs +++ b/crates/agent_ui/src/acp/thread_history.rs @@ -1,11 +1,12 @@ -use crate::RemoveSelectedThread; +use crate::acp::AcpThreadView; +use crate::{AgentPanel, RemoveSelectedThread}; use agent2::{HistoryEntry, HistoryStore}; use chrono::{Datelike as _, Local, NaiveDate, TimeDelta}; use editor::{Editor, EditorEvent}; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ App, Empty, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Stateful, Task, - UniformListScrollHandle, Window, uniform_list, + UniformListScrollHandle, WeakEntity, Window, uniform_list, }; use std::{fmt::Display, ops::Range, sync::Arc}; use time::{OffsetDateTime, UtcOffset}; @@ -639,6 +640,150 @@ impl Render for AcpThreadHistory { } } +#[derive(IntoElement)] +pub struct AcpHistoryEntryElement { + entry: HistoryEntry, + thread_view: WeakEntity, + selected: bool, + hovered: bool, + on_hover: Box, +} + +impl AcpHistoryEntryElement { + pub fn new(entry: HistoryEntry, thread_view: WeakEntity) -> Self { + Self { + entry, + thread_view, + selected: false, + hovered: false, + on_hover: Box::new(|_, _, _| {}), + } + } + + pub fn hovered(mut self, hovered: bool) -> Self { + self.hovered = hovered; + self + } + + pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self { + self.on_hover = Box::new(on_hover); + self + } +} + +impl RenderOnce for AcpHistoryEntryElement { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let (id, title, timestamp) = match &self.entry { + HistoryEntry::AcpThread(thread) => ( + thread.id.to_string(), + thread.title.clone(), + thread.updated_at, + ), + HistoryEntry::TextThread(context) => ( + context.path.to_string_lossy().to_string(), + context.title.clone(), + context.mtime.to_utc(), + ), + }; + + let formatted_time = { + let now = chrono::Utc::now(); + let duration = now.signed_duration_since(timestamp); + + if duration.num_days() > 0 { + format!("{}d", duration.num_days()) + } else if duration.num_hours() > 0 { + format!("{}h ago", duration.num_hours()) + } else if duration.num_minutes() > 0 { + format!("{}m ago", duration.num_minutes()) + } else { + "Just now".to_string() + } + }; + + ListItem::new(SharedString::from(id)) + .rounded() + .toggle_state(self.selected) + .spacing(ListItemSpacing::Sparse) + .start_slot( + h_flex() + .w_full() + .gap_2() + .justify_between() + .child(Label::new(title).size(LabelSize::Small).truncate()) + .child( + Label::new(formatted_time) + .color(Color::Muted) + .size(LabelSize::XSmall), + ), + ) + .on_hover(self.on_hover) + .end_slot::(if self.hovered || self.selected { + Some( + IconButton::new("delete", IconName::Trash) + .shape(IconButtonShape::Square) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .tooltip(move |window, cx| { + Tooltip::for_action("Delete", &RemoveSelectedThread, window, cx) + }) + .on_click({ + let thread_view = self.thread_view.clone(); + let entry = self.entry.clone(); + + move |_event, _window, cx| { + if let Some(thread_view) = thread_view.upgrade() { + thread_view.update(cx, |thread_view, cx| { + thread_view.delete_history_entry(entry.clone(), cx); + }); + } + } + }), + ) + } else { + None + }) + .on_click({ + let thread_view = self.thread_view.clone(); + let entry = self.entry; + + move |_event, window, cx| { + if let Some(workspace) = thread_view + .upgrade() + .and_then(|view| view.read(cx).workspace().upgrade()) + { + match &entry { + HistoryEntry::AcpThread(thread_metadata) => { + if let Some(panel) = workspace.read(cx).panel::(cx) { + panel.update(cx, |panel, cx| { + panel.load_agent_thread( + thread_metadata.clone(), + window, + cx, + ); + }); + } + } + HistoryEntry::TextThread(context) => { + if let Some(panel) = workspace.read(cx).panel::(cx) { + panel.update(cx, |panel, cx| { + panel + .open_saved_prompt_editor( + context.path.clone(), + window, + cx, + ) + .detach_and_log_err(cx); + }); + } + } + } + } + } + }) + } +} + #[derive(Clone, Copy)] pub enum EntryTimeFormat { DateAndTime, diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 2b87144fcd..35da9b8c85 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -8,7 +8,7 @@ use action_log::ActionLog; use agent_client_protocol::{self as acp}; use agent_servers::{AgentServer, ClaudeCode}; use agent_settings::{AgentProfileId, AgentSettings, CompletionMode, NotifyWhenAgentWaiting}; -use agent2::{DbThreadMetadata, HistoryEntryId, HistoryStore}; +use agent2::{DbThreadMetadata, HistoryEntry, HistoryEntryId, HistoryStore}; use anyhow::bail; use audio::{Audio, Sound}; use buffer_diff::BufferDiff; @@ -54,11 +54,12 @@ use crate::acp::entry_view_state::{EntryViewEvent, ViewEvent}; use crate::acp::message_editor::{MessageEditor, MessageEditorEvent}; use crate::agent_diff::AgentDiff; use crate::profile_selector::{ProfileProvider, ProfileSelector}; + use crate::ui::preview::UsageCallout; use crate::ui::{AgentNotification, AgentNotificationEvent, BurnModeTooltip}; use crate::{ AgentDiffPane, AgentPanel, ContinueThread, ContinueWithBurnMode, ExpandMessageEditor, Follow, - KeepAll, OpenAgentDiff, RejectAll, ToggleBurnMode, ToggleProfileSelector, + KeepAll, OpenAgentDiff, OpenHistory, RejectAll, ToggleBurnMode, ToggleProfileSelector, }; const RESPONSE_PADDING_X: Pixels = px(19.); @@ -240,6 +241,7 @@ pub struct AcpThreadView { project: Entity, thread_state: ThreadState, history_store: Entity, + hovered_recent_history_item: Option, entry_view_state: Entity, message_editor: Entity, model_selector: Option>, @@ -357,6 +359,7 @@ impl AcpThreadView { editor_expanded: false, terminal_expanded: true, history_store, + hovered_recent_history_item: None, _subscriptions: subscriptions, _cancel_task: None, } @@ -582,6 +585,10 @@ impl AcpThreadView { cx.notify(); } + pub fn workspace(&self) -> &WeakEntity { + &self.workspace + } + pub fn thread(&self) -> Option<&Entity> { match &self.thread_state { ThreadState::Ready { thread, .. } => Some(thread), @@ -2284,51 +2291,132 @@ impl AcpThreadView { ) } - fn render_empty_state(&self, cx: &App) -> AnyElement { + fn render_empty_state_section_header( + &self, + label: impl Into, + action_slot: Option, + cx: &mut Context, + ) -> impl IntoElement { + div().pl_1().pr_1p5().child( + h_flex() + .mt_2() + .pl_1p5() + .pb_1() + .w_full() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child( + Label::new(label.into()) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .children(action_slot), + ) + } + + fn render_empty_state(&self, window: &mut Window, cx: &mut Context) -> AnyElement { let loading = matches!(&self.thread_state, ThreadState::Loading { .. }); + let recent_history = self + .history_store + .update(cx, |history_store, cx| history_store.recent_entries(3, cx)); + let no_history = self + .history_store + .update(cx, |history_store, cx| history_store.is_empty(cx)); v_flex() .size_full() - .items_center() - .justify_center() - .child(if loading { - h_flex() - .justify_center() - .child(self.render_agent_logo()) - .with_animation( - "pulsating_icon", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.4, 1.0)), - |icon, delta| icon.opacity(delta), - ) - .into_any() - } else { - self.render_agent_logo().into_any_element() - }) - .child(h_flex().mt_4().mb_1().justify_center().child(if loading { - div() - .child(LoadingLabel::new("").size(LabelSize::Large)) - .into_any_element() - } else { - Headline::new(self.agent.empty_state_headline()) - .size(HeadlineSize::Medium) - .into_any_element() - })) - .child( - div() - .max_w_1_2() - .text_sm() - .text_center() - .map(|this| { - if loading { - this.invisible() + .when(no_history, |this| { + this.child( + v_flex() + .size_full() + .items_center() + .justify_center() + .child(if loading { + h_flex() + .justify_center() + .child(self.render_agent_logo()) + .with_animation( + "pulsating_icon", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 1.0)), + |icon, delta| icon.opacity(delta), + ) + .into_any() } else { - this.text_color(cx.theme().colors().text_muted) - } - }) - .child(self.agent.empty_state_message()), - ) + self.render_agent_logo().into_any_element() + }) + .child(h_flex().mt_4().mb_2().justify_center().child(if loading { + div() + .child(LoadingLabel::new("").size(LabelSize::Large)) + .into_any_element() + } else { + Headline::new(self.agent.empty_state_headline()) + .size(HeadlineSize::Medium) + .into_any_element() + })), + ) + }) + .when(!no_history, |this| { + this.justify_end().child( + v_flex() + .child( + self.render_empty_state_section_header( + "Recent", + Some( + Button::new("view-history", "View All") + .style(ButtonStyle::Subtle) + .label_size(LabelSize::Small) + .key_binding( + KeyBinding::for_action_in( + &OpenHistory, + &self.focus_handle(cx), + window, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(move |_event, window, cx| { + window.dispatch_action(OpenHistory.boxed_clone(), cx); + }) + .into_any_element(), + ), + cx, + ), + ) + .child( + v_flex().p_1().pr_1p5().gap_1().children( + recent_history + .into_iter() + .enumerate() + .map(|(index, entry)| { + // TODO: Add keyboard navigation. + let is_hovered = + self.hovered_recent_history_item == Some(index); + crate::acp::thread_history::AcpHistoryEntryElement::new( + entry, + cx.entity().downgrade(), + ) + .hovered(is_hovered) + .on_hover(cx.listener( + move |this, is_hovered, _window, cx| { + if *is_hovered { + this.hovered_recent_history_item = Some(index); + } else if this.hovered_recent_history_item + == Some(index) + { + this.hovered_recent_history_item = None; + } + cx.notify(); + }, + )) + .into_any_element() + }), + ), + ), + ) + }) .into_any() } @@ -2351,9 +2439,11 @@ impl AcpThreadView { .items_center() .justify_center() .child(self.render_error_agent_logo()) - .child(h_flex().mt_4().mb_1().justify_center().child( - Headline::new(self.agent.empty_state_headline()).size(HeadlineSize::Medium), - )) + .child( + h_flex().mt_4().mb_1().justify_center().child( + Headline::new("Authentication Required").size(HeadlineSize::Medium), + ), + ) .into_any(), ) .children(description.map(|desc| { @@ -4234,6 +4324,18 @@ impl AcpThreadView { ); cx.notify(); } + + pub fn delete_history_entry(&mut self, entry: HistoryEntry, cx: &mut Context) { + let task = match entry { + HistoryEntry::AcpThread(thread) => self.history_store.update(cx, |history, cx| { + history.delete_thread(thread.id.clone(), cx) + }), + HistoryEntry::TextThread(context) => self.history_store.update(cx, |history, cx| { + history.delete_text_thread(context.path.clone(), cx) + }), + }; + task.detach_and_log_err(cx); + } } impl Focusable for AcpThreadView { @@ -4268,7 +4370,9 @@ impl Render for AcpThreadView { window, cx, ), - ThreadState::Loading { .. } => v_flex().flex_1().child(self.render_empty_state(cx)), + ThreadState::Loading { .. } => { + v_flex().flex_1().child(self.render_empty_state(window, cx)) + } ThreadState::LoadError(e) => v_flex() .p_2() .flex_1() @@ -4310,7 +4414,7 @@ impl Render for AcpThreadView { }, ) } else { - this.child(self.render_empty_state(cx)) + this.child(self.render_empty_state(window, cx)) } }) } diff --git a/crates/agent_ui/src/ui.rs b/crates/agent_ui/src/ui.rs index beeaf0c43b..e27a224240 100644 --- a/crates/agent_ui/src/ui.rs +++ b/crates/agent_ui/src/ui.rs @@ -2,7 +2,6 @@ mod agent_notification; mod burn_mode_tooltip; mod context_pill; mod end_trial_upsell; -// mod new_thread_button; mod onboarding_modal; pub mod preview; @@ -10,5 +9,4 @@ pub use agent_notification::*; pub use burn_mode_tooltip::*; pub use context_pill::*; pub use end_trial_upsell::*; -// pub use new_thread_button::*; pub use onboarding_modal::*; diff --git a/crates/agent_ui/src/ui/new_thread_button.rs b/crates/agent_ui/src/ui/new_thread_button.rs deleted file mode 100644 index 347d6adcaf..0000000000 --- a/crates/agent_ui/src/ui/new_thread_button.rs +++ /dev/null @@ -1,75 +0,0 @@ -use gpui::{ClickEvent, ElementId, IntoElement, ParentElement, Styled}; -use ui::prelude::*; - -#[derive(IntoElement)] -pub struct NewThreadButton { - id: ElementId, - label: SharedString, - icon: IconName, - keybinding: Option, - on_click: Option>, -} - -impl NewThreadButton { - fn new(id: impl Into, label: impl Into, icon: IconName) -> Self { - Self { - id: id.into(), - label: label.into(), - icon, - keybinding: None, - on_click: None, - } - } - - fn keybinding(mut self, keybinding: Option) -> Self { - self.keybinding = keybinding; - self - } - - fn on_click(mut self, handler: F) -> Self - where - F: Fn(&mut Window, &mut App) + 'static, - { - self.on_click = Some(Box::new( - move |_: &ClickEvent, window: &mut Window, cx: &mut App| handler(window, cx), - )); - self - } -} - -impl RenderOnce for NewThreadButton { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - h_flex() - .id(self.id) - .w_full() - .py_1p5() - .px_2() - .gap_1() - .justify_between() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border.opacity(0.4)) - .bg(cx.theme().colors().element_active.opacity(0.2)) - .hover(|style| { - style - .bg(cx.theme().colors().element_hover) - .border_color(cx.theme().colors().border) - }) - .child( - h_flex() - .gap_1p5() - .child( - Icon::new(self.icon) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child(Label::new(self.label).size(LabelSize::Small)), - ) - .when_some(self.keybinding, |this, keybinding| { - this.child(keybinding.size(rems_from_px(10.))) - }) - .when_some(self.on_click, |this, on_click| { - this.on_click(move |event, window, cx| on_click(event, window, cx)) - }) - } -} From b070dc66b3f2b00e87f34cb7319d2f2168cac7f8 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Wed, 20 Aug 2025 18:26:07 -0300 Subject: [PATCH 05/89] acp: Suggest installing gemini@preview instead of latest (#36629) Release Notes: - N/A --- crates/agent_servers/src/gemini.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index dcbeaa1d63..25c654db9b 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -53,7 +53,7 @@ impl AgentServer for Gemini { return Err(LoadError::NotInstalled { error_message: "Failed to find Gemini CLI binary".into(), install_message: "Install Gemini CLI".into(), - install_command: "npm install -g @google/gemini-cli@latest".into() + install_command: "npm install -g @google/gemini-cli@preview".into() }.into()); }; From 1ee07a4bafb368e481f262ecdef826c20db63b40 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Wed, 20 Aug 2025 17:31:25 -0400 Subject: [PATCH 06/89] acp: Rename `assistant::QuoteSelection` and support it in agent2 threads (#36628) Release Notes: - N/A --- assets/keymaps/default-linux.json | 4 +- assets/keymaps/default-macos.json | 4 +- assets/keymaps/linux/cursor.json | 4 +- assets/keymaps/macos/cursor.json | 4 +- .../agent_ui/src/acp/completion_provider.rs | 122 ++++++++++-------- crates/agent_ui/src/acp/message_editor.rs | 54 ++++++-- crates/agent_ui/src/acp/thread_view.rs | 6 + crates/agent_ui/src/agent_panel.rs | 16 ++- crates/agent_ui/src/agent_ui.rs | 6 + crates/agent_ui/src/text_thread_editor.rs | 3 +- docs/src/ai/text-threads.md | 4 +- 11 files changed, 148 insertions(+), 79 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index b4efa70572..955e68f5a9 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -138,7 +138,7 @@ "find": "buffer_search::Deploy", "ctrl-f": "buffer_search::Deploy", "ctrl-h": "buffer_search::DeployReplace", - "ctrl->": "assistant::QuoteSelection", + "ctrl->": "agent::QuoteSelection", "ctrl-<": "assistant::InsertIntoEditor", "ctrl-alt-e": "editor::SelectEnclosingSymbol", "ctrl-shift-backspace": "editor::GoToPreviousChange", @@ -241,7 +241,7 @@ "ctrl-shift-i": "agent::ToggleOptionsMenu", "ctrl-alt-shift-n": "agent::ToggleNewThreadMenu", "shift-alt-escape": "agent::ExpandMessageEditor", - "ctrl->": "assistant::QuoteSelection", + "ctrl->": "agent::QuoteSelection", "ctrl-alt-e": "agent::RemoveAllContext", "ctrl-shift-e": "project_panel::ToggleFocus", "ctrl-shift-enter": "agent::ContinueThread", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index ad2ab2ba89..8b18299a91 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -162,7 +162,7 @@ "cmd-alt-f": "buffer_search::DeployReplace", "cmd-alt-l": ["buffer_search::Deploy", { "selection_search_enabled": true }], "cmd-e": ["buffer_search::Deploy", { "focus": false }], - "cmd->": "assistant::QuoteSelection", + "cmd->": "agent::QuoteSelection", "cmd-<": "assistant::InsertIntoEditor", "cmd-alt-e": "editor::SelectEnclosingSymbol", "alt-enter": "editor::OpenSelectionsInMultibuffer" @@ -281,7 +281,7 @@ "cmd-shift-i": "agent::ToggleOptionsMenu", "cmd-alt-shift-n": "agent::ToggleNewThreadMenu", "shift-alt-escape": "agent::ExpandMessageEditor", - "cmd->": "assistant::QuoteSelection", + "cmd->": "agent::QuoteSelection", "cmd-alt-e": "agent::RemoveAllContext", "cmd-shift-e": "project_panel::ToggleFocus", "cmd-ctrl-b": "agent::ToggleBurnMode", diff --git a/assets/keymaps/linux/cursor.json b/assets/keymaps/linux/cursor.json index 1c381b0cf0..2e27158e11 100644 --- a/assets/keymaps/linux/cursor.json +++ b/assets/keymaps/linux/cursor.json @@ -17,8 +17,8 @@ "bindings": { "ctrl-i": "agent::ToggleFocus", "ctrl-shift-i": "agent::ToggleFocus", - "ctrl-shift-l": "assistant::QuoteSelection", // In cursor uses "Ask" mode - "ctrl-l": "assistant::QuoteSelection", // In cursor uses "Agent" mode + "ctrl-shift-l": "agent::QuoteSelection", // In cursor uses "Ask" mode + "ctrl-l": "agent::QuoteSelection", // In cursor uses "Agent" mode "ctrl-k": "assistant::InlineAssist", "ctrl-shift-k": "assistant::InsertIntoEditor" } diff --git a/assets/keymaps/macos/cursor.json b/assets/keymaps/macos/cursor.json index fdf9c437cf..1d723bd75b 100644 --- a/assets/keymaps/macos/cursor.json +++ b/assets/keymaps/macos/cursor.json @@ -17,8 +17,8 @@ "bindings": { "cmd-i": "agent::ToggleFocus", "cmd-shift-i": "agent::ToggleFocus", - "cmd-shift-l": "assistant::QuoteSelection", // In cursor uses "Ask" mode - "cmd-l": "assistant::QuoteSelection", // In cursor uses "Agent" mode + "cmd-shift-l": "agent::QuoteSelection", // In cursor uses "Ask" mode + "cmd-l": "agent::QuoteSelection", // In cursor uses "Agent" mode "cmd-k": "assistant::InlineAssist", "cmd-shift-k": "assistant::InsertIntoEditor" } diff --git a/crates/agent_ui/src/acp/completion_provider.rs b/crates/agent_ui/src/acp/completion_provider.rs index bf0a3f7a5a..3587e5144e 100644 --- a/crates/agent_ui/src/acp/completion_provider.rs +++ b/crates/agent_ui/src/acp/completion_provider.rs @@ -108,62 +108,7 @@ impl ContextPickerCompletionProvider { confirm: Some(Arc::new(|_, _, _| true)), }), ContextPickerEntry::Action(action) => { - let (new_text, on_action) = match action { - ContextPickerAction::AddSelections => { - const PLACEHOLDER: &str = "selection "; - let selections = selection_ranges(workspace, cx) - .into_iter() - .enumerate() - .map(|(ix, (buffer, range))| { - ( - buffer, - range, - (PLACEHOLDER.len() * ix)..(PLACEHOLDER.len() * (ix + 1) - 1), - ) - }) - .collect::>(); - - let new_text: String = PLACEHOLDER.repeat(selections.len()); - - let callback = Arc::new({ - let source_range = source_range.clone(); - move |_, window: &mut Window, cx: &mut App| { - let selections = selections.clone(); - let message_editor = message_editor.clone(); - let source_range = source_range.clone(); - window.defer(cx, move |window, cx| { - message_editor - .update(cx, |message_editor, cx| { - message_editor.confirm_mention_for_selection( - source_range, - selections, - window, - cx, - ) - }) - .ok(); - }); - false - } - }); - - (new_text, callback) - } - }; - - Some(Completion { - replace_range: source_range, - new_text, - label: CodeLabel::plain(action.label().to_string(), None), - icon_path: Some(action.icon().path().into()), - documentation: None, - source: project::CompletionSource::Custom, - insert_text_mode: None, - // This ensures that when a user accepts this completion, the - // completion menu will still be shown after "@category " is - // inserted - confirm: Some(on_action), - }) + Self::completion_for_action(action, source_range, message_editor, workspace, cx) } } } @@ -359,6 +304,71 @@ impl ContextPickerCompletionProvider { }) } + pub(crate) fn completion_for_action( + action: ContextPickerAction, + source_range: Range, + message_editor: WeakEntity, + workspace: &Entity, + cx: &mut App, + ) -> Option { + let (new_text, on_action) = match action { + ContextPickerAction::AddSelections => { + const PLACEHOLDER: &str = "selection "; + let selections = selection_ranges(workspace, cx) + .into_iter() + .enumerate() + .map(|(ix, (buffer, range))| { + ( + buffer, + range, + (PLACEHOLDER.len() * ix)..(PLACEHOLDER.len() * (ix + 1) - 1), + ) + }) + .collect::>(); + + let new_text: String = PLACEHOLDER.repeat(selections.len()); + + let callback = Arc::new({ + let source_range = source_range.clone(); + move |_, window: &mut Window, cx: &mut App| { + let selections = selections.clone(); + let message_editor = message_editor.clone(); + let source_range = source_range.clone(); + window.defer(cx, move |window, cx| { + message_editor + .update(cx, |message_editor, cx| { + message_editor.confirm_mention_for_selection( + source_range, + selections, + window, + cx, + ) + }) + .ok(); + }); + false + } + }); + + (new_text, callback) + } + }; + + Some(Completion { + replace_range: source_range, + new_text, + label: CodeLabel::plain(action.label().to_string(), None), + icon_path: Some(action.icon().path().into()), + documentation: None, + source: project::CompletionSource::Custom, + insert_text_mode: None, + // This ensures that when a user accepts this completion, the + // completion menu will still be shown after "@category " is + // inserted + confirm: Some(on_action), + }) + } + fn search( &self, mode: Option, diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index 5eab1a4e2d..be133808b7 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -1,6 +1,6 @@ use crate::{ acp::completion_provider::ContextPickerCompletionProvider, - context_picker::fetch_context_picker::fetch_url_content, + context_picker::{ContextPickerAction, fetch_context_picker::fetch_url_content}, }; use acp_thread::{MentionUri, selection_name}; use agent_client_protocol as acp; @@ -27,7 +27,7 @@ use gpui::{ }; use language::{Buffer, Language}; use language_model::LanguageModelImage; -use project::{Project, ProjectPath, Worktree}; +use project::{CompletionIntent, Project, ProjectItem, ProjectPath, Worktree}; use prompt_store::PromptStore; use rope::Point; use settings::Settings; @@ -561,21 +561,24 @@ impl MessageEditor { let range = snapshot.anchor_after(offset + range_to_fold.start) ..snapshot.anchor_after(offset + range_to_fold.end); - let path = buffer - .read(cx) - .file() - .map_or(PathBuf::from("untitled"), |file| file.path().to_path_buf()); + // TODO support selections from buffers with no path + let Some(project_path) = buffer.read(cx).project_path(cx) else { + continue; + }; + let Some(abs_path) = self.project.read(cx).absolute_path(&project_path, cx) else { + continue; + }; let snapshot = buffer.read(cx).snapshot(); let point_range = selection_range.to_point(&snapshot); let line_range = point_range.start.row..point_range.end.row; let uri = MentionUri::Selection { - path: path.clone(), + path: abs_path.clone(), line_range: line_range.clone(), }; let crease = crate::context_picker::crease_for_mention( - selection_name(&path, &line_range).into(), + selection_name(&abs_path, &line_range).into(), uri.icon_path(cx), range, self.editor.downgrade(), @@ -587,8 +590,7 @@ impl MessageEditor { crease_ids.first().copied().unwrap() }); - self.mention_set - .insert_uri(crease_id, MentionUri::Selection { path, line_range }); + self.mention_set.insert_uri(crease_id, uri); } } @@ -948,6 +950,38 @@ impl MessageEditor { .detach(); } + pub fn insert_selections(&mut self, window: &mut Window, cx: &mut Context) { + let buffer = self.editor.read(cx).buffer().clone(); + let Some(buffer) = buffer.read(cx).as_singleton() else { + return; + }; + let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len())); + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + let Some(completion) = ContextPickerCompletionProvider::completion_for_action( + ContextPickerAction::AddSelections, + anchor..anchor, + cx.weak_entity(), + &workspace, + cx, + ) else { + return; + }; + self.editor.update(cx, |message_editor, cx| { + message_editor.edit( + [( + multi_buffer::Anchor::max()..multi_buffer::Anchor::max(), + completion.new_text, + )], + cx, + ); + }); + if let Some(confirm) = completion.confirm { + confirm(CompletionIntent::Complete, window, cx); + } + } + pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context) { self.editor.update(cx, |message_editor, cx| { message_editor.set_read_only(read_only); diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 35da9b8c85..0dfa3d259e 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -4097,6 +4097,12 @@ impl AcpThreadView { }) } + pub(crate) fn insert_selections(&self, window: &mut Window, cx: &mut Context) { + self.message_editor.update(cx, |message_editor, cx| { + message_editor.insert_selections(window, cx); + }) + } + fn render_thread_retry_status_callout( &self, _window: &mut Window, diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index e2c4acb1ce..65a9da573a 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -903,6 +903,16 @@ impl AgentPanel { } } + fn active_thread_view(&self) -> Option<&Entity> { + match &self.active_view { + ActiveView::ExternalAgentThread { thread_view } => Some(thread_view), + ActiveView::Thread { .. } + | ActiveView::TextThread { .. } + | ActiveView::History + | ActiveView::Configuration => None, + } + } + fn new_thread(&mut self, action: &NewThread, window: &mut Window, cx: &mut Context) { if cx.has_flag::() { return self.new_agent_thread(AgentType::NativeAgent, window, cx); @@ -3882,7 +3892,11 @@ impl AgentPanelDelegate for ConcreteAssistantPanelDelegate { // Wait to create a new context until the workspace is no longer // being updated. cx.defer_in(window, move |panel, window, cx| { - if let Some(message_editor) = panel.active_message_editor() { + if let Some(thread_view) = panel.active_thread_view() { + thread_view.update(cx, |thread_view, cx| { + thread_view.insert_selections(window, cx); + }); + } else if let Some(message_editor) = panel.active_message_editor() { message_editor.update(cx, |message_editor, cx| { message_editor.context_store().update(cx, |store, cx| { let buffer = buffer.read(cx); diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 7b6557245f..6084fd6423 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -128,6 +128,12 @@ actions!( ] ); +#[derive(Clone, Copy, Debug, PartialEq, Eq, Action)] +#[action(namespace = agent)] +#[action(deprecated_aliases = ["assistant::QuoteSelection"])] +/// Quotes the current selection in the agent panel's message editor. +pub struct QuoteSelection; + /// Creates a new conversation thread, optionally based on an existing thread. #[derive(Default, Clone, PartialEq, Deserialize, JsonSchema, Action)] #[action(namespace = agent)] diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs index a928f7af54..9fbd90c4a6 100644 --- a/crates/agent_ui/src/text_thread_editor.rs +++ b/crates/agent_ui/src/text_thread_editor.rs @@ -1,4 +1,5 @@ use crate::{ + QuoteSelection, language_model_selector::{LanguageModelSelector, language_model_selector}, ui::BurnModeTooltip, }; @@ -89,8 +90,6 @@ actions!( CycleMessageRole, /// Inserts the selected text into the active editor. InsertIntoEditor, - /// Quotes the current selection in the assistant conversation. - QuoteSelection, /// Splits the conversation at the current cursor position. Split, ] diff --git a/docs/src/ai/text-threads.md b/docs/src/ai/text-threads.md index 65a5dcba03..ed439252b4 100644 --- a/docs/src/ai/text-threads.md +++ b/docs/src/ai/text-threads.md @@ -16,7 +16,7 @@ To begin, type a message in a `You` block. As you type, the remaining tokens count for the selected model is updated. -Inserting text from an editor is as simple as highlighting the text and running `assistant: quote selection` ({#kb assistant::QuoteSelection}); Zed will wrap it in a fenced code block if it is code. +Inserting text from an editor is as simple as highlighting the text and running `agent: quote selection` ({#kb agent::QuoteSelection}); Zed will wrap it in a fenced code block if it is code. ![Quoting a selection](https://zed.dev/img/assistant/quoting-a-selection.png) @@ -148,7 +148,7 @@ Usage: `/terminal []` The `/selection` command inserts the selected text in the editor into the context. This is useful for referencing specific parts of your code. -This is equivalent to the `assistant: quote selection` command ({#kb assistant::QuoteSelection}). +This is equivalent to the `agent: quote selection` command ({#kb agent::QuoteSelection}). Usage: `/selection` From 8e57f633d0c606dae3a6846ea02c904caef3f2e9 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Wed, 20 Aug 2025 18:35:48 -0300 Subject: [PATCH 07/89] acp: Hide feedback buttons for external agents (#36630) Release Notes: - N/A --- crates/agent_ui/src/acp/thread_view.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 0dfa3d259e..f4c0ce9784 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -3815,7 +3815,11 @@ impl AcpThreadView { .flex_wrap() .justify_end(); - if AgentSettings::get_global(cx).enable_feedback { + if AgentSettings::get_global(cx).enable_feedback + && self + .thread() + .is_some_and(|thread| thread.read(cx).connection().telemetry().is_some()) + { let feedback = self.thread_feedback.feedback; container = container.child( div().visible_on_hover("thread-controls-container").child( From 00ff7b72d73a6085aa520ded6bcc42be6b126bdd Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Wed, 20 Aug 2025 15:30:11 -0400 Subject: [PATCH 08/89] Fix typo in `Excerpt::contains` (#36621) Follow-up to #36524 Release Notes: - N/A --- crates/multi_buffer/src/multi_buffer.rs | 27 ++++++++++++------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index f73014a6ff..a54d38163d 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -6998,20 +6998,19 @@ impl Excerpt { } fn contains(&self, anchor: &Anchor) -> bool { - anchor.buffer_id == None - || anchor.buffer_id == Some(self.buffer_id) - && self - .range - .context - .start - .cmp(&anchor.text_anchor, &self.buffer) - .is_le() - && self - .range - .context - .end - .cmp(&anchor.text_anchor, &self.buffer) - .is_ge() + (anchor.buffer_id == None || anchor.buffer_id == Some(self.buffer_id)) + && self + .range + .context + .start + .cmp(&anchor.text_anchor, &self.buffer) + .is_le() + && self + .range + .context + .end + .cmp(&anchor.text_anchor, &self.buffer) + .is_ge() } /// The [`Excerpt`]'s start offset in its [`Buffer`] From 5b443bb49ee31fd5a2e7153c699d7c27e708c59e Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Wed, 20 Aug 2025 16:12:41 -0600 Subject: [PATCH 09/89] acp: Handle Gemini Auth Better (#36631) Release Notes: - N/A --------- Co-authored-by: Danilo Leal --- crates/agent_servers/src/gemini.rs | 7 +- crates/agent_ui/src/acp/thread_view.rs | 154 ++++++++++++++++-- crates/language_models/src/provider/google.rs | 53 +++++- 3 files changed, 195 insertions(+), 19 deletions(-) diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index 25c654db9b..d30525328b 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -5,6 +5,7 @@ use crate::{AgentServer, AgentServerCommand}; use acp_thread::{AgentConnection, LoadError}; use anyhow::Result; use gpui::{Entity, Task}; +use language_models::provider::google::GoogleLanguageModelProvider; use project::Project; use settings::SettingsStore; use ui::App; @@ -47,7 +48,7 @@ impl AgentServer for Gemini { settings.get::(None).gemini.clone() })?; - let Some(command) = + let Some(mut command) = AgentServerCommand::resolve("gemini", &[ACP_ARG], None, settings, &project, cx).await else { return Err(LoadError::NotInstalled { @@ -57,6 +58,10 @@ impl AgentServer for Gemini { }.into()); }; + if let Some(api_key)= cx.update(GoogleLanguageModelProvider::api_key)?.await.ok() { + command.env.get_or_insert_default().insert("GEMINI_API_KEY".to_owned(), api_key.key); + } + let result = crate::acp::connect(server_name, command.clone(), &root_dir, cx).await; if result.is_err() { let version_fut = util::command::new_smol_command(&command.path) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index f4c0ce9784..12a33d022e 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -278,6 +278,7 @@ enum ThreadState { connection: Rc, description: Option>, configuration_view: Option, + pending_auth_method: Option, _subscription: Option, }, } @@ -563,6 +564,7 @@ impl AcpThreadView { this.update(cx, |this, cx| { this.thread_state = ThreadState::Unauthenticated { + pending_auth_method: None, connection, configuration_view, description: err @@ -999,12 +1001,74 @@ impl AcpThreadView { window: &mut Window, cx: &mut Context, ) { - let ThreadState::Unauthenticated { ref connection, .. } = self.thread_state else { + let ThreadState::Unauthenticated { + connection, + pending_auth_method, + configuration_view, + .. + } = &mut self.thread_state + else { return; }; + if method.0.as_ref() == "gemini-api-key" { + let registry = LanguageModelRegistry::global(cx); + let provider = registry + .read(cx) + .provider(&language_model::GOOGLE_PROVIDER_ID) + .unwrap(); + if !provider.is_authenticated(cx) { + let this = cx.weak_entity(); + let agent = self.agent.clone(); + let connection = connection.clone(); + window.defer(cx, |window, cx| { + Self::handle_auth_required( + this, + AuthRequired { + description: Some("GEMINI_API_KEY must be set".to_owned()), + provider_id: Some(language_model::GOOGLE_PROVIDER_ID), + }, + agent, + connection, + window, + cx, + ); + }); + return; + } + } else if method.0.as_ref() == "vertex-ai" + && std::env::var("GOOGLE_API_KEY").is_err() + && (std::env::var("GOOGLE_CLOUD_PROJECT").is_err() + || (std::env::var("GOOGLE_CLOUD_PROJECT").is_err())) + { + let this = cx.weak_entity(); + let agent = self.agent.clone(); + let connection = connection.clone(); + + window.defer(cx, |window, cx| { + Self::handle_auth_required( + this, + AuthRequired { + description: Some( + "GOOGLE_API_KEY must be set in the environment to use Vertex AI authentication for Gemini CLI. Please export it and restart Zed." + .to_owned(), + ), + provider_id: None, + }, + agent, + connection, + window, + cx, + ) + }); + return; + } + self.thread_error.take(); + configuration_view.take(); + pending_auth_method.replace(method.clone()); let authenticate = connection.authenticate(method, cx); + cx.notify(); self.auth_task = Some(cx.spawn_in(window, { let project = self.project.clone(); let agent = self.agent.clone(); @@ -2425,6 +2489,7 @@ impl AcpThreadView { connection: &Rc, description: Option<&Entity>, configuration_view: Option<&AnyView>, + pending_auth_method: Option<&acp::AuthMethodId>, window: &mut Window, cx: &Context, ) -> Div { @@ -2456,17 +2521,80 @@ impl AcpThreadView { .cloned() .map(|view| div().px_4().w_full().max_w_128().child(view)), ) - .child(h_flex().mt_1p5().justify_center().children( - connection.auth_methods().iter().map(|method| { - Button::new(SharedString::from(method.id.0.clone()), method.name.clone()) - .on_click({ - let method_id = method.id.clone(); - cx.listener(move |this, _, window, cx| { - this.authenticate(method_id.clone(), window, cx) + .when( + configuration_view.is_none() + && description.is_none() + && pending_auth_method.is_none(), + |el| { + el.child( + div() + .text_ui(cx) + .text_center() + .px_4() + .w_full() + .max_w_128() + .child(Label::new("Authentication required")), + ) + }, + ) + .when_some(pending_auth_method, |el, _| { + let spinner_icon = div() + .px_0p5() + .id("generating") + .tooltip(Tooltip::text("Generating Changes…")) + .child( + Icon::new(IconName::ArrowCircle) + .size(IconSize::Small) + .with_animation( + "arrow-circle", + Animation::new(Duration::from_secs(2)).repeat(), + |icon, delta| { + icon.transform(Transformation::rotate(percentage(delta))) + }, + ) + .into_any_element(), + ) + .into_any(); + el.child( + h_flex() + .text_ui(cx) + .text_center() + .justify_center() + .gap_2() + .px_4() + .w_full() + .max_w_128() + .child(Label::new("Authenticating...")) + .child(spinner_icon), + ) + }) + .child( + h_flex() + .mt_1p5() + .gap_1() + .flex_wrap() + .justify_center() + .children(connection.auth_methods().iter().enumerate().rev().map( + |(ix, method)| { + Button::new( + SharedString::from(method.id.0.clone()), + method.name.clone(), + ) + .style(ButtonStyle::Outlined) + .when(ix == 0, |el| { + el.style(ButtonStyle::Tinted(ui::TintColor::Accent)) }) - }) - }), - )) + .size(ButtonSize::Medium) + .label_size(LabelSize::Small) + .on_click({ + let method_id = method.id.clone(); + cx.listener(move |this, _, window, cx| { + this.authenticate(method_id.clone(), window, cx) + }) + }) + }, + )), + ) } fn render_load_error(&self, e: &LoadError, cx: &Context) -> AnyElement { @@ -2551,6 +2679,8 @@ impl AcpThreadView { let install_command = install_command.clone(); container = container.child( Button::new("install", install_message) + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .size(ButtonSize::Medium) .tooltip(Tooltip::text(install_command.clone())) .on_click(cx.listener(move |this, _, window, cx| { let task = this @@ -4372,11 +4502,13 @@ impl Render for AcpThreadView { connection, description, configuration_view, + pending_auth_method, .. } => self.render_auth_required_state( connection, description.as_ref(), configuration_view.as_ref(), + pending_auth_method.as_ref(), window, cx, ), diff --git a/crates/language_models/src/provider/google.rs b/crates/language_models/src/provider/google.rs index 1ac12b4cd4..566620675e 100644 --- a/crates/language_models/src/provider/google.rs +++ b/crates/language_models/src/provider/google.rs @@ -12,9 +12,9 @@ use gpui::{ }; use http_client::HttpClient; use language_model::{ - AuthenticateError, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelToolChoice, LanguageModelToolSchemaFormat, LanguageModelToolUse, - LanguageModelToolUseId, MessageContent, StopReason, + AuthenticateError, ConfigurationViewTargetAgent, LanguageModelCompletionError, + LanguageModelCompletionEvent, LanguageModelToolChoice, LanguageModelToolSchemaFormat, + LanguageModelToolUse, LanguageModelToolUseId, MessageContent, StopReason, }; use language_model::{ LanguageModel, LanguageModelId, LanguageModelName, LanguageModelProvider, @@ -37,6 +37,8 @@ use util::ResultExt; use crate::AllLanguageModelSettings; use crate::ui::InstructionListItem; +use super::anthropic::ApiKey; + const PROVIDER_ID: LanguageModelProviderId = language_model::GOOGLE_PROVIDER_ID; const PROVIDER_NAME: LanguageModelProviderName = language_model::GOOGLE_PROVIDER_NAME; @@ -198,6 +200,33 @@ impl GoogleLanguageModelProvider { request_limiter: RateLimiter::new(4), }) } + + pub fn api_key(cx: &mut App) -> Task> { + let credentials_provider = ::global(cx); + let api_url = AllLanguageModelSettings::get_global(cx) + .google + .api_url + .clone(); + + if let Ok(key) = std::env::var(GEMINI_API_KEY_VAR) { + Task::ready(Ok(ApiKey { + key, + from_env: true, + })) + } else { + cx.spawn(async move |cx| { + let (_, api_key) = credentials_provider + .read_credentials(&api_url, cx) + .await? + .ok_or(AuthenticateError::CredentialsNotFound)?; + + Ok(ApiKey { + key: String::from_utf8(api_key).context("invalid {PROVIDER_NAME} API key")?, + from_env: false, + }) + }) + } + } } impl LanguageModelProviderState for GoogleLanguageModelProvider { @@ -279,11 +308,11 @@ impl LanguageModelProvider for GoogleLanguageModelProvider { fn configuration_view( &self, - _target_agent: language_model::ConfigurationViewTargetAgent, + target_agent: language_model::ConfigurationViewTargetAgent, window: &mut Window, cx: &mut App, ) -> AnyView { - cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) + cx.new(|cx| ConfigurationView::new(self.state.clone(), target_agent, window, cx)) .into() } @@ -776,11 +805,17 @@ fn convert_usage(usage: &UsageMetadata) -> language_model::TokenUsage { struct ConfigurationView { api_key_editor: Entity, state: gpui::Entity, + target_agent: language_model::ConfigurationViewTargetAgent, load_credentials_task: Option>, } impl ConfigurationView { - fn new(state: gpui::Entity, window: &mut Window, cx: &mut Context) -> Self { + fn new( + state: gpui::Entity, + target_agent: language_model::ConfigurationViewTargetAgent, + window: &mut Window, + cx: &mut Context, + ) -> Self { cx.observe(&state, |_, _, cx| { cx.notify(); }) @@ -810,6 +845,7 @@ impl ConfigurationView { editor.set_placeholder_text("AIzaSy...", cx); editor }), + target_agent, state, load_credentials_task, } @@ -885,7 +921,10 @@ impl Render for ConfigurationView { v_flex() .size_full() .on_action(cx.listener(Self::save_api_key)) - .child(Label::new("To use Zed's agent with Google AI, you need to add an API key. Follow these steps:")) + .child(Label::new(format!("To use {}, you need to add an API key. Follow these steps:", match self.target_agent { + ConfigurationViewTargetAgent::ZedAgent => "Zed's agent with Google AI", + ConfigurationViewTargetAgent::Other(agent) => agent, + }))) .child( List::new() .child(InstructionListItem::new( From 69c5af09f49a7627782955864755d2fc8f44dcee Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Wed, 20 Aug 2025 19:30:25 -0300 Subject: [PATCH 10/89] acp: Supress gemini aborted errors (#36633) This PR adds a temporary workaround to supress "Aborted" errors from Gemini when cancelling generation. This won't be needed once https://github.com/google-gemini/gemini-cli/pull/6656 is generally available. Release Notes: - N/A --- Cargo.lock | 4 +- Cargo.toml | 2 +- crates/agent_servers/src/acp/v1.rs | 61 ++++++++++++++++++++++++++++-- 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 70b8f630f7..ef70a2c55e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -171,9 +171,9 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "0.0.28" +version = "0.0.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c887e795097665ab95119580534e7cc1335b59e1a7fec296943e534b970f4ed" +checksum = "89a2cd7e0bd2bb7ed27687cfcf6561b91542c1ce23e52fd54ee59b7568c9bd84" dependencies = [ "anyhow", "futures 0.3.31", diff --git a/Cargo.toml b/Cargo.toml index 436d4a7f5c..3f54745900 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -423,7 +423,7 @@ zlog_settings = { path = "crates/zlog_settings" } # agentic-coding-protocol = "0.0.10" -agent-client-protocol = "0.0.28" +agent-client-protocol = "0.0.29" aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" } any_vec = "0.14" diff --git a/crates/agent_servers/src/acp/v1.rs b/crates/agent_servers/src/acp/v1.rs index 2e70a5f37a..2cad1b5a87 100644 --- a/crates/agent_servers/src/acp/v1.rs +++ b/crates/agent_servers/src/acp/v1.rs @@ -1,11 +1,12 @@ use action_log::ActionLog; -use agent_client_protocol::{self as acp, Agent as _}; +use agent_client_protocol::{self as acp, Agent as _, ErrorCode}; use anyhow::anyhow; use collections::HashMap; use futures::AsyncBufReadExt as _; use futures::channel::oneshot; use futures::io::BufReader; use project::Project; +use serde::Deserialize; use std::path::Path; use std::rc::Rc; use std::{any::Any, cell::RefCell}; @@ -27,6 +28,7 @@ pub struct AcpConnection { pub struct AcpSession { thread: WeakEntity, + pending_cancel: bool, } const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::V1; @@ -171,6 +173,7 @@ impl AgentConnection for AcpConnection { let session = AcpSession { thread: thread.downgrade(), + pending_cancel: false, }; sessions.borrow_mut().insert(session_id, session); @@ -202,9 +205,48 @@ impl AgentConnection for AcpConnection { cx: &mut App, ) -> Task> { let conn = self.connection.clone(); + let sessions = self.sessions.clone(); + let session_id = params.session_id.clone(); cx.foreground_executor().spawn(async move { - let response = conn.prompt(params).await?; - Ok(response) + match conn.prompt(params).await { + Ok(response) => Ok(response), + Err(err) => { + if err.code != ErrorCode::INTERNAL_ERROR.code { + anyhow::bail!(err) + } + + let Some(data) = &err.data else { + anyhow::bail!(err) + }; + + // Temporary workaround until the following PR is generally available: + // https://github.com/google-gemini/gemini-cli/pull/6656 + + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct ErrorDetails { + details: Box, + } + + match serde_json::from_value(data.clone()) { + Ok(ErrorDetails { details }) => { + if sessions + .borrow() + .get(&session_id) + .is_some_and(|session| session.pending_cancel) + && details.contains("This operation was aborted") + { + Ok(acp::PromptResponse { + stop_reason: acp::StopReason::Canceled, + }) + } else { + Err(anyhow!(details)) + } + } + Err(_) => Err(anyhow!(err)), + } + } + } }) } @@ -213,12 +255,23 @@ impl AgentConnection for AcpConnection { } fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) { + if let Some(session) = self.sessions.borrow_mut().get_mut(session_id) { + session.pending_cancel = true; + } let conn = self.connection.clone(); let params = acp::CancelNotification { session_id: session_id.clone(), }; + let sessions = self.sessions.clone(); + let session_id = session_id.clone(); cx.foreground_executor() - .spawn(async move { conn.cancel(params).await }) + .spawn(async move { + let resp = conn.cancel(params).await; + if let Some(session) = sessions.borrow_mut().get_mut(&session_id) { + session.pending_cancel = false; + } + resp + }) .detach(); } From ca5f543763438ad1e4a9c6396b65553d5011f223 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Wed, 20 Aug 2025 18:44:59 -0400 Subject: [PATCH 11/89] zed 0.201.1 --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ef70a2c55e..e6cec32114 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20387,7 +20387,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.201.0" +version = "0.201.1" dependencies = [ "activity_indicator", "agent", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index d69efaf6c0..7f34c01f32 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "0.201.0" +version = "0.201.1" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] From e120ff6673066f956754a2ecc7978be52e512cef Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Wed, 20 Aug 2025 20:32:17 -0300 Subject: [PATCH 12/89] acp: Reliably suppress gemini abort error (#36640) https://github.com/zed-industries/zed/pull/36633 relied on the prompt request responding before cancel, but that's not guaranteed Release Notes: - N/A --- crates/agent_servers/src/acp/v1.rs | 33 ++++++++++++++---------------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/crates/agent_servers/src/acp/v1.rs b/crates/agent_servers/src/acp/v1.rs index 2cad1b5a87..bc11a3748a 100644 --- a/crates/agent_servers/src/acp/v1.rs +++ b/crates/agent_servers/src/acp/v1.rs @@ -28,7 +28,7 @@ pub struct AcpConnection { pub struct AcpSession { thread: WeakEntity, - pending_cancel: bool, + suppress_abort_err: bool, } const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::V1; @@ -173,7 +173,7 @@ impl AgentConnection for AcpConnection { let session = AcpSession { thread: thread.downgrade(), - pending_cancel: false, + suppress_abort_err: false, }; sessions.borrow_mut().insert(session_id, session); @@ -208,7 +208,16 @@ impl AgentConnection for AcpConnection { let sessions = self.sessions.clone(); let session_id = params.session_id.clone(); cx.foreground_executor().spawn(async move { - match conn.prompt(params).await { + let result = conn.prompt(params).await; + + let mut suppress_abort_err = false; + + if let Some(session) = sessions.borrow_mut().get_mut(&session_id) { + suppress_abort_err = session.suppress_abort_err; + session.suppress_abort_err = false; + } + + match result { Ok(response) => Ok(response), Err(err) => { if err.code != ErrorCode::INTERNAL_ERROR.code { @@ -230,11 +239,7 @@ impl AgentConnection for AcpConnection { match serde_json::from_value(data.clone()) { Ok(ErrorDetails { details }) => { - if sessions - .borrow() - .get(&session_id) - .is_some_and(|session| session.pending_cancel) - && details.contains("This operation was aborted") + if suppress_abort_err && details.contains("This operation was aborted") { Ok(acp::PromptResponse { stop_reason: acp::StopReason::Canceled, @@ -256,22 +261,14 @@ impl AgentConnection for AcpConnection { fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) { if let Some(session) = self.sessions.borrow_mut().get_mut(session_id) { - session.pending_cancel = true; + session.suppress_abort_err = true; } let conn = self.connection.clone(); let params = acp::CancelNotification { session_id: session_id.clone(), }; - let sessions = self.sessions.clone(); - let session_id = session_id.clone(); cx.foreground_executor() - .spawn(async move { - let resp = conn.cancel(params).await; - if let Some(session) = sessions.borrow_mut().get_mut(&session_id) { - session.pending_cancel = false; - } - resp - }) + .spawn(async move { conn.cancel(params).await }) .detach(); } From 5e27924b0bb02fe277a17d753a2ed54c351ef700 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Wed, 20 Aug 2025 21:10:36 -0300 Subject: [PATCH 13/89] acp: Update to 0.0.30 (#36643) See: https://github.com/zed-industries/agent-client-protocol/pull/20 Release Notes: - N/A --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- crates/acp_thread/src/acp_thread.rs | 2 +- crates/acp_thread/src/connection.rs | 2 +- crates/agent2/src/tests/mod.rs | 4 ++-- crates/agent2/src/thread.rs | 2 +- crates/agent_servers/src/acp/v1.rs | 4 ++-- crates/agent_servers/src/claude.rs | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e6cec32114..e98ff9ddca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -171,9 +171,9 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "0.0.29" +version = "0.0.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a2cd7e0bd2bb7ed27687cfcf6561b91542c1ce23e52fd54ee59b7568c9bd84" +checksum = "5f792e009ba59b137ee1db560bc37e567887ad4b5af6f32181d381fff690e2d4" dependencies = [ "anyhow", "futures 0.3.31", diff --git a/Cargo.toml b/Cargo.toml index 3f54745900..d458a4752c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -423,7 +423,7 @@ zlog_settings = { path = "crates/zlog_settings" } # agentic-coding-protocol = "0.0.10" -agent-client-protocol = "0.0.29" +agent-client-protocol = "0.0.30" aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" } any_vec = "0.14" diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 9833e1957c..61bc50576a 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -1381,7 +1381,7 @@ impl AcpThread { let canceled = matches!( result, Ok(Ok(acp::PromptResponse { - stop_reason: acp::StopReason::Canceled + stop_reason: acp::StopReason::Cancelled })) ); diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 791b161417..2bbd364873 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -420,7 +420,7 @@ mod test_support { .response_tx .take() { - end_turn_tx.send(acp::StopReason::Canceled).unwrap(); + end_turn_tx.send(acp::StopReason::Cancelled).unwrap(); } } diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index 478604b14a..3bd1be497e 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -975,7 +975,7 @@ async fn test_cancellation(cx: &mut TestAppContext) { assert!( matches!( last_event, - Some(Ok(ThreadEvent::Stop(acp::StopReason::Canceled))) + Some(Ok(ThreadEvent::Stop(acp::StopReason::Cancelled))) ), "unexpected event {last_event:?}" ); @@ -1029,7 +1029,7 @@ async fn test_in_progress_send_canceled_by_next_send(cx: &mut TestAppContext) { fake_model.end_last_completion_stream(); let events_1 = events_1.collect::>().await; - assert_eq!(stop_events(events_1), vec![acp::StopReason::Canceled]); + assert_eq!(stop_events(events_1), vec![acp::StopReason::Cancelled]); let events_2 = events_2.collect::>().await; assert_eq!(stop_events(events_2), vec![acp::StopReason::EndTurn]); } diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 62174fd3b4..d34c929152 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -2248,7 +2248,7 @@ impl ThreadEventStream { fn send_canceled(&self) { self.0 - .unbounded_send(Ok(ThreadEvent::Stop(acp::StopReason::Canceled))) + .unbounded_send(Ok(ThreadEvent::Stop(acp::StopReason::Cancelled))) .ok(); } diff --git a/crates/agent_servers/src/acp/v1.rs b/crates/agent_servers/src/acp/v1.rs index bc11a3748a..29f389547d 100644 --- a/crates/agent_servers/src/acp/v1.rs +++ b/crates/agent_servers/src/acp/v1.rs @@ -242,7 +242,7 @@ impl AgentConnection for AcpConnection { if suppress_abort_err && details.contains("This operation was aborted") { Ok(acp::PromptResponse { - stop_reason: acp::StopReason::Canceled, + stop_reason: acp::StopReason::Cancelled, }) } else { Err(anyhow!(details)) @@ -302,7 +302,7 @@ impl acp::Client for ClientDelegate { let outcome = match result { Ok(option) => acp::RequestPermissionOutcome::Selected { option_id: option }, - Err(oneshot::Canceled) => acp::RequestPermissionOutcome::Canceled, + Err(oneshot::Canceled) => acp::RequestPermissionOutcome::Cancelled, }; Ok(acp::RequestPermissionResponse { outcome }) diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index 8d93557e1c..c9290e0ba5 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -705,7 +705,7 @@ impl ClaudeAgentSession { let stop_reason = match subtype { ResultErrorType::Success => acp::StopReason::EndTurn, ResultErrorType::ErrorMaxTurns => acp::StopReason::MaxTurnRequests, - ResultErrorType::ErrorDuringExecution => acp::StopReason::Canceled, + ResultErrorType::ErrorDuringExecution => acp::StopReason::Cancelled, }; end_turn_tx .send(Ok(acp::PromptResponse { stop_reason })) From 1c91d4b17cc42298af56fc3101bc54387c68cfde Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Thu, 21 Aug 2025 11:37:45 +0200 Subject: [PATCH 14/89] remote: Fix toolchain RPC messages not being handled because of the entity getting dropped (#36665) Release Notes: - N/A --- crates/project/src/toolchain_store.rs | 73 ++++++++++++------- crates/remote_server/src/headless_project.rs | 4 + .../src/active_toolchain.rs | 11 --- 3 files changed, 49 insertions(+), 39 deletions(-) diff --git a/crates/project/src/toolchain_store.rs b/crates/project/src/toolchain_store.rs index 05531ebe9a..ac87e64248 100644 --- a/crates/project/src/toolchain_store.rs +++ b/crates/project/src/toolchain_store.rs @@ -34,7 +34,10 @@ enum ToolchainStoreInner { Entity, #[allow(dead_code)] Subscription, ), - Remote(Entity), + Remote( + Entity, + #[allow(dead_code)] Subscription, + ), } impl EventEmitter for ToolchainStore {} @@ -65,10 +68,12 @@ impl ToolchainStore { Self(ToolchainStoreInner::Local(entity, subscription)) } - pub(super) fn remote(project_id: u64, client: AnyProtoClient, cx: &mut App) -> Self { - Self(ToolchainStoreInner::Remote( - cx.new(|_| RemoteToolchainStore { client, project_id }), - )) + pub(super) fn remote(project_id: u64, client: AnyProtoClient, cx: &mut Context) -> Self { + let entity = cx.new(|_| RemoteToolchainStore { client, project_id }); + let _subscription = cx.subscribe(&entity, |_, _, e: &ToolchainStoreEvent, cx| { + cx.emit(e.clone()) + }); + Self(ToolchainStoreInner::Remote(entity, _subscription)) } pub(crate) fn activate_toolchain( &self, @@ -80,8 +85,8 @@ impl ToolchainStore { ToolchainStoreInner::Local(local, _) => { local.update(cx, |this, cx| this.activate_toolchain(path, toolchain, cx)) } - ToolchainStoreInner::Remote(remote) => { - remote.read(cx).activate_toolchain(path, toolchain, cx) + ToolchainStoreInner::Remote(remote, _) => { + remote.update(cx, |this, cx| this.activate_toolchain(path, toolchain, cx)) } } } @@ -95,7 +100,7 @@ impl ToolchainStore { ToolchainStoreInner::Local(local, _) => { local.update(cx, |this, cx| this.list_toolchains(path, language_name, cx)) } - ToolchainStoreInner::Remote(remote) => { + ToolchainStoreInner::Remote(remote, _) => { remote.read(cx).list_toolchains(path, language_name, cx) } } @@ -112,7 +117,7 @@ impl ToolchainStore { &path.path, language_name, )), - ToolchainStoreInner::Remote(remote) => { + ToolchainStoreInner::Remote(remote, _) => { remote.read(cx).active_toolchain(path, language_name, cx) } } @@ -234,13 +239,13 @@ impl ToolchainStore { pub fn as_language_toolchain_store(&self) -> Arc { match &self.0 { ToolchainStoreInner::Local(local, _) => Arc::new(LocalStore(local.downgrade())), - ToolchainStoreInner::Remote(remote) => Arc::new(RemoteStore(remote.downgrade())), + ToolchainStoreInner::Remote(remote, _) => Arc::new(RemoteStore(remote.downgrade())), } } pub fn as_local_store(&self) -> Option<&Entity> { match &self.0 { ToolchainStoreInner::Local(local, _) => Some(local), - ToolchainStoreInner::Remote(_) => None, + ToolchainStoreInner::Remote(_, _) => None, } } } @@ -415,6 +420,8 @@ impl LocalToolchainStore { .cloned() } } + +impl EventEmitter for RemoteToolchainStore {} struct RemoteToolchainStore { client: AnyProtoClient, project_id: u64, @@ -425,27 +432,37 @@ impl RemoteToolchainStore { &self, project_path: ProjectPath, toolchain: Toolchain, - cx: &App, + cx: &mut Context, ) -> Task> { let project_id = self.project_id; let client = self.client.clone(); - cx.background_spawn(async move { - let path = PathBuf::from(toolchain.path.to_string()); - let _ = client - .request(proto::ActivateToolchain { - project_id, - worktree_id: project_path.worktree_id.to_proto(), - language_name: toolchain.language_name.into(), - toolchain: Some(proto::Toolchain { - name: toolchain.name.into(), - path: path.to_proto(), - raw_json: toolchain.as_json.to_string(), - }), - path: Some(project_path.path.to_string_lossy().into_owned()), + cx.spawn(async move |this, cx| { + let did_activate = cx + .background_spawn(async move { + let path = PathBuf::from(toolchain.path.to_string()); + let _ = client + .request(proto::ActivateToolchain { + project_id, + worktree_id: project_path.worktree_id.to_proto(), + language_name: toolchain.language_name.into(), + toolchain: Some(proto::Toolchain { + name: toolchain.name.into(), + path: path.to_proto(), + raw_json: toolchain.as_json.to_string(), + }), + path: Some(project_path.path.to_string_lossy().into_owned()), + }) + .await + .log_err()?; + Some(()) }) - .await - .log_err()?; - Some(()) + .await; + did_activate.and_then(|_| { + this.update(cx, |_, cx| { + cx.emit(ToolchainStoreEvent::ToolchainActivated); + }) + .ok() + }) }) } diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index 83caebe62f..6216ff7728 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -46,6 +46,9 @@ pub struct HeadlessProject { pub languages: Arc, pub extensions: Entity, pub git_store: Entity, + // Used mostly to keep alive the toolchain store for RPC handlers. + // Local variant is used within LSP store, but that's a separate entity. + pub _toolchain_store: Entity, } pub struct HeadlessAppState { @@ -269,6 +272,7 @@ impl HeadlessProject { languages, extensions, git_store, + _toolchain_store: toolchain_store, } } diff --git a/crates/toolchain_selector/src/active_toolchain.rs b/crates/toolchain_selector/src/active_toolchain.rs index ea5dcc2a19..bf45bffea3 100644 --- a/crates/toolchain_selector/src/active_toolchain.rs +++ b/crates/toolchain_selector/src/active_toolchain.rs @@ -38,7 +38,6 @@ impl ActiveToolchain { .ok() .flatten(); if let Some(editor) = editor { - this.active_toolchain.take(); this.update_lister(editor, window, cx); } }, @@ -124,16 +123,6 @@ impl ActiveToolchain { if let Some((_, buffer, _)) = editor.active_excerpt(cx) && let Some(worktree_id) = buffer.read(cx).file().map(|file| file.worktree_id(cx)) { - if self - .active_buffer - .as_ref() - .is_some_and(|(old_worktree_id, old_buffer, _)| { - (old_worktree_id, old_buffer.entity_id()) == (&worktree_id, buffer.entity_id()) - }) - { - return; - } - let subscription = cx.subscribe_in( &buffer, window, From 7f0ce7c6defbfec9519250b323a756316b8aac92 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 21 Aug 2025 02:36:50 +0200 Subject: [PATCH 15/89] acp: Add e2e test support for NativeAgent (#36635) Release Notes: - N/A --- Cargo.lock | 4 + crates/agent2/Cargo.toml | 2 + crates/agent2/src/native_agent_server.rs | 49 ++++++++ crates/agent_servers/Cargo.toml | 11 +- crates/agent_servers/src/agent_servers.rs | 4 +- crates/agent_servers/src/claude.rs | 2 +- crates/agent_servers/src/e2e_tests.rs | 134 +++++++++++++++++----- crates/agent_servers/src/gemini.rs | 2 +- 8 files changed, 172 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e98ff9ddca..375793feb9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -268,11 +268,14 @@ dependencies = [ "agent_settings", "agentic-coding-protocol", "anyhow", + "client", "collections", "context_server", "env_logger 0.11.8", + "fs", "futures 0.3.31", "gpui", + "gpui_tokio", "indoc", "itertools 0.14.0", "language", @@ -284,6 +287,7 @@ dependencies = [ "paths", "project", "rand 0.8.5", + "reqwest_client", "schemars", "semver", "serde", diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml index 2a5d879e9e..8dd79062f8 100644 --- a/crates/agent2/Cargo.toml +++ b/crates/agent2/Cargo.toml @@ -10,6 +10,7 @@ path = "src/agent2.rs" [features] test-support = ["db/test-support"] +e2e = [] [lints] workspace = true @@ -72,6 +73,7 @@ zstd.workspace = true [dev-dependencies] agent = { workspace = true, "features" = ["test-support"] } +agent_servers = { workspace = true, "features" = ["test-support"] } assistant_context = { workspace = true, "features" = ["test-support"] } ctor.workspace = true client = { workspace = true, "features" = ["test-support"] } diff --git a/crates/agent2/src/native_agent_server.rs b/crates/agent2/src/native_agent_server.rs index a1f935589a..ac5aa95c04 100644 --- a/crates/agent2/src/native_agent_server.rs +++ b/crates/agent2/src/native_agent_server.rs @@ -73,3 +73,52 @@ impl AgentServer for NativeAgentServer { self } } + +#[cfg(test)] +mod tests { + use super::*; + + use assistant_context::ContextStore; + use gpui::AppContext; + + agent_servers::e2e_tests::common_e2e_tests!( + async |fs, project, cx| { + let auth = cx.update(|cx| { + prompt_store::init(cx); + terminal::init(cx); + + let registry = language_model::LanguageModelRegistry::read_global(cx); + let auth = registry + .provider(&language_model::ANTHROPIC_PROVIDER_ID) + .unwrap() + .authenticate(cx); + + cx.spawn(async move |_| auth.await) + }); + + auth.await.unwrap(); + + cx.update(|cx| { + let registry = language_model::LanguageModelRegistry::global(cx); + + registry.update(cx, |registry, cx| { + registry.select_default_model( + Some(&language_model::SelectedModel { + provider: language_model::ANTHROPIC_PROVIDER_ID, + model: language_model::LanguageModelId("claude-sonnet-4-latest".into()), + }), + cx, + ); + }); + }); + + let history = cx.update(|cx| { + let context_store = cx.new(move |cx| ContextStore::fake(project.clone(), cx)); + cx.new(move |cx| HistoryStore::new(context_store, cx)) + }); + + NativeAgentServer::new(fs.clone(), history) + }, + allow_option_id = "allow" + ); +} diff --git a/crates/agent_servers/Cargo.toml b/crates/agent_servers/Cargo.toml index b654486cb6..60dd796463 100644 --- a/crates/agent_servers/Cargo.toml +++ b/crates/agent_servers/Cargo.toml @@ -6,7 +6,7 @@ publish.workspace = true license = "GPL-3.0-or-later" [features] -test-support = ["acp_thread/test-support", "gpui/test-support", "project/test-support"] +test-support = ["acp_thread/test-support", "gpui/test-support", "project/test-support", "dep:env_logger", "fs", "client/test-support", "dep:gpui_tokio", "reqwest_client/test-support"] e2e = [] [lints] @@ -23,10 +23,14 @@ agent-client-protocol.workspace = true agent_settings.workspace = true agentic-coding-protocol.workspace = true anyhow.workspace = true +client = { workspace = true, optional = true } collections.workspace = true context_server.workspace = true +env_logger = { workspace = true, optional = true } +fs = { workspace = true, optional = true } futures.workspace = true gpui.workspace = true +gpui_tokio = { workspace = true, optional = true } indoc.workspace = true itertools.workspace = true language.workspace = true @@ -36,6 +40,7 @@ log.workspace = true paths.workspace = true project.workspace = true rand.workspace = true +reqwest_client = { workspace = true, optional = true } schemars.workspace = true semver.workspace = true serde.workspace = true @@ -57,8 +62,12 @@ libc.workspace = true nix.workspace = true [dev-dependencies] +client = { workspace = true, features = ["test-support"] } env_logger.workspace = true +fs.workspace = true language.workspace = true indoc.workspace = true acp_thread = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } +gpui_tokio.workspace = true +reqwest_client = { workspace = true, features = ["test-support"] } diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index cebf82cddb..2f5ec478ae 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -3,8 +3,8 @@ mod claude; mod gemini; mod settings; -#[cfg(test)] -mod e2e_tests; +#[cfg(any(test, feature = "test-support"))] +pub mod e2e_tests; pub use claude::*; pub use gemini::*; diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index c9290e0ba5..ef666974f1 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -1093,7 +1093,7 @@ pub(crate) mod tests { use gpui::TestAppContext; use serde_json::json; - crate::common_e2e_tests!(ClaudeCode, allow_option_id = "allow"); + crate::common_e2e_tests!(async |_, _, _| ClaudeCode, allow_option_id = "allow"); pub fn local_command() -> AgentServerCommand { AgentServerCommand { diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index 8b2703575d..c271079071 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -4,21 +4,30 @@ use std::{ time::Duration, }; -use crate::{AgentServer, AgentServerSettings, AllAgentServersSettings}; +use crate::AgentServer; use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus}; use agent_client_protocol as acp; use futures::{FutureExt, StreamExt, channel::mpsc, select}; -use gpui::{Entity, TestAppContext}; +use gpui::{AppContext, Entity, TestAppContext}; use indoc::indoc; use project::{FakeFs, Project}; -use settings::{Settings, SettingsStore}; use util::path; -pub async fn test_basic(server: impl AgentServer + 'static, cx: &mut TestAppContext) { - let fs = init_test(cx).await; - let project = Project::test(fs, [], cx).await; - let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await; +pub async fn test_basic(server: F, cx: &mut TestAppContext) +where + T: AgentServer + 'static, + F: AsyncFn(&Arc, &Entity, &mut TestAppContext) -> T, +{ + let fs = init_test(cx).await as Arc; + let project = Project::test(fs.clone(), [], cx).await; + let thread = new_test_thread( + server(&fs, &project, cx).await, + project.clone(), + "/private/tmp", + cx, + ) + .await; thread .update(cx, |thread, cx| thread.send_raw("Hello from Zed!", cx)) @@ -42,8 +51,12 @@ pub async fn test_basic(server: impl AgentServer + 'static, cx: &mut TestAppCont }); } -pub async fn test_path_mentions(server: impl AgentServer + 'static, cx: &mut TestAppContext) { - let _fs = init_test(cx).await; +pub async fn test_path_mentions(server: F, cx: &mut TestAppContext) +where + T: AgentServer + 'static, + F: AsyncFn(&Arc, &Entity, &mut TestAppContext) -> T, +{ + let fs = init_test(cx).await as _; let tempdir = tempfile::tempdir().unwrap(); std::fs::write( @@ -56,7 +69,13 @@ pub async fn test_path_mentions(server: impl AgentServer + 'static, cx: &mut Tes ) .expect("failed to write file"); let project = Project::example([tempdir.path()], &mut cx.to_async()).await; - let thread = new_test_thread(server, project.clone(), tempdir.path(), cx).await; + let thread = new_test_thread( + server(&fs, &project, cx).await, + project.clone(), + tempdir.path(), + cx, + ) + .await; thread .update(cx, |thread, cx| { thread.send( @@ -110,15 +129,25 @@ pub async fn test_path_mentions(server: impl AgentServer + 'static, cx: &mut Tes drop(tempdir); } -pub async fn test_tool_call(server: impl AgentServer + 'static, cx: &mut TestAppContext) { - let _fs = init_test(cx).await; +pub async fn test_tool_call(server: F, cx: &mut TestAppContext) +where + T: AgentServer + 'static, + F: AsyncFn(&Arc, &Entity, &mut TestAppContext) -> T, +{ + let fs = init_test(cx).await as _; let tempdir = tempfile::tempdir().unwrap(); let foo_path = tempdir.path().join("foo"); std::fs::write(&foo_path, "Lorem ipsum dolor").expect("failed to write file"); let project = Project::example([tempdir.path()], &mut cx.to_async()).await; - let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await; + let thread = new_test_thread( + server(&fs, &project, cx).await, + project.clone(), + "/private/tmp", + cx, + ) + .await; thread .update(cx, |thread, cx| { @@ -152,14 +181,23 @@ pub async fn test_tool_call(server: impl AgentServer + 'static, cx: &mut TestApp drop(tempdir); } -pub async fn test_tool_call_with_permission( - server: impl AgentServer + 'static, +pub async fn test_tool_call_with_permission( + server: F, allow_option_id: acp::PermissionOptionId, cx: &mut TestAppContext, -) { - let fs = init_test(cx).await; - let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await; - let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await; +) where + T: AgentServer + 'static, + F: AsyncFn(&Arc, &Entity, &mut TestAppContext) -> T, +{ + let fs = init_test(cx).await as Arc; + let project = Project::test(fs.clone(), [path!("/private/tmp").as_ref()], cx).await; + let thread = new_test_thread( + server(&fs, &project, cx).await, + project.clone(), + "/private/tmp", + cx, + ) + .await; let full_turn = thread.update(cx, |thread, cx| { thread.send_raw( r#"Run exactly `touch hello.txt && echo "Hello, world!" | tee hello.txt` in the terminal."#, @@ -247,11 +285,21 @@ pub async fn test_tool_call_with_permission( }); } -pub async fn test_cancel(server: impl AgentServer + 'static, cx: &mut TestAppContext) { - let fs = init_test(cx).await; +pub async fn test_cancel(server: F, cx: &mut TestAppContext) +where + T: AgentServer + 'static, + F: AsyncFn(&Arc, &Entity, &mut TestAppContext) -> T, +{ + let fs = init_test(cx).await as Arc; - let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await; - let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await; + let project = Project::test(fs.clone(), [path!("/private/tmp").as_ref()], cx).await; + let thread = new_test_thread( + server(&fs, &project, cx).await, + project.clone(), + "/private/tmp", + cx, + ) + .await; let _ = thread.update(cx, |thread, cx| { thread.send_raw( r#"Run exactly `touch hello.txt && echo "Hello, world!" | tee hello.txt` in the terminal."#, @@ -316,10 +364,20 @@ pub async fn test_cancel(server: impl AgentServer + 'static, cx: &mut TestAppCon }); } -pub async fn test_thread_drop(server: impl AgentServer + 'static, cx: &mut TestAppContext) { - let fs = init_test(cx).await; - let project = Project::test(fs, [], cx).await; - let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await; +pub async fn test_thread_drop(server: F, cx: &mut TestAppContext) +where + T: AgentServer + 'static, + F: AsyncFn(&Arc, &Entity, &mut TestAppContext) -> T, +{ + let fs = init_test(cx).await as Arc; + let project = Project::test(fs.clone(), [], cx).await; + let thread = new_test_thread( + server(&fs, &project, cx).await, + project.clone(), + "/private/tmp", + cx, + ) + .await; thread .update(cx, |thread, cx| thread.send_raw("Hello from test!", cx)) @@ -386,25 +444,39 @@ macro_rules! common_e2e_tests { } }; } +pub use common_e2e_tests; // Helpers pub async fn init_test(cx: &mut TestAppContext) -> Arc { + #[cfg(test)] + use settings::Settings; + env_logger::try_init().ok(); cx.update(|cx| { - let settings_store = SettingsStore::test(cx); + let settings_store = settings::SettingsStore::test(cx); cx.set_global(settings_store); Project::init_settings(cx); language::init(cx); + gpui_tokio::init(cx); + let http_client = reqwest_client::ReqwestClient::user_agent("agent tests").unwrap(); + cx.set_http_client(Arc::new(http_client)); + client::init_settings(cx); + let client = client::Client::production(cx); + let user_store = cx.new(|cx| client::UserStore::new(client.clone(), cx)); + language_model::init(client.clone(), cx); + language_models::init(user_store, client, cx); + agent_settings::init(cx); crate::settings::init(cx); + #[cfg(test)] crate::AllAgentServersSettings::override_global( - AllAgentServersSettings { - claude: Some(AgentServerSettings { + crate::AllAgentServersSettings { + claude: Some(crate::AgentServerSettings { command: crate::claude::tests::local_command(), }), - gemini: Some(AgentServerSettings { + gemini: Some(crate::AgentServerSettings { command: crate::gemini::tests::local_command(), }), }, diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index d30525328b..1a63322fac 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -108,7 +108,7 @@ pub(crate) mod tests { use crate::AgentServerCommand; use std::path::Path; - crate::common_e2e_tests!(Gemini, allow_option_id = "proceed_once"); + crate::common_e2e_tests!(async |_, _, _| Gemini, allow_option_id = "proceed_once"); pub fn local_command() -> AgentServerCommand { let cli_path = Path::new(env!("CARGO_MANIFEST_DIR")) From b7783efc779e4f89e5e392ca41e387e459c3e2a1 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Wed, 20 Aug 2025 21:52:38 -0300 Subject: [PATCH 16/89] acp: Suggest upgrading to preview instead of latest (#36648) A previous PR changed the install command from `@latest` to `@preview`, but the upgrade command kept suggesting `@latest`. Release Notes: - N/A --- crates/agent_servers/src/gemini.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index 1a63322fac..3b892e7931 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -89,7 +89,7 @@ impl AgentServer for Gemini { current_version ).into(), upgrade_message: "Upgrade Gemini CLI to latest".into(), - upgrade_command: "npm install -g @google/gemini-cli@latest".into(), + upgrade_command: "npm install -g @google/gemini-cli@preview".into(), }.into()) } } From 8c6a1d143c2b9b1408639219f5fc69a1dd877b71 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 21 Aug 2025 10:57:28 +0200 Subject: [PATCH 17/89] Fix @-mentioning threads when their summary isn't ready yet (#36664) Release Notes: - N/A --- crates/agent2/src/agent.rs | 10 ++-------- crates/agent_ui/src/acp/message_editor.rs | 9 +++------ 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index c15048ad8c..d5bc0fea63 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -1269,18 +1269,12 @@ mod tests { let model = Arc::new(FakeLanguageModel::default()); let summary_model = Arc::new(FakeLanguageModel::default()); thread.update(cx, |thread, cx| { - thread.set_model(model, cx); - thread.set_summarization_model(Some(summary_model), cx); + thread.set_model(model.clone(), cx); + thread.set_summarization_model(Some(summary_model.clone()), cx); }); cx.run_until_parked(); assert_eq!(history_entries(&history_store, cx), vec![]); - let model = thread.read_with(cx, |thread, _| thread.model().unwrap().clone()); - let model = model.as_fake(); - let summary_model = thread.read_with(cx, |thread, _| { - thread.summarization_model().unwrap().clone() - }); - let summary_model = summary_model.as_fake(); let send = acp_thread.update(cx, |thread, cx| { thread.send( vec![ diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index be133808b7..03769f6515 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -629,15 +629,11 @@ impl MessageEditor { .shared(); self.mention_set.insert_thread(id.clone(), task.clone()); + self.mention_set.insert_uri(crease_id, uri); let editor = self.editor.clone(); cx.spawn_in(window, async move |this, cx| { - if task.await.notify_async_err(cx).is_some() { - this.update(cx, |this, _| { - this.mention_set.insert_uri(crease_id, uri); - }) - .ok(); - } else { + if task.await.notify_async_err(cx).is_none() { editor .update(cx, |editor, cx| { editor.display_map.update(cx, |display_map, cx| { @@ -648,6 +644,7 @@ impl MessageEditor { .ok(); this.update(cx, |this, _| { this.mention_set.thread_summaries.remove(&id); + this.mention_set.uri_by_crease_id.remove(&crease_id); }) .ok(); } From 90946aeb2a138b51ead3378110771933b236daee Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 21 Aug 2025 11:25:00 +0200 Subject: [PATCH 18/89] agent2: Allow expanding terminals individually (#36670) Release Notes: - N/A --- crates/agent_ui/src/acp/entry_view_state.rs | 10 ++++-- crates/agent_ui/src/acp/thread_view.rs | 36 ++++++++++++++------- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/crates/agent_ui/src/acp/entry_view_state.rs b/crates/agent_ui/src/acp/entry_view_state.rs index 67acbb8b5b..fb15d8bed8 100644 --- a/crates/agent_ui/src/acp/entry_view_state.rs +++ b/crates/agent_ui/src/acp/entry_view_state.rs @@ -121,14 +121,19 @@ impl EntryViewState { for terminal in terminals { views.entry(terminal.entity_id()).or_insert_with(|| { - create_terminal( + let element = create_terminal( self.workspace.clone(), self.project.clone(), terminal.clone(), window, cx, ) - .into_any() + .into_any(); + cx.emit(EntryViewEvent { + entry_index: index, + view_event: ViewEvent::NewTerminal(terminal.entity_id()), + }); + element }); } @@ -187,6 +192,7 @@ pub struct EntryViewEvent { } pub enum ViewEvent { + NewTerminal(EntityId), MessageEditorEvent(Entity, MessageEditorEvent), } diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 12a33d022e..432ba4e0e8 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -20,11 +20,11 @@ use file_icons::FileIcons; use fs::Fs; use gpui::{ Action, Animation, AnimationExt, AnyView, App, BorderStyle, ClickEvent, ClipboardItem, - EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length, ListOffset, - ListState, MouseButton, PlatformDisplay, SharedString, Stateful, StyleRefinement, Subscription, - Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, Window, - WindowHandle, div, ease_in_out, linear_color_stop, linear_gradient, list, percentage, point, - prelude::*, pulsating_between, + EdgesRefinement, ElementId, Empty, Entity, EntityId, FocusHandle, Focusable, Hsla, Length, + ListOffset, ListState, MouseButton, PlatformDisplay, SharedString, Stateful, StyleRefinement, + Subscription, Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, + Window, WindowHandle, div, ease_in_out, linear_color_stop, linear_gradient, list, percentage, + point, prelude::*, pulsating_between, }; use language::Buffer; @@ -256,10 +256,10 @@ pub struct AcpThreadView { auth_task: Option>, expanded_tool_calls: HashSet, expanded_thinking_blocks: HashSet<(usize, usize)>, + expanded_terminals: HashSet, edits_expanded: bool, plan_expanded: bool, editor_expanded: bool, - terminal_expanded: bool, editing_message: Option, _cancel_task: Option>, _subscriptions: [Subscription; 3], @@ -354,11 +354,11 @@ impl AcpThreadView { auth_task: None, expanded_tool_calls: HashSet::default(), expanded_thinking_blocks: HashSet::default(), + expanded_terminals: HashSet::default(), editing_message: None, edits_expanded: false, plan_expanded: false, editor_expanded: false, - terminal_expanded: true, history_store, hovered_recent_history_item: None, _subscriptions: subscriptions, @@ -677,6 +677,11 @@ impl AcpThreadView { cx: &mut Context, ) { match &event.view_event { + ViewEvent::NewTerminal(terminal_id) => { + if AgentSettings::get_global(cx).expand_terminal_card { + self.expanded_terminals.insert(*terminal_id); + } + } ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Focus) => { if let Some(thread) = self.thread() && let Some(AgentThreadEntry::UserMessage(user_message)) = @@ -2009,6 +2014,8 @@ impl AcpThreadView { .map(|path| format!("{}", path.display())) .unwrap_or_else(|| "current directory".to_string()); + let is_expanded = self.expanded_terminals.contains(&terminal.entity_id()); + let header = h_flex() .id(SharedString::from(format!( "terminal-tool-header-{}", @@ -2142,12 +2149,19 @@ impl AcpThreadView { "terminal-tool-disclosure-{}", terminal.entity_id() )), - self.terminal_expanded, + is_expanded, ) .opened_icon(IconName::ChevronUp) .closed_icon(IconName::ChevronDown) - .on_click(cx.listener(move |this, _event, _window, _cx| { - this.terminal_expanded = !this.terminal_expanded; + .on_click(cx.listener({ + let terminal_id = terminal.entity_id(); + move |this, _event, _window, _cx| { + if is_expanded { + this.expanded_terminals.remove(&terminal_id); + } else { + this.expanded_terminals.insert(terminal_id); + } + } })), ); @@ -2156,7 +2170,7 @@ impl AcpThreadView { .read(cx) .entry(entry_ix) .and_then(|entry| entry.terminal(terminal)); - let show_output = self.terminal_expanded && terminal_view.is_some(); + let show_output = is_expanded && terminal_view.is_some(); v_flex() .mb_2() From 02506356bc392b3cdaf0dce6fff2fb3f0e1ab833 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 21 Aug 2025 12:59:51 +0200 Subject: [PATCH 19/89] acp: Use unstaged style for diffs (#36674) Release Notes: - N/A --- crates/acp_thread/src/diff.rs | 55 +++++++++++++++++------------------ 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/crates/acp_thread/src/diff.rs b/crates/acp_thread/src/diff.rs index 70367e340a..130bc3ab6b 100644 --- a/crates/acp_thread/src/diff.rs +++ b/crates/acp_thread/src/diff.rs @@ -28,12 +28,7 @@ impl Diff { cx: &mut Context, ) -> Self { let multibuffer = cx.new(|_cx| MultiBuffer::without_headers(Capability::ReadOnly)); - - let new_buffer = cx.new(|cx| Buffer::local(new_text, cx)); - let old_buffer = cx.new(|cx| Buffer::local(old_text.unwrap_or("".into()), cx)); - let new_buffer_snapshot = new_buffer.read(cx).text_snapshot(); - let buffer_diff = cx.new(|cx| BufferDiff::new(&new_buffer_snapshot, cx)); - + let buffer = cx.new(|cx| Buffer::local(new_text, cx)); let task = cx.spawn({ let multibuffer = multibuffer.clone(); let path = path.clone(); @@ -43,42 +38,34 @@ impl Diff { .await .log_err(); - new_buffer.update(cx, |buffer, cx| buffer.set_language(language.clone(), cx))?; + buffer.update(cx, |buffer, cx| buffer.set_language(language.clone(), cx))?; - let old_buffer_snapshot = old_buffer.update(cx, |buffer, cx| { - buffer.set_language(language, cx); - buffer.snapshot() - })?; - - buffer_diff - .update(cx, |diff, cx| { - diff.set_base_text( - old_buffer_snapshot, - Some(language_registry), - new_buffer_snapshot, - cx, - ) - })? - .await?; + let diff = build_buffer_diff( + old_text.unwrap_or("".into()).into(), + &buffer, + Some(language_registry.clone()), + cx, + ) + .await?; multibuffer .update(cx, |multibuffer, cx| { let hunk_ranges = { - let buffer = new_buffer.read(cx); - let diff = buffer_diff.read(cx); + let buffer = buffer.read(cx); + let diff = diff.read(cx); diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer, cx) .map(|diff_hunk| diff_hunk.buffer_range.to_point(buffer)) .collect::>() }; multibuffer.set_excerpts_for_path( - PathKey::for_buffer(&new_buffer, cx), - new_buffer.clone(), + PathKey::for_buffer(&buffer, cx), + buffer.clone(), hunk_ranges, editor::DEFAULT_MULTIBUFFER_CONTEXT, cx, ); - multibuffer.add_diff(buffer_diff, cx); + multibuffer.add_diff(diff, cx); }) .log_err(); @@ -106,6 +93,15 @@ impl Diff { text_snapshot, cx, ); + let snapshot = diff.snapshot(cx); + + let secondary_diff = cx.new(|cx| { + let mut diff = BufferDiff::new(&buffer_snapshot, cx); + diff.set_snapshot(snapshot, &buffer_snapshot, cx); + diff + }); + diff.set_secondary_diff(secondary_diff); + diff }); @@ -204,7 +200,10 @@ impl PendingDiff { ) .await?; buffer_diff.update(cx, |diff, cx| { - diff.set_snapshot(diff_snapshot, &text_snapshot, cx) + diff.set_snapshot(diff_snapshot.clone(), &text_snapshot, cx); + diff.secondary_diff().unwrap().update(cx, |diff, cx| { + diff.set_snapshot(diff_snapshot.clone(), &text_snapshot, cx); + }); })?; diff.update(cx, |diff, cx| { if let Diff::Pending(diff) = diff { From 129b93ace9ed35f1d20f300d67693ccb05084144 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 21 Aug 2025 13:05:41 +0200 Subject: [PATCH 20/89] acp: Allow collapsing edit file tool calls (#36675) Release Notes: - N/A --- crates/agent_ui/src/acp/entry_view_state.rs | 18 ++++++++--- crates/agent_ui/src/acp/thread_view.rs | 34 +++++++++++---------- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/crates/agent_ui/src/acp/entry_view_state.rs b/crates/agent_ui/src/acp/entry_view_state.rs index fb15d8bed8..c310473259 100644 --- a/crates/agent_ui/src/acp/entry_view_state.rs +++ b/crates/agent_ui/src/acp/entry_view_state.rs @@ -1,6 +1,7 @@ use std::ops::Range; use acp_thread::{AcpThread, AgentThreadEntry}; +use agent_client_protocol::ToolCallId; use agent2::HistoryStore; use collections::HashMap; use editor::{Editor, EditorMode, MinimapVisibility}; @@ -106,6 +107,7 @@ impl EntryViewState { } } AgentThreadEntry::ToolCall(tool_call) => { + let id = tool_call.id.clone(); let terminals = tool_call.terminals().cloned().collect::>(); let diffs = tool_call.diffs().cloned().collect::>(); @@ -131,16 +133,21 @@ impl EntryViewState { .into_any(); cx.emit(EntryViewEvent { entry_index: index, - view_event: ViewEvent::NewTerminal(terminal.entity_id()), + view_event: ViewEvent::NewTerminal(id.clone()), }); element }); } for diff in diffs { - views - .entry(diff.entity_id()) - .or_insert_with(|| create_editor_diff(diff.clone(), window, cx).into_any()); + views.entry(diff.entity_id()).or_insert_with(|| { + let element = create_editor_diff(diff.clone(), window, cx).into_any(); + cx.emit(EntryViewEvent { + entry_index: index, + view_event: ViewEvent::NewDiff(id.clone()), + }); + element + }); } } AgentThreadEntry::AssistantMessage(_) => { @@ -192,7 +199,8 @@ pub struct EntryViewEvent { } pub enum ViewEvent { - NewTerminal(EntityId), + NewDiff(ToolCallId), + NewTerminal(ToolCallId), MessageEditorEvent(Entity, MessageEditorEvent), } diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 432ba4e0e8..9c9e2ee4dd 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -20,11 +20,11 @@ use file_icons::FileIcons; use fs::Fs; use gpui::{ Action, Animation, AnimationExt, AnyView, App, BorderStyle, ClickEvent, ClipboardItem, - EdgesRefinement, ElementId, Empty, Entity, EntityId, FocusHandle, Focusable, Hsla, Length, - ListOffset, ListState, MouseButton, PlatformDisplay, SharedString, Stateful, StyleRefinement, - Subscription, Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, - Window, WindowHandle, div, ease_in_out, linear_color_stop, linear_gradient, list, percentage, - point, prelude::*, pulsating_between, + EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length, ListOffset, + ListState, MouseButton, PlatformDisplay, SharedString, Stateful, StyleRefinement, Subscription, + Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, Window, + WindowHandle, div, ease_in_out, linear_color_stop, linear_gradient, list, percentage, point, + prelude::*, pulsating_between, }; use language::Buffer; @@ -256,7 +256,6 @@ pub struct AcpThreadView { auth_task: Option>, expanded_tool_calls: HashSet, expanded_thinking_blocks: HashSet<(usize, usize)>, - expanded_terminals: HashSet, edits_expanded: bool, plan_expanded: bool, editor_expanded: bool, @@ -354,7 +353,6 @@ impl AcpThreadView { auth_task: None, expanded_tool_calls: HashSet::default(), expanded_thinking_blocks: HashSet::default(), - expanded_terminals: HashSet::default(), editing_message: None, edits_expanded: false, plan_expanded: false, @@ -677,9 +675,14 @@ impl AcpThreadView { cx: &mut Context, ) { match &event.view_event { - ViewEvent::NewTerminal(terminal_id) => { + ViewEvent::NewDiff(tool_call_id) => { + if AgentSettings::get_global(cx).expand_edit_card { + self.expanded_tool_calls.insert(tool_call_id.clone()); + } + } + ViewEvent::NewTerminal(tool_call_id) => { if AgentSettings::get_global(cx).expand_terminal_card { - self.expanded_terminals.insert(*terminal_id); + self.expanded_tool_calls.insert(tool_call_id.clone()); } } ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Focus) => { @@ -1559,10 +1562,9 @@ impl AcpThreadView { matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some(); let use_card_layout = needs_confirmation || is_edit; - let is_collapsible = !tool_call.content.is_empty() && !use_card_layout; + let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation; - let is_open = - needs_confirmation || is_edit || self.expanded_tool_calls.contains(&tool_call.id); + let is_open = needs_confirmation || self.expanded_tool_calls.contains(&tool_call.id); let gradient_overlay = |color: Hsla| { div() @@ -2014,7 +2016,7 @@ impl AcpThreadView { .map(|path| format!("{}", path.display())) .unwrap_or_else(|| "current directory".to_string()); - let is_expanded = self.expanded_terminals.contains(&terminal.entity_id()); + let is_expanded = self.expanded_tool_calls.contains(&tool_call.id); let header = h_flex() .id(SharedString::from(format!( @@ -2154,12 +2156,12 @@ impl AcpThreadView { .opened_icon(IconName::ChevronUp) .closed_icon(IconName::ChevronDown) .on_click(cx.listener({ - let terminal_id = terminal.entity_id(); + let id = tool_call.id.clone(); move |this, _event, _window, _cx| { if is_expanded { - this.expanded_terminals.remove(&terminal_id); + this.expanded_tool_calls.remove(&id); } else { - this.expanded_terminals.insert(terminal_id); + this.expanded_tool_calls.insert(id.clone()); } } })), From 79064d1fb804ff84426c01497601a863654d2d5a Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 21 Aug 2025 16:18:22 +0200 Subject: [PATCH 21/89] acp: Use file icons for edit tool cards when ToolCallLocation is known (#36684) Release Notes: - N/A --- crates/agent_ui/src/acp/thread_view.rs | 33 ++++++++++++++++---------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 9c9e2ee4dd..f8c616c9e0 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -1469,19 +1469,26 @@ impl AcpThreadView { tool_call: &ToolCall, cx: &Context, ) -> Div { - let tool_icon = Icon::new(match tool_call.kind { - acp::ToolKind::Read => IconName::ToolRead, - acp::ToolKind::Edit => IconName::ToolPencil, - acp::ToolKind::Delete => IconName::ToolDeleteFile, - acp::ToolKind::Move => IconName::ArrowRightLeft, - acp::ToolKind::Search => IconName::ToolSearch, - acp::ToolKind::Execute => IconName::ToolTerminal, - acp::ToolKind::Think => IconName::ToolThink, - acp::ToolKind::Fetch => IconName::ToolWeb, - acp::ToolKind::Other => IconName::ToolHammer, - }) - .size(IconSize::Small) - .color(Color::Muted); + let tool_icon = + if tool_call.kind == acp::ToolKind::Edit && tool_call.locations.len() == 1 { + FileIcons::get_icon(&tool_call.locations[0].path, cx) + .map(Icon::from_path) + .unwrap_or(Icon::new(IconName::ToolPencil)) + } else { + Icon::new(match tool_call.kind { + acp::ToolKind::Read => IconName::ToolRead, + acp::ToolKind::Edit => IconName::ToolPencil, + acp::ToolKind::Delete => IconName::ToolDeleteFile, + acp::ToolKind::Move => IconName::ArrowRightLeft, + acp::ToolKind::Search => IconName::ToolSearch, + acp::ToolKind::Execute => IconName::ToolTerminal, + acp::ToolKind::Think => IconName::ToolThink, + acp::ToolKind::Fetch => IconName::ToolWeb, + acp::ToolKind::Other => IconName::ToolHammer, + }) + } + .size(IconSize::Small) + .color(Color::Muted); let base_container = h_flex().size_4().justify_center(); From bb32d4567a69d0fc201a19d6b61c1d0f5fc804df Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Thu, 21 Aug 2025 08:43:57 -0600 Subject: [PATCH 22/89] acp: Hide history unless in native agent (#36644) Release Notes: - N/A --- crates/agent2/src/history_store.rs | 10 ++++++++++ crates/agent_ui/src/acp/thread_history.rs | 17 ++++------------- crates/agent_ui/src/acp/thread_view.rs | 21 +++++++++++++-------- 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/crates/agent2/src/history_store.rs b/crates/agent2/src/history_store.rs index 2d70164a66..78d83cc1d0 100644 --- a/crates/agent2/src/history_store.rs +++ b/crates/agent2/src/history_store.rs @@ -10,6 +10,7 @@ use itertools::Itertools; use paths::contexts_dir; use serde::{Deserialize, Serialize}; use std::{collections::VecDeque, path::Path, sync::Arc, time::Duration}; +use ui::ElementId; use util::ResultExt as _; const MAX_RECENTLY_OPENED_ENTRIES: usize = 6; @@ -68,6 +69,15 @@ pub enum HistoryEntryId { TextThread(Arc), } +impl Into for HistoryEntryId { + fn into(self) -> ElementId { + match self { + HistoryEntryId::AcpThread(session_id) => ElementId::Name(session_id.0.into()), + HistoryEntryId::TextThread(path) => ElementId::Path(path), + } + } +} + #[derive(Serialize, Deserialize, Debug)] enum SerializedRecentOpen { AcpThread(String), diff --git a/crates/agent_ui/src/acp/thread_history.rs b/crates/agent_ui/src/acp/thread_history.rs index 68a41f31d0..d76969378c 100644 --- a/crates/agent_ui/src/acp/thread_history.rs +++ b/crates/agent_ui/src/acp/thread_history.rs @@ -673,18 +673,9 @@ impl AcpHistoryEntryElement { impl RenderOnce for AcpHistoryEntryElement { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - let (id, title, timestamp) = match &self.entry { - HistoryEntry::AcpThread(thread) => ( - thread.id.to_string(), - thread.title.clone(), - thread.updated_at, - ), - HistoryEntry::TextThread(context) => ( - context.path.to_string_lossy().to_string(), - context.title.clone(), - context.mtime.to_utc(), - ), - }; + let id = self.entry.id(); + let title = self.entry.title(); + let timestamp = self.entry.updated_at(); let formatted_time = { let now = chrono::Utc::now(); @@ -701,7 +692,7 @@ impl RenderOnce for AcpHistoryEntryElement { } }; - ListItem::new(SharedString::from(id)) + ListItem::new(id) .rounded() .toggle_state(self.selected) .spacing(ListItemSpacing::Sparse) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index f8c616c9e0..090e224b4d 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -2404,16 +2404,18 @@ impl AcpThreadView { fn render_empty_state(&self, window: &mut Window, cx: &mut Context) -> AnyElement { let loading = matches!(&self.thread_state, ThreadState::Loading { .. }); - let recent_history = self - .history_store - .update(cx, |history_store, cx| history_store.recent_entries(3, cx)); - let no_history = self - .history_store - .update(cx, |history_store, cx| history_store.is_empty(cx)); + let render_history = self + .agent + .clone() + .downcast::() + .is_some() + && self + .history_store + .update(cx, |history_store, cx| !history_store.is_empty(cx)); v_flex() .size_full() - .when(no_history, |this| { + .when(!render_history, |this| { this.child( v_flex() .size_full() @@ -2445,7 +2447,10 @@ impl AcpThreadView { })), ) }) - .when(!no_history, |this| { + .when(render_history, |this| { + let recent_history = self + .history_store + .update(cx, |history_store, cx| history_store.recent_entries(3, cx)); this.justify_end().child( v_flex() .child( From 7f95310020c3a5b5d6d1cfad5398014e462952c1 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Thu, 21 Aug 2025 08:44:04 -0600 Subject: [PATCH 23/89] acp: Detect gemini auth errors and show a button (#36641) Closes #ISSUE Release Notes: - N/A --- crates/agent_ui/src/acp/thread_view.rs | 63 ++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 090e224b4d..7e330b7e6f 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -76,11 +76,12 @@ enum ThreadError { PaymentRequired, ModelRequestLimitReached(cloud_llm_client::Plan), ToolUseLimitReached, + AuthenticationRequired(SharedString), Other(SharedString), } impl ThreadError { - fn from_err(error: anyhow::Error) -> Self { + fn from_err(error: anyhow::Error, agent: &Rc) -> Self { if error.is::() { Self::PaymentRequired } else if error.is::() { @@ -90,7 +91,17 @@ impl ThreadError { { Self::ModelRequestLimitReached(error.plan) } else { - Self::Other(error.to_string().into()) + let string = error.to_string(); + // TODO: we should have Gemini return better errors here. + if agent.clone().downcast::().is_some() + && string.contains("Could not load the default credentials") + || string.contains("API key not valid") + || string.contains("Request had invalid authentication credentials") + { + Self::AuthenticationRequired(string.into()) + } else { + Self::Other(error.to_string().into()) + } } } } @@ -930,7 +941,7 @@ impl AcpThreadView { } fn handle_thread_error(&mut self, error: anyhow::Error, cx: &mut Context) { - self.thread_error = Some(ThreadError::from_err(error)); + self.thread_error = Some(ThreadError::from_err(error, &self.agent)); cx.notify(); } @@ -4310,6 +4321,9 @@ impl AcpThreadView { fn render_thread_error(&self, window: &mut Window, cx: &mut Context) -> Option
{ let content = match self.thread_error.as_ref()? { ThreadError::Other(error) => self.render_any_thread_error(error.clone(), cx), + ThreadError::AuthenticationRequired(error) => { + self.render_authentication_required_error(error.clone(), cx) + } ThreadError::PaymentRequired => self.render_payment_required_error(cx), ThreadError::ModelRequestLimitReached(plan) => { self.render_model_request_limit_reached_error(*plan, cx) @@ -4348,6 +4362,24 @@ impl AcpThreadView { .dismiss_action(self.dismiss_error_button(cx)) } + fn render_authentication_required_error( + &self, + error: SharedString, + cx: &mut Context, + ) -> Callout { + Callout::new() + .severity(Severity::Error) + .title("Authentication Required") + .description(error.clone()) + .actions_slot( + h_flex() + .gap_0p5() + .child(self.authenticate_button(cx)) + .child(self.create_copy_button(error)), + ) + .dismiss_action(self.dismiss_error_button(cx)) + } + fn render_model_request_limit_reached_error( &self, plan: cloud_llm_client::Plan, @@ -4469,6 +4501,31 @@ impl AcpThreadView { })) } + fn authenticate_button(&self, cx: &mut Context) -> impl IntoElement { + Button::new("authenticate", "Authenticate") + .label_size(LabelSize::Small) + .style(ButtonStyle::Filled) + .on_click(cx.listener({ + move |this, _, window, cx| { + let agent = this.agent.clone(); + let ThreadState::Ready { thread, .. } = &this.thread_state else { + return; + }; + + let connection = thread.read(cx).connection().clone(); + let err = AuthRequired { + description: None, + provider_id: None, + }; + this.clear_thread_error(cx); + let this = cx.weak_entity(); + window.defer(cx, |window, cx| { + Self::handle_auth_required(this, err, agent, connection, window, cx); + }) + } + })) + } + fn upgrade_button(&self, cx: &mut Context) -> impl IntoElement { Button::new("upgrade", "Upgrade") .label_size(LabelSize::Small) From ca67e0658a06bdf024e1e3fda0b5f8315540b074 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Thu, 21 Aug 2025 09:23:56 -0400 Subject: [PATCH 24/89] Show excerpt dividers in `without_headers` multibuffers (#36647) Release Notes: - Fixed diff cards in agent threads not showing dividers between disjoint edited regions. --- crates/editor/src/display_map/block_map.rs | 99 ++++++++++++++-------- crates/editor/src/element.rs | 72 +++++++++------- crates/editor/src/test.rs | 35 ++++---- 3 files changed, 122 insertions(+), 84 deletions(-) diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index e32a4e45db..b073fe7be7 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -290,7 +290,10 @@ pub enum Block { ExcerptBoundary { excerpt: ExcerptInfo, height: u32, - starts_new_buffer: bool, + }, + BufferHeader { + excerpt: ExcerptInfo, + height: u32, }, } @@ -303,27 +306,37 @@ impl Block { .. } => BlockId::ExcerptBoundary(next_excerpt.id), Block::FoldedBuffer { first_excerpt, .. } => BlockId::FoldedBuffer(first_excerpt.id), + Block::BufferHeader { + excerpt: next_excerpt, + .. + } => BlockId::ExcerptBoundary(next_excerpt.id), } } pub fn has_height(&self) -> bool { match self { Block::Custom(block) => block.height.is_some(), - Block::ExcerptBoundary { .. } | Block::FoldedBuffer { .. } => true, + Block::ExcerptBoundary { .. } + | Block::FoldedBuffer { .. } + | Block::BufferHeader { .. } => true, } } pub fn height(&self) -> u32 { match self { Block::Custom(block) => block.height.unwrap_or(0), - Block::ExcerptBoundary { height, .. } | Block::FoldedBuffer { height, .. } => *height, + Block::ExcerptBoundary { height, .. } + | Block::FoldedBuffer { height, .. } + | Block::BufferHeader { height, .. } => *height, } } pub fn style(&self) -> BlockStyle { match self { Block::Custom(block) => block.style, - Block::ExcerptBoundary { .. } | Block::FoldedBuffer { .. } => BlockStyle::Sticky, + Block::ExcerptBoundary { .. } + | Block::FoldedBuffer { .. } + | Block::BufferHeader { .. } => BlockStyle::Sticky, } } @@ -332,6 +345,7 @@ impl Block { Block::Custom(block) => matches!(block.placement, BlockPlacement::Above(_)), Block::FoldedBuffer { .. } => false, Block::ExcerptBoundary { .. } => true, + Block::BufferHeader { .. } => true, } } @@ -340,6 +354,7 @@ impl Block { Block::Custom(block) => matches!(block.placement, BlockPlacement::Near(_)), Block::FoldedBuffer { .. } => false, Block::ExcerptBoundary { .. } => false, + Block::BufferHeader { .. } => false, } } @@ -351,6 +366,7 @@ impl Block { ), Block::FoldedBuffer { .. } => false, Block::ExcerptBoundary { .. } => false, + Block::BufferHeader { .. } => false, } } @@ -359,6 +375,7 @@ impl Block { Block::Custom(block) => matches!(block.placement, BlockPlacement::Replace(_)), Block::FoldedBuffer { .. } => true, Block::ExcerptBoundary { .. } => false, + Block::BufferHeader { .. } => false, } } @@ -367,6 +384,7 @@ impl Block { Block::Custom(_) => false, Block::FoldedBuffer { .. } => true, Block::ExcerptBoundary { .. } => true, + Block::BufferHeader { .. } => true, } } @@ -374,9 +392,8 @@ impl Block { match self { Block::Custom(_) => false, Block::FoldedBuffer { .. } => true, - Block::ExcerptBoundary { - starts_new_buffer, .. - } => *starts_new_buffer, + Block::ExcerptBoundary { .. } => false, + Block::BufferHeader { .. } => true, } } } @@ -393,14 +410,14 @@ impl Debug for Block { .field("first_excerpt", &first_excerpt) .field("height", height) .finish(), - Self::ExcerptBoundary { - starts_new_buffer, - excerpt, - height, - } => f + Self::ExcerptBoundary { excerpt, height } => f .debug_struct("ExcerptBoundary") .field("excerpt", excerpt) - .field("starts_new_buffer", starts_new_buffer) + .field("height", height) + .finish(), + Self::BufferHeader { excerpt, height } => f + .debug_struct("BufferHeader") + .field("excerpt", excerpt) .field("height", height) .finish(), } @@ -662,13 +679,11 @@ impl BlockMap { }), ); - if buffer.show_headers() { - blocks_in_edit.extend(self.header_and_footer_blocks( - buffer, - (start_bound, end_bound), - wrap_snapshot, - )); - } + blocks_in_edit.extend(self.header_and_footer_blocks( + buffer, + (start_bound, end_bound), + wrap_snapshot, + )); BlockMap::sort_blocks(&mut blocks_in_edit); @@ -771,7 +786,7 @@ impl BlockMap { if self.buffers_with_disabled_headers.contains(&new_buffer_id) { continue; } - if self.folded_buffers.contains(&new_buffer_id) { + if self.folded_buffers.contains(&new_buffer_id) && buffer.show_headers() { let mut last_excerpt_end_row = first_excerpt.end_row; while let Some(next_boundary) = boundaries.peek() { @@ -804,20 +819,24 @@ impl BlockMap { } } - if new_buffer_id.is_some() { + let starts_new_buffer = new_buffer_id.is_some(); + let block = if starts_new_buffer && buffer.show_headers() { height += self.buffer_header_height; - } else { + Block::BufferHeader { + excerpt: excerpt_boundary.next, + height, + } + } else if excerpt_boundary.prev.is_some() { height += self.excerpt_header_height; - } - - return Some(( - BlockPlacement::Above(WrapRow(wrap_row)), Block::ExcerptBoundary { excerpt: excerpt_boundary.next, height, - starts_new_buffer: new_buffer_id.is_some(), - }, - )); + } + } else { + continue; + }; + + return Some((BlockPlacement::Above(WrapRow(wrap_row)), block)); } }) } @@ -842,13 +861,25 @@ impl BlockMap { ( Block::ExcerptBoundary { excerpt: excerpt_a, .. + } + | Block::BufferHeader { + excerpt: excerpt_a, .. }, Block::ExcerptBoundary { excerpt: excerpt_b, .. + } + | Block::BufferHeader { + excerpt: excerpt_b, .. }, ) => Some(excerpt_a.id).cmp(&Some(excerpt_b.id)), - (Block::ExcerptBoundary { .. }, Block::Custom(_)) => Ordering::Less, - (Block::Custom(_), Block::ExcerptBoundary { .. }) => Ordering::Greater, + ( + Block::ExcerptBoundary { .. } | Block::BufferHeader { .. }, + Block::Custom(_), + ) => Ordering::Less, + ( + Block::Custom(_), + Block::ExcerptBoundary { .. } | Block::BufferHeader { .. }, + ) => Ordering::Greater, (Block::Custom(block_a), Block::Custom(block_b)) => block_a .priority .cmp(&block_b.priority) @@ -1377,7 +1408,9 @@ impl BlockSnapshot { while let Some(transform) = cursor.item() { match &transform.block { - Some(Block::ExcerptBoundary { excerpt, .. }) => { + Some( + Block::ExcerptBoundary { excerpt, .. } | Block::BufferHeader { excerpt, .. }, + ) => { return Some(StickyHeaderExcerpt { excerpt }); } Some(block) if block.is_buffer_header() => return None, diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 416f35d7a7..797b0d6634 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -2749,7 +2749,10 @@ impl EditorElement { let mut block_offset = 0; let mut found_excerpt_header = false; for (_, block) in snapshot.blocks_in_range(prev_line..row_range.start) { - if matches!(block, Block::ExcerptBoundary { .. }) { + if matches!( + block, + Block::ExcerptBoundary { .. } | Block::BufferHeader { .. } + ) { found_excerpt_header = true; break; } @@ -2766,7 +2769,10 @@ impl EditorElement { let mut block_height = 0; let mut found_excerpt_header = false; for (_, block) in snapshot.blocks_in_range(row_range.end..cons_line) { - if matches!(block, Block::ExcerptBoundary { .. }) { + if matches!( + block, + Block::ExcerptBoundary { .. } | Block::BufferHeader { .. } + ) { found_excerpt_header = true; } block_height += block.height(); @@ -3452,42 +3458,41 @@ impl EditorElement { .into_any_element() } - Block::ExcerptBoundary { - excerpt, - height, - starts_new_buffer, - .. - } => { + Block::ExcerptBoundary { .. } => { let color = cx.theme().colors().clone(); let mut result = v_flex().id(block_id).w_full(); + result = result.child( + h_flex().relative().child( + div() + .top(line_height / 2.) + .absolute() + .w_full() + .h_px() + .bg(color.border_variant), + ), + ); + + result.into_any() + } + + Block::BufferHeader { excerpt, height } => { + let mut result = v_flex().id(block_id).w_full(); + let jump_data = header_jump_data(snapshot, block_row_start, *height, excerpt); - if *starts_new_buffer { - if sticky_header_excerpt_id != Some(excerpt.id) { - let selected = selected_buffer_ids.contains(&excerpt.buffer_id); + if sticky_header_excerpt_id != Some(excerpt.id) { + let selected = selected_buffer_ids.contains(&excerpt.buffer_id); - result = result.child(div().pr(editor_margins.right).child( - self.render_buffer_header( - excerpt, false, selected, false, jump_data, window, cx, - ), - )); - } else { - result = - result.child(div().h(FILE_HEADER_HEIGHT as f32 * window.line_height())); - } - } else { - result = result.child( - h_flex().relative().child( - div() - .top(line_height / 2.) - .absolute() - .w_full() - .h_px() - .bg(color.border_variant), + result = result.child(div().pr(editor_margins.right).child( + self.render_buffer_header( + excerpt, false, selected, false, jump_data, window, cx, ), - ); - }; + )); + } else { + result = + result.child(div().h(FILE_HEADER_HEIGHT as f32 * window.line_height())); + } result.into_any() } @@ -5708,7 +5713,10 @@ impl EditorElement { let end_row_in_current_excerpt = snapshot .blocks_in_range(start_row..end_row) .find_map(|(start_row, block)| { - if matches!(block, Block::ExcerptBoundary { .. }) { + if matches!( + block, + Block::ExcerptBoundary { .. } | Block::BufferHeader { .. } + ) { Some(start_row) } else { None diff --git a/crates/editor/src/test.rs b/crates/editor/src/test.rs index d388e8f3b7..960fecf59a 100644 --- a/crates/editor/src/test.rs +++ b/crates/editor/src/test.rs @@ -230,26 +230,23 @@ pub fn editor_content_with_blocks(editor: &Entity, cx: &mut VisualTestCo lines[row as usize].push_str("§ -----"); } } - Block::ExcerptBoundary { - excerpt, - height, - starts_new_buffer, - } => { - if starts_new_buffer { - lines[row.0 as usize].push_str(&cx.update(|_, cx| { - format!( - "§ {}", - excerpt - .buffer - .file() - .unwrap() - .file_name(cx) - .to_string_lossy() - ) - })); - } else { - lines[row.0 as usize].push_str("§ -----") + Block::ExcerptBoundary { height, .. } => { + for row in row.0..row.0 + height { + lines[row as usize].push_str("§ -----"); } + } + Block::BufferHeader { excerpt, height } => { + lines[row.0 as usize].push_str(&cx.update(|_, cx| { + format!( + "§ {}", + excerpt + .buffer + .file() + .unwrap() + .file_name(cx) + .to_string_lossy() + ) + })); for row in row.0 + 1..row.0 + height { lines[row as usize].push_str("§ -----"); } From 20710a41a032d917c6619ced920fefddbe3cc956 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Thu, 21 Aug 2025 09:24:34 -0400 Subject: [PATCH 25/89] Fix more improper uses of the `buffer_id` field of `Anchor` (#36636) Follow-up to #36524 Release Notes: - N/A --- crates/editor/src/editor.rs | 73 +++++++++------------- crates/editor/src/hover_links.rs | 5 +- crates/editor/src/inlay_hint_cache.rs | 5 +- crates/editor/src/linked_editing_ranges.rs | 2 +- crates/editor/src/mouse_context_menu.rs | 18 +++--- crates/outline_panel/src/outline_panel.rs | 5 +- 6 files changed, 49 insertions(+), 59 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 25fddf5cf1..4051da190c 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -6697,7 +6697,6 @@ impl Editor { return; } - let buffer_id = cursor_position.buffer_id; let buffer = this.buffer.read(cx); if buffer .text_anchor_for_position(cursor_position, cx) @@ -6710,8 +6709,8 @@ impl Editor { let mut write_ranges = Vec::new(); let mut read_ranges = Vec::new(); for highlight in highlights { - for (excerpt_id, excerpt_range) in - buffer.excerpts_for_buffer(cursor_buffer.read(cx).remote_id(), cx) + let buffer_id = cursor_buffer.read(cx).remote_id(); + for (excerpt_id, excerpt_range) in buffer.excerpts_for_buffer(buffer_id, cx) { let start = highlight .range @@ -6726,12 +6725,12 @@ impl Editor { } let range = Anchor { - buffer_id, + buffer_id: Some(buffer_id), excerpt_id, text_anchor: start, diff_base_anchor: None, }..Anchor { - buffer_id, + buffer_id: Some(buffer_id), excerpt_id, text_anchor: end, diff_base_anchor: None, @@ -9496,17 +9495,21 @@ impl Editor { selection: Range, cx: &mut Context, ) { - let buffer_id = match (&selection.start.buffer_id, &selection.end.buffer_id) { - (Some(a), Some(b)) if a == b => a, - _ => { - log::error!("expected anchor range to have matching buffer IDs"); - return; - } - }; - let multi_buffer = self.buffer().read(cx); - let Some(buffer) = multi_buffer.buffer(*buffer_id) else { + let Some((_, buffer, _)) = self + .buffer() + .read(cx) + .excerpt_containing(selection.start, cx) + else { return; }; + let Some((_, end_buffer, _)) = self.buffer().read(cx).excerpt_containing(selection.end, cx) + else { + return; + }; + if buffer != end_buffer { + log::error!("expected anchor range to have matching buffer IDs"); + return; + } let id = post_inc(&mut self.next_completion_id); let snippet_sort_order = EditorSettings::get_global(cx).snippet_sort_order; @@ -10593,16 +10596,12 @@ impl Editor { snapshot: &EditorSnapshot, cx: &mut Context, ) -> Option<(Anchor, Breakpoint)> { - let project = self.project.clone()?; - - let buffer_id = breakpoint_position.buffer_id.or_else(|| { - snapshot - .buffer_snapshot - .buffer_id_for_excerpt(breakpoint_position.excerpt_id) - })?; + let buffer = self + .buffer + .read(cx) + .buffer_for_anchor(breakpoint_position, cx)?; let enclosing_excerpt = breakpoint_position.excerpt_id; - let buffer = project.read(cx).buffer_for_id(buffer_id, cx)?; let buffer_snapshot = buffer.read(cx).snapshot(); let row = buffer_snapshot @@ -10775,21 +10774,11 @@ impl Editor { return; }; - let Some(buffer_id) = breakpoint_position.buffer_id.or_else(|| { - if breakpoint_position == Anchor::min() { - self.buffer() - .read(cx) - .excerpt_buffer_ids() - .into_iter() - .next() - } else { - None - } - }) else { - return; - }; - - let Some(buffer) = self.buffer().read(cx).buffer(buffer_id) else { + let Some(buffer) = self + .buffer + .read(cx) + .buffer_for_anchor(breakpoint_position, cx) + else { return; }; @@ -15432,7 +15421,8 @@ impl Editor { return; }; - let Some(buffer_id) = buffer.anchor_after(next_diagnostic.range.start).buffer_id else { + let next_diagnostic_start = buffer.anchor_after(next_diagnostic.range.start); + let Some(buffer_id) = buffer.buffer_id_for_anchor(next_diagnostic_start) else { return; }; self.change_selections(Default::default(), window, cx, |s| { @@ -20421,11 +20411,8 @@ impl Editor { .range_to_buffer_ranges_with_deleted_hunks(selection.range()) { if let Some(anchor) = anchor { - // selection is in a deleted hunk - let Some(buffer_id) = anchor.buffer_id else { - continue; - }; - let Some(buffer_handle) = multi_buffer.buffer(buffer_id) else { + let Some(buffer_handle) = multi_buffer.buffer_for_anchor(anchor, cx) + else { continue; }; let offset = text::ToOffset::to_offset( diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs index 04e66a234c..d10df5154e 100644 --- a/crates/editor/src/hover_links.rs +++ b/crates/editor/src/hover_links.rs @@ -321,7 +321,10 @@ pub fn update_inlay_link_and_hover_points( if let Some(cached_hint) = inlay_hint_cache.hint_by_id(excerpt_id, hovered_hint.id) { match cached_hint.resolve_state { ResolveState::CanResolve(_, _) => { - if let Some(buffer_id) = previous_valid_anchor.buffer_id { + if let Some(buffer_id) = snapshot + .buffer_snapshot + .buffer_id_for_anchor(previous_valid_anchor) + { inlay_hint_cache.spawn_hint_resolve( buffer_id, excerpt_id, diff --git a/crates/editor/src/inlay_hint_cache.rs b/crates/editor/src/inlay_hint_cache.rs index cea0e32d7f..dbf5ac95b7 100644 --- a/crates/editor/src/inlay_hint_cache.rs +++ b/crates/editor/src/inlay_hint_cache.rs @@ -475,10 +475,7 @@ impl InlayHintCache { let excerpt_cached_hints = excerpt_cached_hints.read(); let mut excerpt_cache = excerpt_cached_hints.ordered_hints.iter().fuse().peekable(); shown_excerpt_hints_to_remove.retain(|(shown_anchor, shown_hint_id)| { - let Some(buffer) = shown_anchor - .buffer_id - .and_then(|buffer_id| multi_buffer.buffer(buffer_id)) - else { + let Some(buffer) = multi_buffer.buffer_for_anchor(*shown_anchor, cx) else { return false; }; let buffer_snapshot = buffer.read(cx).snapshot(); diff --git a/crates/editor/src/linked_editing_ranges.rs b/crates/editor/src/linked_editing_ranges.rs index aaf9032b04..4f1313797f 100644 --- a/crates/editor/src/linked_editing_ranges.rs +++ b/crates/editor/src/linked_editing_ranges.rs @@ -72,7 +72,7 @@ pub(super) fn refresh_linked_ranges( // Throw away selections spanning multiple buffers. continue; } - if let Some(buffer) = end_position.buffer_id.and_then(|id| buffer.buffer(id)) { + if let Some(buffer) = buffer.buffer_for_anchor(end_position, cx) { applicable_selections.push(( buffer, start_position.text_anchor, diff --git a/crates/editor/src/mouse_context_menu.rs b/crates/editor/src/mouse_context_menu.rs index 5cf22de537..3bc334c54c 100644 --- a/crates/editor/src/mouse_context_menu.rs +++ b/crates/editor/src/mouse_context_menu.rs @@ -190,14 +190,16 @@ pub fn deploy_context_menu( .all::(cx) .into_iter() .any(|s| !s.is_empty()); - let has_git_repo = anchor.buffer_id.is_some_and(|buffer_id| { - project - .read(cx) - .git_store() - .read(cx) - .repository_and_path_for_buffer_id(buffer_id, cx) - .is_some() - }); + let has_git_repo = buffer + .buffer_id_for_anchor(anchor) + .is_some_and(|buffer_id| { + project + .read(cx) + .git_store() + .read(cx) + .repository_and_path_for_buffer_id(buffer_id, cx) + .is_some() + }); let evaluate_selection = window.is_action_available(&EvaluateSelectedText, cx); let run_to_cursor = window.is_action_available(&RunToCursor, cx); diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 59c43f945f..10698cead8 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -4393,12 +4393,13 @@ impl OutlinePanel { }) .filter(|(match_range, _)| { let editor = active_editor.read(cx); - if let Some(buffer_id) = match_range.start.buffer_id + let snapshot = editor.buffer().read(cx).snapshot(cx); + if let Some(buffer_id) = snapshot.buffer_id_for_anchor(match_range.start) && editor.is_buffer_folded(buffer_id, cx) { return false; } - if let Some(buffer_id) = match_range.start.buffer_id + if let Some(buffer_id) = snapshot.buffer_id_for_anchor(match_range.end) && editor.is_buffer_folded(buffer_id, cx) { return false; From e436b82d94b55b83e639dda96e54eed04f19e537 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 21 Aug 2025 11:57:46 -0300 Subject: [PATCH 26/89] acp: Use `ResourceLink` for agents that don't support embedded context (#36687) The completion provider was already limiting the mention kinds according to `acp::PromptCapabilities`. However, it was still using `ContentBlock::EmbeddedResource` when `acp::PromptCapabilities::embedded_context` was `false`. We will now use `ResourceLink` in that case making it more complaint with the specification. Release Notes: - N/A --- crates/agent_ui/src/acp/message_editor.rs | 91 ++++++++++++++++++++--- 1 file changed, 79 insertions(+), 12 deletions(-) diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index 03769f6515..510ec5f480 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -709,9 +709,13 @@ impl MessageEditor { window: &mut Window, cx: &mut Context, ) -> Task, Vec>)>> { - let contents = - self.mention_set - .contents(&self.project, self.prompt_store.as_ref(), window, cx); + let contents = self.mention_set.contents( + &self.project, + self.prompt_store.as_ref(), + &self.prompt_capabilities.get(), + window, + cx, + ); let editor = self.editor.clone(); let prevent_slash_commands = self.prevent_slash_commands; @@ -776,6 +780,17 @@ impl MessageEditor { .map(|path| format!("file://{}", path.display())), }) } + Mention::UriOnly(uri) => { + acp::ContentBlock::ResourceLink(acp::ResourceLink { + name: uri.name(), + uri: uri.to_uri().to_string(), + annotations: None, + description: None, + mime_type: None, + size: None, + title: None, + }) + } }; chunks.push(chunk); ix = crease_range.end; @@ -1418,6 +1433,7 @@ pub enum Mention { tracked_buffers: Vec>, }, Image(MentionImage), + UriOnly(MentionUri), } #[derive(Clone, Debug, Eq, PartialEq)] @@ -1481,9 +1497,20 @@ impl MentionSet { &self, project: &Entity, prompt_store: Option<&Entity>, + prompt_capabilities: &acp::PromptCapabilities, _window: &mut Window, cx: &mut App, ) -> Task>> { + if !prompt_capabilities.embedded_context { + let mentions = self + .uri_by_crease_id + .iter() + .map(|(crease_id, uri)| (*crease_id, Mention::UriOnly(uri.clone()))) + .collect(); + + return Task::ready(Ok(mentions)); + } + let mut processed_image_creases = HashSet::default(); let mut contents = self @@ -2180,11 +2207,21 @@ mod tests { assert_eq!(fold_ranges(editor, cx).len(), 1); }); + let all_prompt_capabilities = acp::PromptCapabilities { + image: true, + audio: true, + embedded_context: true, + }; + let contents = message_editor .update_in(&mut cx, |message_editor, window, cx| { - message_editor - .mention_set() - .contents(&project, None, window, cx) + message_editor.mention_set().contents( + &project, + None, + &all_prompt_capabilities, + window, + cx, + ) }) .await .unwrap() @@ -2199,6 +2236,28 @@ mod tests { pretty_assertions::assert_eq!(uri, &url_one.parse::().unwrap()); } + let contents = message_editor + .update_in(&mut cx, |message_editor, window, cx| { + message_editor.mention_set().contents( + &project, + None, + &acp::PromptCapabilities::default(), + window, + cx, + ) + }) + .await + .unwrap() + .into_values() + .collect::>(); + + { + let [Mention::UriOnly(uri)] = contents.as_slice() else { + panic!("Unexpected mentions"); + }; + pretty_assertions::assert_eq!(uri, &url_one.parse::().unwrap()); + } + cx.simulate_input(" "); editor.update(&mut cx, |editor, cx| { @@ -2234,9 +2293,13 @@ mod tests { let contents = message_editor .update_in(&mut cx, |message_editor, window, cx| { - message_editor - .mention_set() - .contents(&project, None, window, cx) + message_editor.mention_set().contents( + &project, + None, + &all_prompt_capabilities, + window, + cx, + ) }) .await .unwrap() @@ -2344,9 +2407,13 @@ mod tests { let contents = message_editor .update_in(&mut cx, |message_editor, window, cx| { - message_editor - .mention_set() - .contents(&project, None, window, cx) + message_editor.mention_set().contents( + &project, + None, + &all_prompt_capabilities, + window, + cx, + ) }) .await .unwrap() From 3ea59d23fd94a4e936165dc910387ef146d2d083 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Thu, 21 Aug 2025 11:11:23 -0400 Subject: [PATCH 27/89] zed 0.201.2 --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 375793feb9..797e38fdac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20391,7 +20391,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.201.1" +version = "0.201.2" dependencies = [ "activity_indicator", "agent", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 7f34c01f32..fa8358444e 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "0.201.1" +version = "0.201.2" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] From ca0a20f3d53176459d2592a485365ab63ce2ecd7 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 21 Aug 2025 18:11:05 +0200 Subject: [PATCH 28/89] acp: Refactor agent2 `send` to have a clearer control flow (#36689) Release Notes: - N/A --- Cargo.lock | 1 + crates/agent2/Cargo.toml | 1 + crates/agent2/src/thread.rs | 295 ++++++++++++++++-------------------- 3 files changed, 134 insertions(+), 163 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 797e38fdac..203e9869e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -244,6 +244,7 @@ dependencies = [ "terminal", "text", "theme", + "thiserror 2.0.12", "tree-sitter-rust", "ui", "unindent", diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml index 8dd79062f8..68246a96b0 100644 --- a/crates/agent2/Cargo.toml +++ b/crates/agent2/Cargo.toml @@ -61,6 +61,7 @@ sqlez.workspace = true task.workspace = true telemetry.workspace = true terminal.workspace = true +thiserror.workspace = true text.workspace = true ui.workspace = true util.workspace = true diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index d34c929152..6f560cd390 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -499,6 +499,16 @@ pub struct ToolCallAuthorization { pub response: oneshot::Sender, } +#[derive(Debug, thiserror::Error)] +enum CompletionError { + #[error("max tokens")] + MaxTokens, + #[error("refusal")] + Refusal, + #[error(transparent)] + Other(#[from] anyhow::Error), +} + pub struct Thread { id: acp::SessionId, prompt_id: PromptId, @@ -1077,101 +1087,62 @@ impl Thread { _task: cx.spawn(async move |this, cx| { log::info!("Starting agent turn execution"); let mut update_title = None; - let turn_result: Result = async { - let mut completion_intent = CompletionIntent::UserPrompt; + let turn_result: Result<()> = async { + let mut intent = CompletionIntent::UserPrompt; loop { - log::debug!( - "Building completion request with intent: {:?}", - completion_intent - ); - let request = this.update(cx, |this, cx| { - this.build_completion_request(completion_intent, cx) - })??; - - log::info!("Calling model.stream_completion"); - - let mut tool_use_limit_reached = false; - let mut refused = false; - let mut reached_max_tokens = false; - let mut tool_uses = Self::stream_completion_with_retries( - this.clone(), - model.clone(), - request, - &event_stream, - &mut tool_use_limit_reached, - &mut refused, - &mut reached_max_tokens, - cx, - ) - .await?; - - if refused { - return Ok(StopReason::Refusal); - } else if reached_max_tokens { - return Ok(StopReason::MaxTokens); - } - - let end_turn = tool_uses.is_empty(); - while let Some(tool_result) = tool_uses.next().await { - log::info!("Tool finished {:?}", tool_result); - - event_stream.update_tool_call_fields( - &tool_result.tool_use_id, - acp::ToolCallUpdateFields { - status: Some(if tool_result.is_error { - acp::ToolCallStatus::Failed - } else { - acp::ToolCallStatus::Completed - }), - raw_output: tool_result.output.clone(), - ..Default::default() - }, - ); - this.update(cx, |this, _cx| { - this.pending_message() - .tool_results - .insert(tool_result.tool_use_id.clone(), tool_result); - })?; - } + Self::stream_completion(&this, &model, intent, &event_stream, cx).await?; + let mut end_turn = true; this.update(cx, |this, cx| { + // Generate title if needed. if this.title.is_none() && update_title.is_none() { update_title = Some(this.update_title(&event_stream, cx)); } + + // End the turn if the model didn't use tools. + let message = this.pending_message.as_ref(); + end_turn = + message.map_or(true, |message| message.tool_results.is_empty()); + this.flush_pending_message(cx); })?; - if tool_use_limit_reached { + if this.read_with(cx, |this, _| this.tool_use_limit_reached)? { log::info!("Tool use limit reached, completing turn"); - this.update(cx, |this, _cx| this.tool_use_limit_reached = true)?; return Err(language_model::ToolUseLimitReachedError.into()); } else if end_turn { log::info!("No tool uses found, completing turn"); - return Ok(StopReason::EndTurn); + return Ok(()); } else { - this.update(cx, |this, cx| this.flush_pending_message(cx))?; - completion_intent = CompletionIntent::ToolResults; + intent = CompletionIntent::ToolResults; } } } .await; _ = this.update(cx, |this, cx| this.flush_pending_message(cx)); + if let Some(update_title) = update_title { + update_title.await.context("update title failed").log_err(); + } + match turn_result { - Ok(reason) => { - log::info!("Turn execution completed: {:?}", reason); - - if let Some(update_title) = update_title { - update_title.await.context("update title failed").log_err(); - } - - event_stream.send_stop(reason); - if reason == StopReason::Refusal { - _ = this.update(cx, |this, _| this.messages.truncate(message_ix)); - } + Ok(()) => { + log::info!("Turn execution completed"); + event_stream.send_stop(acp::StopReason::EndTurn); } Err(error) => { log::error!("Turn execution failed: {:?}", error); - event_stream.send_error(error); + match error.downcast::() { + Ok(CompletionError::Refusal) => { + event_stream.send_stop(acp::StopReason::Refusal); + _ = this.update(cx, |this, _| this.messages.truncate(message_ix)); + } + Ok(CompletionError::MaxTokens) => { + event_stream.send_stop(acp::StopReason::MaxTokens); + } + Ok(CompletionError::Other(error)) | Err(error) => { + event_stream.send_error(error); + } + } } } @@ -1181,17 +1152,17 @@ impl Thread { Ok(events_rx) } - async fn stream_completion_with_retries( - this: WeakEntity, - model: Arc, - request: LanguageModelRequest, + async fn stream_completion( + this: &WeakEntity, + model: &Arc, + completion_intent: CompletionIntent, event_stream: &ThreadEventStream, - tool_use_limit_reached: &mut bool, - refusal: &mut bool, - max_tokens_reached: &mut bool, cx: &mut AsyncApp, - ) -> Result>> { + ) -> Result<()> { log::debug!("Stream completion started successfully"); + let request = this.update(cx, |this, cx| { + this.build_completion_request(completion_intent, cx) + })??; let mut attempt = None; 'retry: loop { @@ -1204,68 +1175,33 @@ impl Thread { attempt ); - let mut events = model.stream_completion(request.clone(), cx).await?; - let mut tool_uses = FuturesUnordered::new(); + log::info!( + "Calling model.stream_completion, attempt {}", + attempt.unwrap_or(0) + ); + let mut events = model + .stream_completion(request.clone(), cx) + .await + .map_err(|error| anyhow!(error))?; + let mut tool_results = FuturesUnordered::new(); + while let Some(event) = events.next().await { match event { - Ok(LanguageModelCompletionEvent::StatusUpdate( - CompletionRequestStatus::ToolUseLimitReached, - )) => { - *tool_use_limit_reached = true; - } - Ok(LanguageModelCompletionEvent::StatusUpdate( - CompletionRequestStatus::UsageUpdated { amount, limit }, - )) => { - this.update(cx, |this, cx| { - this.update_model_request_usage(amount, limit, cx) - })?; - } - Ok(LanguageModelCompletionEvent::UsageUpdate(usage)) => { - telemetry::event!( - "Agent Thread Completion Usage Updated", - thread_id = this.read_with(cx, |this, _| this.id.to_string())?, - prompt_id = this.read_with(cx, |this, _| this.prompt_id.to_string())?, - model = model.telemetry_id(), - model_provider = model.provider_id().to_string(), - attempt, - input_tokens = usage.input_tokens, - output_tokens = usage.output_tokens, - cache_creation_input_tokens = usage.cache_creation_input_tokens, - cache_read_input_tokens = usage.cache_read_input_tokens, - ); - - this.update(cx, |this, cx| this.update_token_usage(usage, cx))?; - } - Ok(LanguageModelCompletionEvent::Stop(StopReason::Refusal)) => { - *refusal = true; - return Ok(FuturesUnordered::default()); - } - Ok(LanguageModelCompletionEvent::Stop(StopReason::MaxTokens)) => { - *max_tokens_reached = true; - return Ok(FuturesUnordered::default()); - } - Ok(LanguageModelCompletionEvent::Stop( - StopReason::ToolUse | StopReason::EndTurn, - )) => break, Ok(event) => { log::trace!("Received completion event: {:?}", event); - this.update(cx, |this, cx| { - tool_uses.extend(this.handle_streamed_completion_event( - event, - event_stream, - cx, - )); - })?; + tool_results.extend(this.update(cx, |this, cx| { + this.handle_streamed_completion_event(event, event_stream, cx) + })??); } Err(error) => { let completion_mode = this.read_with(cx, |thread, _cx| thread.completion_mode())?; if completion_mode == CompletionMode::Normal { - return Err(error.into()); + return Err(anyhow!(error))?; } let Some(strategy) = Self::retry_strategy_for(&error) else { - return Err(error.into()); + return Err(anyhow!(error))?; }; let max_attempts = match &strategy { @@ -1279,7 +1215,7 @@ impl Thread { let attempt = *attempt; if attempt > max_attempts { - return Err(error.into()); + return Err(anyhow!(error))?; } let delay = match &strategy { @@ -1306,7 +1242,29 @@ impl Thread { } } - return Ok(tool_uses); + while let Some(tool_result) = tool_results.next().await { + log::info!("Tool finished {:?}", tool_result); + + event_stream.update_tool_call_fields( + &tool_result.tool_use_id, + acp::ToolCallUpdateFields { + status: Some(if tool_result.is_error { + acp::ToolCallStatus::Failed + } else { + acp::ToolCallStatus::Completed + }), + raw_output: tool_result.output.clone(), + ..Default::default() + }, + ); + this.update(cx, |this, _cx| { + this.pending_message() + .tool_results + .insert(tool_result.tool_use_id.clone(), tool_result); + })?; + } + + return Ok(()); } } @@ -1328,14 +1286,14 @@ impl Thread { } /// A helper method that's called on every streamed completion event. - /// Returns an optional tool result task, which the main agentic loop in - /// send will send back to the model when it resolves. + /// Returns an optional tool result task, which the main agentic loop will + /// send back to the model when it resolves. fn handle_streamed_completion_event( &mut self, event: LanguageModelCompletionEvent, event_stream: &ThreadEventStream, cx: &mut Context, - ) -> Option> { + ) -> Result>> { log::trace!("Handling streamed completion event: {:?}", event); use LanguageModelCompletionEvent::*; @@ -1350,7 +1308,7 @@ impl Thread { } RedactedThinking { data } => self.handle_redacted_thinking_event(data, cx), ToolUse(tool_use) => { - return self.handle_tool_use_event(tool_use, event_stream, cx); + return Ok(self.handle_tool_use_event(tool_use, event_stream, cx)); } ToolUseJsonParseError { id, @@ -1358,18 +1316,46 @@ impl Thread { raw_input, json_parse_error, } => { - return Some(Task::ready(self.handle_tool_use_json_parse_error_event( - id, - tool_name, - raw_input, - json_parse_error, + return Ok(Some(Task::ready( + self.handle_tool_use_json_parse_error_event( + id, + tool_name, + raw_input, + json_parse_error, + ), ))); } - StatusUpdate(_) => {} - UsageUpdate(_) | Stop(_) => unreachable!(), + UsageUpdate(usage) => { + telemetry::event!( + "Agent Thread Completion Usage Updated", + thread_id = self.id.to_string(), + prompt_id = self.prompt_id.to_string(), + model = self.model.as_ref().map(|m| m.telemetry_id()), + model_provider = self.model.as_ref().map(|m| m.provider_id().to_string()), + input_tokens = usage.input_tokens, + output_tokens = usage.output_tokens, + cache_creation_input_tokens = usage.cache_creation_input_tokens, + cache_read_input_tokens = usage.cache_read_input_tokens, + ); + self.update_token_usage(usage, cx); + } + StatusUpdate(CompletionRequestStatus::UsageUpdated { amount, limit }) => { + self.update_model_request_usage(amount, limit, cx); + } + StatusUpdate( + CompletionRequestStatus::Started + | CompletionRequestStatus::Queued { .. } + | CompletionRequestStatus::Failed { .. }, + ) => {} + StatusUpdate(CompletionRequestStatus::ToolUseLimitReached) => { + self.tool_use_limit_reached = true; + } + Stop(StopReason::Refusal) => return Err(CompletionError::Refusal.into()), + Stop(StopReason::MaxTokens) => return Err(CompletionError::MaxTokens.into()), + Stop(StopReason::ToolUse | StopReason::EndTurn) => {} } - None + Ok(None) } fn handle_text_event( @@ -2225,25 +2211,8 @@ impl ThreadEventStream { self.0.unbounded_send(Ok(ThreadEvent::Retry(status))).ok(); } - fn send_stop(&self, reason: StopReason) { - match reason { - StopReason::EndTurn => { - self.0 - .unbounded_send(Ok(ThreadEvent::Stop(acp::StopReason::EndTurn))) - .ok(); - } - StopReason::MaxTokens => { - self.0 - .unbounded_send(Ok(ThreadEvent::Stop(acp::StopReason::MaxTokens))) - .ok(); - } - StopReason::Refusal => { - self.0 - .unbounded_send(Ok(ThreadEvent::Stop(acp::StopReason::Refusal))) - .ok(); - } - StopReason::ToolUse => {} - } + fn send_stop(&self, reason: acp::StopReason) { + self.0.unbounded_send(Ok(ThreadEvent::Stop(reason))).ok(); } fn send_canceled(&self) { From e42a0da5ce896edb05c8a19f3fab9a989c504cd6 Mon Sep 17 00:00:00 2001 From: Julia Ryan Date: Thu, 21 Aug 2025 12:03:30 -0500 Subject: [PATCH 29/89] Upload telemetry event on crashes (#36695) This will let us track crashes-per-launch using the new minidump-based crash reporting. Release Notes: - N/A Co-authored-by: Conrad Irwin Co-authored-by: Marshall Bowers --- crates/zed/src/reliability.rs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/zed/src/reliability.rs b/crates/zed/src/reliability.rs index f55468280c..646a3af5bb 100644 --- a/crates/zed/src/reliability.rs +++ b/crates/zed/src/reliability.rs @@ -251,6 +251,7 @@ pub fn init( endpoint, minidump_contents, &metadata, + installation_id.clone(), ) .await .log_err(); @@ -478,7 +479,9 @@ fn upload_panics_and_crashes( return; } cx.background_spawn(async move { - upload_previous_minidumps(http.clone()).await.warn_on_err(); + upload_previous_minidumps(http.clone(), installation_id.clone()) + .await + .warn_on_err(); let most_recent_panic = upload_previous_panics(http.clone(), &panic_report_url) .await .log_err() @@ -546,7 +549,10 @@ async fn upload_previous_panics( Ok(most_recent_panic) } -pub async fn upload_previous_minidumps(http: Arc) -> anyhow::Result<()> { +pub async fn upload_previous_minidumps( + http: Arc, + installation_id: Option, +) -> anyhow::Result<()> { let Some(minidump_endpoint) = MINIDUMP_ENDPOINT.as_ref() else { log::warn!("Minidump endpoint not set"); return Ok(()); @@ -569,6 +575,7 @@ pub async fn upload_previous_minidumps(http: Arc) -> anyhow:: .await .context("Failed to read minidump")?, &metadata, + installation_id.clone(), ) .await .log_err() @@ -586,6 +593,7 @@ async fn upload_minidump( endpoint: &str, minidump: Vec, metadata: &crashes::CrashInfo, + installation_id: Option, ) -> Result<()> { let mut form = Form::new() .part( @@ -601,7 +609,9 @@ async fn upload_minidump( .text("sentry[tags][version]", metadata.init.zed_version.clone()) .text("sentry[release]", metadata.init.commit_sha.clone()) .text("platform", "rust"); + let mut panic_message = "".to_owned(); if let Some(panic_info) = metadata.panic.as_ref() { + panic_message = panic_info.message.clone(); form = form.text("sentry[logentry][formatted]", panic_info.message.clone()); form = form.text("span", panic_info.span.clone()); // TODO: add gpu-context, feature-flag-context, and more of device-context like gpu @@ -610,6 +620,16 @@ async fn upload_minidump( if let Some(minidump_error) = metadata.minidump_error.clone() { form = form.text("minidump_error", minidump_error); } + if let Some(id) = installation_id.clone() { + form = form.text("sentry[user][id]", id) + } + + ::telemetry::event!( + "Minidump Uploaded", + panic_message = panic_message, + crashed_version = metadata.init.zed_version.clone(), + commit_sha = metadata.init.commit_sha.clone(), + ); let mut response_text = String::new(); let mut response = http.send_multipart_form(endpoint, form).await?; From c5d96e1ef8b13619ddc297bbb66494c7886af19f Mon Sep 17 00:00:00 2001 From: Julia Ryan Date: Thu, 21 Aug 2025 12:19:57 -0500 Subject: [PATCH 30/89] Use Tokio::spawn instead of getting an executor handle (#36701) This was causing panics due to the handles being dropped out of order. It doesn't seem possible to guarantee the correct drop ordering given that we're holding them over await points, so lets just spawn on the tokio executor itself which gives us access to the state we needed those handles for in the first place. Fixes: ZED-1R Release Notes: - N/A Co-authored-by: Conrad Irwin Co-authored-by: Marshall Bowers --- Cargo.lock | 1 + crates/client/src/client.rs | 24 ++++++++++--------- .../cloud_api_client/src/cloud_api_client.rs | 8 +------ crates/gpui_tokio/Cargo.toml | 1 + crates/gpui_tokio/src/gpui_tokio.rs | 22 +++++++++++++++++ 5 files changed, 38 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 203e9869e2..da24807695 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7538,6 +7538,7 @@ dependencies = [ name = "gpui_tokio" version = "0.1.0" dependencies = [ + "anyhow", "gpui", "tokio", "util", diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index ed3f114943..f9b8a10610 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -1290,19 +1290,21 @@ impl Client { "http" => Http, _ => Err(anyhow!("invalid rpc url: {}", rpc_url))?, }; - let rpc_host = rpc_url - .host_str() - .zip(rpc_url.port_or_known_default()) - .context("missing host in rpc url")?; - let stream = { - let handle = cx.update(|cx| gpui_tokio::Tokio::handle(cx)).ok().unwrap(); - let _guard = handle.enter(); - match proxy { - Some(proxy) => connect_proxy_stream(&proxy, rpc_host).await?, - None => Box::new(TcpStream::connect(rpc_host).await?), + let stream = gpui_tokio::Tokio::spawn_result(cx, { + let rpc_url = rpc_url.clone(); + async move { + let rpc_host = rpc_url + .host_str() + .zip(rpc_url.port_or_known_default()) + .context("missing host in rpc url")?; + Ok(match proxy { + Some(proxy) => connect_proxy_stream(&proxy, rpc_host).await?, + None => Box::new(TcpStream::connect(rpc_host).await?), + }) } - }; + })? + .await?; log::info!("connected to rpc endpoint {}", rpc_url); diff --git a/crates/cloud_api_client/src/cloud_api_client.rs b/crates/cloud_api_client/src/cloud_api_client.rs index 92417d8319..205f3e2432 100644 --- a/crates/cloud_api_client/src/cloud_api_client.rs +++ b/crates/cloud_api_client/src/cloud_api_client.rs @@ -102,13 +102,7 @@ impl CloudApiClient { let credentials = credentials.as_ref().context("no credentials provided")?; let authorization_header = format!("{} {}", credentials.user_id, credentials.access_token); - Ok(cx.spawn(async move |cx| { - let handle = cx - .update(|cx| Tokio::handle(cx)) - .ok() - .context("failed to get Tokio handle")?; - let _guard = handle.enter(); - + Ok(Tokio::spawn_result(cx, async move { let ws = WebSocket::connect(connect_url) .with_request( request::Builder::new() diff --git a/crates/gpui_tokio/Cargo.toml b/crates/gpui_tokio/Cargo.toml index 46d5eafd5a..2d4abf4063 100644 --- a/crates/gpui_tokio/Cargo.toml +++ b/crates/gpui_tokio/Cargo.toml @@ -13,6 +13,7 @@ path = "src/gpui_tokio.rs" doctest = false [dependencies] +anyhow.workspace = true util.workspace = true gpui.workspace = true tokio = { workspace = true, features = ["rt", "rt-multi-thread"] } diff --git a/crates/gpui_tokio/src/gpui_tokio.rs b/crates/gpui_tokio/src/gpui_tokio.rs index fffe18a616..8384f2a88e 100644 --- a/crates/gpui_tokio/src/gpui_tokio.rs +++ b/crates/gpui_tokio/src/gpui_tokio.rs @@ -52,6 +52,28 @@ impl Tokio { }) } + /// Spawns the given future on Tokio's thread pool, and returns it via a GPUI task + /// Note that the Tokio task will be cancelled if the GPUI task is dropped + pub fn spawn_result(cx: &C, f: Fut) -> C::Result>> + where + C: AppContext, + Fut: Future> + Send + 'static, + R: Send + 'static, + { + cx.read_global(|tokio: &GlobalTokio, cx| { + let join_handle = tokio.runtime.spawn(f); + let abort_handle = join_handle.abort_handle(); + let cancel = defer(move || { + abort_handle.abort(); + }); + cx.background_spawn(async move { + let result = join_handle.await?; + drop(cancel); + result + }) + }) + } + pub fn handle(cx: &App) -> tokio::runtime::Handle { GlobalTokio::global(cx).runtime.handle().clone() } From ca897fcd2fbef213b7b967fb1455ef99d2d52213 Mon Sep 17 00:00:00 2001 From: Julia Ryan Date: Thu, 21 Aug 2025 05:33:45 -0500 Subject: [PATCH 31/89] Avoid suspending panicking thread while crashing (#36645) On the latest build @maxbrunsfeld got a panic that hung zed. It appeared that the hang occured after the minidump had been successfully written, so our theory on what happened is that the `suspend_all_other_threads` call in the crash handler suspended the panicking thread (due to the signal from simulate_exception being received on a different thread), and then when the crash handler returned everything was suspended so the panic hook never made it to the `process::abort`. This change makes the crash handler avoid _both_ the current and the panicking thread which should avoid that scenario. Release Notes: - N/A --- crates/crashes/src/crashes.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/crashes/src/crashes.rs b/crates/crashes/src/crashes.rs index 4e4b69f639..b1afc5ae45 100644 --- a/crates/crashes/src/crashes.rs +++ b/crates/crashes/src/crashes.rs @@ -4,6 +4,8 @@ use minidumper::{Client, LoopAction, MinidumpBinary}; use release_channel::{RELEASE_CHANNEL, ReleaseChannel}; use serde::{Deserialize, Serialize}; +#[cfg(target_os = "macos")] +use std::sync::atomic::AtomicU32; use std::{ env, fs::{self, File}, @@ -26,6 +28,9 @@ pub static REQUESTED_MINIDUMP: AtomicBool = AtomicBool::new(false); const CRASH_HANDLER_PING_TIMEOUT: Duration = Duration::from_secs(60); const CRASH_HANDLER_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +#[cfg(target_os = "macos")] +static PANIC_THREAD_ID: AtomicU32 = AtomicU32::new(0); + pub async fn init(crash_init: InitCrashHandler) { if *RELEASE_CHANNEL == ReleaseChannel::Dev && env::var("ZED_GENERATE_MINIDUMPS").is_err() { return; @@ -110,9 +115,10 @@ unsafe fn suspend_all_other_threads() { mach2::task::task_threads(task, &raw mut threads, &raw mut count); } let current = unsafe { mach2::mach_init::mach_thread_self() }; + let panic_thread = PANIC_THREAD_ID.load(Ordering::SeqCst); for i in 0..count { let t = unsafe { *threads.add(i as usize) }; - if t != current { + if t != current && t != panic_thread { unsafe { mach2::thread_act::thread_suspend(t) }; } } @@ -238,6 +244,13 @@ pub fn handle_panic(message: String, span: Option<&Location>) { ) .ok(); log::error!("triggering a crash to generate a minidump..."); + + #[cfg(target_os = "macos")] + PANIC_THREAD_ID.store( + unsafe { mach2::mach_init::mach_thread_self() }, + Ordering::SeqCst, + ); + #[cfg(target_os = "linux")] CrashHandler.simulate_signal(crash_handler::Signal::Trap as u32); #[cfg(not(target_os = "linux"))] From 39b6558d0f2d90cc59d268e694f41a2d870fb38c Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 21 Aug 2025 12:06:27 -0700 Subject: [PATCH 32/89] acp: Move ignored integration tests behind e2e flag (#36711) Release Notes: - N/A --- crates/agent2/src/tests/mod.rs | 44 +++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index 3bd1be497e..edba227da7 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -32,17 +32,22 @@ mod test_tools; use test_tools::*; #[gpui::test] -#[ignore = "can't run on CI yet"] async fn test_echo(cx: &mut TestAppContext) { - let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await; + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); let events = thread .update(cx, |thread, cx| { thread.send(UserMessageId::new(), ["Testing: Reply with 'Hello'"], cx) }) - .unwrap() - .collect() - .await; + .unwrap(); + cx.run_until_parked(); + fake_model.send_last_completion_stream_text_chunk("Hello"); + fake_model + .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)); + fake_model.end_last_completion_stream(); + + let events = events.collect().await; thread.update(cx, |thread, _cx| { assert_eq!( thread.last_message().unwrap().to_markdown(), @@ -57,9 +62,9 @@ async fn test_echo(cx: &mut TestAppContext) { } #[gpui::test] -#[ignore = "can't run on CI yet"] async fn test_thinking(cx: &mut TestAppContext) { - let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4Thinking).await; + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); let events = thread .update(cx, |thread, cx| { @@ -74,9 +79,18 @@ async fn test_thinking(cx: &mut TestAppContext) { cx, ) }) - .unwrap() - .collect() - .await; + .unwrap(); + cx.run_until_parked(); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::Thinking { + text: "Think".to_string(), + signature: None, + }); + fake_model.send_last_completion_stream_text_chunk("Hello"); + fake_model + .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)); + fake_model.end_last_completion_stream(); + + let events = events.collect().await; thread.update(cx, |thread, _cx| { assert_eq!( thread.last_message().unwrap().to_markdown(), @@ -271,7 +285,7 @@ async fn test_prompt_caching(cx: &mut TestAppContext) { } #[gpui::test] -#[ignore = "can't run on CI yet"] +#[cfg_attr(not(feature = "e2e"), ignore)] async fn test_basic_tool_calls(cx: &mut TestAppContext) { let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await; @@ -331,7 +345,7 @@ async fn test_basic_tool_calls(cx: &mut TestAppContext) { } #[gpui::test] -#[ignore = "can't run on CI yet"] +#[cfg_attr(not(feature = "e2e"), ignore)] async fn test_streaming_tool_calls(cx: &mut TestAppContext) { let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await; @@ -794,7 +808,7 @@ async fn next_tool_call_authorization( } #[gpui::test] -#[ignore = "can't run on CI yet"] +#[cfg_attr(not(feature = "e2e"), ignore)] async fn test_concurrent_tool_calls(cx: &mut TestAppContext) { let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await; @@ -919,7 +933,7 @@ async fn test_profiles(cx: &mut TestAppContext) { } #[gpui::test] -#[ignore = "can't run on CI yet"] +#[cfg_attr(not(feature = "e2e"), ignore)] async fn test_cancellation(cx: &mut TestAppContext) { let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await; @@ -1797,7 +1811,6 @@ struct ThreadTest { enum TestModel { Sonnet4, - Sonnet4Thinking, Fake, } @@ -1805,7 +1818,6 @@ impl TestModel { fn id(&self) -> LanguageModelId { match self { TestModel::Sonnet4 => LanguageModelId("claude-sonnet-4-latest".into()), - TestModel::Sonnet4Thinking => LanguageModelId("claude-sonnet-4-thinking-latest".into()), TestModel::Fake => unreachable!(), } } From 210727412c9cb8ab580ed5e3bd5807cc2707f546 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 21 Aug 2025 16:56:15 -0300 Subject: [PATCH 33/89] acp: Hide loading diff animation for external agents and update in place (#36699) The loading diff animation can be jarring for external agents because they stream the diff at the same time the tool call is pushed, so it's only displayed while we're asynchronously calculating the diff. We'll now only show it for the native agent. Also, we'll now only update the diff when it changes, which avoids unnecessarily hiding it for a few frames. Release Notes: - N/A Co-authored-by: Bennet Bo Fenner --- crates/acp_thread/src/acp_thread.rs | 41 ++++++++++++++++-- crates/acp_thread/src/diff.rs | 59 +++++++++++++++++++------- crates/agent_ui/src/acp/thread_view.rs | 6 ++- 3 files changed, 85 insertions(+), 21 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 61bc50576a..a45787f039 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -238,10 +238,21 @@ impl ToolCall { } if let Some(content) = content { - self.content = content - .into_iter() - .map(|chunk| ToolCallContent::from_acp(chunk, language_registry.clone(), cx)) - .collect(); + let new_content_len = content.len(); + let mut content = content.into_iter(); + + // Reuse existing content if we can + for (old, new) in self.content.iter_mut().zip(content.by_ref()) { + old.update_from_acp(new, language_registry.clone(), cx); + } + for new in content { + self.content.push(ToolCallContent::from_acp( + new, + language_registry.clone(), + cx, + )) + } + self.content.truncate(new_content_len); } if let Some(locations) = locations { @@ -551,6 +562,28 @@ impl ToolCallContent { } } + pub fn update_from_acp( + &mut self, + new: acp::ToolCallContent, + language_registry: Arc, + cx: &mut App, + ) { + let needs_update = match (&self, &new) { + (Self::Diff(old_diff), acp::ToolCallContent::Diff { diff: new_diff }) => { + old_diff.read(cx).needs_update( + new_diff.old_text.as_deref().unwrap_or(""), + &new_diff.new_text, + cx, + ) + } + _ => true, + }; + + if needs_update { + *self = Self::from_acp(new, language_registry, cx); + } + } + pub fn to_markdown(&self, cx: &App) -> String { match self { Self::ContentBlock(content) => content.to_markdown(cx).to_string(), diff --git a/crates/acp_thread/src/diff.rs b/crates/acp_thread/src/diff.rs index 130bc3ab6b..59f907dcc4 100644 --- a/crates/acp_thread/src/diff.rs +++ b/crates/acp_thread/src/diff.rs @@ -28,10 +28,12 @@ impl Diff { cx: &mut Context, ) -> Self { let multibuffer = cx.new(|_cx| MultiBuffer::without_headers(Capability::ReadOnly)); - let buffer = cx.new(|cx| Buffer::local(new_text, cx)); + let new_buffer = cx.new(|cx| Buffer::local(new_text, cx)); + let base_text = old_text.clone().unwrap_or(String::new()).into(); let task = cx.spawn({ let multibuffer = multibuffer.clone(); let path = path.clone(); + let buffer = new_buffer.clone(); async move |_, cx| { let language = language_registry .language_for_file_path(&path) @@ -76,6 +78,8 @@ impl Diff { Self::Finalized(FinalizedDiff { multibuffer, path, + base_text, + new_buffer, _update_diff: task, }) } @@ -119,7 +123,7 @@ impl Diff { diff.update(cx); } }), - buffer, + new_buffer: buffer, diff: buffer_diff, revealed_ranges: Vec::new(), update_diff: Task::ready(Ok(())), @@ -154,9 +158,9 @@ impl Diff { .map(|buffer| buffer.read(cx).text()) .join("\n"); let path = match self { - Diff::Pending(PendingDiff { buffer, .. }) => { - buffer.read(cx).file().map(|file| file.path().as_ref()) - } + Diff::Pending(PendingDiff { + new_buffer: buffer, .. + }) => buffer.read(cx).file().map(|file| file.path().as_ref()), Diff::Finalized(FinalizedDiff { path, .. }) => Some(path.as_path()), }; format!( @@ -169,12 +173,33 @@ impl Diff { pub fn has_revealed_range(&self, cx: &App) -> bool { self.multibuffer().read(cx).excerpt_paths().next().is_some() } + + pub fn needs_update(&self, old_text: &str, new_text: &str, cx: &App) -> bool { + match self { + Diff::Pending(PendingDiff { + base_text, + new_buffer, + .. + }) => { + base_text.as_str() != old_text + || !new_buffer.read(cx).as_rope().chunks().equals_str(new_text) + } + Diff::Finalized(FinalizedDiff { + base_text, + new_buffer, + .. + }) => { + base_text.as_str() != old_text + || !new_buffer.read(cx).as_rope().chunks().equals_str(new_text) + } + } + } } pub struct PendingDiff { multibuffer: Entity, base_text: Arc, - buffer: Entity, + new_buffer: Entity, diff: Entity, revealed_ranges: Vec>, _subscription: Subscription, @@ -183,7 +208,7 @@ pub struct PendingDiff { impl PendingDiff { pub fn update(&mut self, cx: &mut Context) { - let buffer = self.buffer.clone(); + let buffer = self.new_buffer.clone(); let buffer_diff = self.diff.clone(); let base_text = self.base_text.clone(); self.update_diff = cx.spawn(async move |diff, cx| { @@ -221,10 +246,10 @@ impl PendingDiff { fn finalize(&self, cx: &mut Context) -> FinalizedDiff { let ranges = self.excerpt_ranges(cx); let base_text = self.base_text.clone(); - let language_registry = self.buffer.read(cx).language_registry(); + let language_registry = self.new_buffer.read(cx).language_registry(); let path = self - .buffer + .new_buffer .read(cx) .file() .map(|file| file.path().as_ref()) @@ -233,12 +258,12 @@ impl PendingDiff { // Replace the buffer in the multibuffer with the snapshot let buffer = cx.new(|cx| { - let language = self.buffer.read(cx).language().cloned(); + let language = self.new_buffer.read(cx).language().cloned(); let buffer = TextBuffer::new_normalized( 0, cx.entity_id().as_non_zero_u64().into(), - self.buffer.read(cx).line_ending(), - self.buffer.read(cx).as_rope().clone(), + self.new_buffer.read(cx).line_ending(), + self.new_buffer.read(cx).as_rope().clone(), ); let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite); buffer.set_language(language, cx); @@ -274,7 +299,9 @@ impl PendingDiff { FinalizedDiff { path, + base_text: self.base_text.clone(), multibuffer: self.multibuffer.clone(), + new_buffer: self.new_buffer.clone(), _update_diff: update_diff, } } @@ -283,8 +310,8 @@ impl PendingDiff { let ranges = self.excerpt_ranges(cx); self.multibuffer.update(cx, |multibuffer, cx| { multibuffer.set_excerpts_for_path( - PathKey::for_buffer(&self.buffer, cx), - self.buffer.clone(), + PathKey::for_buffer(&self.new_buffer, cx), + self.new_buffer.clone(), ranges, editor::DEFAULT_MULTIBUFFER_CONTEXT, cx, @@ -296,7 +323,7 @@ impl PendingDiff { } fn excerpt_ranges(&self, cx: &App) -> Vec> { - let buffer = self.buffer.read(cx); + let buffer = self.new_buffer.read(cx); let diff = self.diff.read(cx); let mut ranges = diff .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer, cx) @@ -330,6 +357,8 @@ impl PendingDiff { pub struct FinalizedDiff { path: PathBuf, + base_text: Arc, + new_buffer: Entity, multibuffer: Entity, _update_diff: Task>, } diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 7e330b7e6f..a15f764375 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -1625,7 +1625,9 @@ impl AcpThreadView { .into_any() } ToolCallStatus::Pending | ToolCallStatus::InProgress - if is_edit && tool_call.content.is_empty() => + if is_edit + && tool_call.content.is_empty() + && self.as_native_connection(cx).is_some() => { self.render_diff_loading(cx).into_any() } @@ -1981,7 +1983,7 @@ impl AcpThreadView { && diff.read(cx).has_revealed_range(cx) { editor.into_any_element() - } else if tool_progress { + } else if tool_progress && self.as_native_connection(cx).is_some() { self.render_diff_loading(cx) } else { Empty.into_any() From 48f51c0c6032b67e468f93e4a6fac81f1fdc7670 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 21 Aug 2025 16:56:40 -0300 Subject: [PATCH 34/89] acp: Remove invalid creases on edit (#36708) Release Notes: - N/A Co-authored-by: Bennet Bo Fenner --- crates/agent_ui/src/acp/message_editor.rs | 54 +++++++++++++---------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index 510ec5f480..cd90ee99d9 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -11,7 +11,7 @@ use assistant_slash_commands::codeblock_fence_for_path; use collections::{HashMap, HashSet}; use editor::{ Addon, Anchor, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, - EditorEvent, EditorMode, EditorStyle, ExcerptId, FoldPlaceholder, MultiBuffer, + EditorEvent, EditorMode, EditorSnapshot, EditorStyle, ExcerptId, FoldPlaceholder, MultiBuffer, SemanticsProvider, ToOffset, actions::Paste, display_map::{Crease, CreaseId, FoldId}, @@ -140,11 +140,11 @@ impl MessageEditor { .detach(); let mut subscriptions = Vec::new(); - if prevent_slash_commands { - subscriptions.push(cx.subscribe_in(&editor, window, { - let semantics_provider = semantics_provider.clone(); - move |this, editor, event, window, cx| { - if let EditorEvent::Edited { .. } = event { + subscriptions.push(cx.subscribe_in(&editor, window, { + let semantics_provider = semantics_provider.clone(); + move |this, editor, event, window, cx| { + if let EditorEvent::Edited { .. } = event { + if prevent_slash_commands { this.highlight_slash_command( semantics_provider.clone(), editor.clone(), @@ -152,9 +152,12 @@ impl MessageEditor { cx, ); } + let snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx)); + this.mention_set.remove_invalid(snapshot); + cx.notify(); } - })); - } + } + })); Self { editor, @@ -730,11 +733,6 @@ impl MessageEditor { editor.display_map.update(cx, |map, cx| { let snapshot = map.snapshot(cx); for (crease_id, crease) in snapshot.crease_snapshot.creases() { - // Skip creases that have been edited out of the message buffer. - if !crease.range().start.is_valid(&snapshot.buffer_snapshot) { - continue; - } - let Some(mention) = contents.get(&crease_id) else { continue; }; @@ -1482,17 +1480,6 @@ impl MentionSet { self.text_thread_summaries.insert(path, task); } - pub fn drain(&mut self) -> impl Iterator { - self.fetch_results.clear(); - self.thread_summaries.clear(); - self.text_thread_summaries.clear(); - self.directories.clear(); - self.uri_by_crease_id - .drain() - .map(|(id, _)| id) - .chain(self.images.drain().map(|(id, _)| id)) - } - pub fn contents( &self, project: &Entity, @@ -1703,6 +1690,25 @@ impl MentionSet { anyhow::Ok(contents) }) } + + pub fn drain(&mut self) -> impl Iterator { + self.fetch_results.clear(); + self.thread_summaries.clear(); + self.text_thread_summaries.clear(); + self.directories.clear(); + self.uri_by_crease_id + .drain() + .map(|(id, _)| id) + .chain(self.images.drain().map(|(id, _)| id)) + } + + pub fn remove_invalid(&mut self, snapshot: EditorSnapshot) { + for (crease_id, crease) in snapshot.crease_snapshot.creases() { + if !crease.range().start.is_valid(&snapshot.buffer_snapshot) { + self.uri_by_crease_id.remove(&crease_id); + } + } + } } struct SlashCommandSemanticsProvider { From 22dd7ac7324a3d98a3ba6ea740b78da7b8b9d8a1 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 21 Aug 2025 17:05:29 -0300 Subject: [PATCH 35/89] thread view: Add improvements to the UI (#36680) Release Notes: - N/A --- crates/agent_servers/src/claude.rs | 2 +- crates/agent_ui/src/acp/thread_view.rs | 108 +++++----- crates/ui/src/components/disclosure.rs | 14 +- crates/ui/src/components/label.rs | 2 + .../ui/src/components/label/spinner_label.rs | 192 ++++++++++++++++++ 5 files changed, 269 insertions(+), 49 deletions(-) create mode 100644 crates/ui/src/components/label/spinner_label.rs diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index ef666974f1..d6ccabb130 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -44,7 +44,7 @@ pub struct ClaudeCode; impl AgentServer for ClaudeCode { fn name(&self) -> &'static str { - "Claude Code" + "Welcome to Claude Code" } fn empty_state_headline(&self) -> &'static str { diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index a15f764375..05d31051b2 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -41,7 +41,7 @@ use text::Anchor; use theme::ThemeSettings; use ui::{ Callout, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding, PopoverMenuHandle, - Scrollbar, ScrollbarState, Tooltip, prelude::*, + Scrollbar, ScrollbarState, SpinnerLabel, Tooltip, prelude::*, }; use util::{ResultExt, size::format_file_size, time::duration_alt_display}; use workspace::{CollaboratorId, Workspace}; @@ -1205,7 +1205,7 @@ impl AcpThreadView { div() .py_3() .px_2() - .rounded_lg() + .rounded_md() .shadow_md() .bg(cx.theme().colors().editor_background) .border_1() @@ -1263,7 +1263,7 @@ impl AcpThreadView { .into_any() } AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => { - let style = default_markdown_style(false, window, cx); + let style = default_markdown_style(false, false, window, cx); let message_body = v_flex() .w_full() .gap_2p5() @@ -1398,8 +1398,6 @@ impl AcpThreadView { .relative() .w_full() .gap_1p5() - .opacity(0.8) - .hover(|style| style.opacity(1.)) .child( h_flex() .size_4() @@ -1440,6 +1438,7 @@ impl AcpThreadView { .child( div() .text_size(self.tool_name_font_size()) + .text_color(cx.theme().colors().text_muted) .child("Thinking"), ) .on_click(cx.listener({ @@ -1463,9 +1462,10 @@ impl AcpThreadView { .border_l_1() .border_color(self.tool_card_border_color(cx)) .text_ui_sm(cx) - .child( - self.render_markdown(chunk, default_markdown_style(false, window, cx)), - ), + .child(self.render_markdown( + chunk, + default_markdown_style(false, false, window, cx), + )), ) }) .into_any_element() @@ -1555,11 +1555,11 @@ impl AcpThreadView { | ToolCallStatus::Completed => None, ToolCallStatus::InProgress => Some( Icon::new(IconName::ArrowCircle) - .color(Color::Accent) + .color(Color::Muted) .size(IconSize::Small) .with_animation( "running", - Animation::new(Duration::from_secs(2)).repeat(), + Animation::new(Duration::from_secs(3)).repeat(), |icon, delta| icon.transform(Transformation::rotate(percentage(delta))), ) .into_any(), @@ -1572,6 +1572,10 @@ impl AcpThreadView { ), }; + let failed_tool_call = matches!( + tool_call.status, + ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed + ); let needs_confirmation = matches!( tool_call.status, ToolCallStatus::WaitingForConfirmation { .. } @@ -1652,7 +1656,7 @@ impl AcpThreadView { v_flex() .when(use_card_layout, |this| { - this.rounded_lg() + this.rounded_md() .border_1() .border_color(self.tool_card_border_color(cx)) .bg(cx.theme().colors().editor_background) @@ -1664,20 +1668,16 @@ impl AcpThreadView { .w_full() .gap_1() .justify_between() - .map(|this| { - if use_card_layout { - this.pl_2() - .pr_1p5() - .py_1() - .rounded_t_md() - .when(is_open, |this| { - this.border_b_1() - .border_color(self.tool_card_border_color(cx)) - }) - .bg(self.tool_card_header_bg(cx)) - } else { - this.opacity(0.8).hover(|style| style.opacity(1.)) - } + .when(use_card_layout, |this| { + this.pl_2() + .pr_1p5() + .py_1() + .rounded_t_md() + .when(is_open && !failed_tool_call, |this| { + this.border_b_1() + .border_color(self.tool_card_border_color(cx)) + }) + .bg(self.tool_card_header_bg(cx)) }) .child( h_flex() @@ -1709,13 +1709,15 @@ impl AcpThreadView { .px_1p5() .rounded_sm() .overflow_x_scroll() - .opacity(0.8) .hover(|label| { - label.opacity(1.).bg(cx - .theme() - .colors() - .element_hover - .opacity(0.5)) + label.bg(cx.theme().colors().element_hover.opacity(0.5)) + }) + .map(|this| { + if use_card_layout { + this.text_color(cx.theme().colors().text) + } else { + this.text_color(cx.theme().colors().text_muted) + } }) .child(name) .tooltip(Tooltip::text("Jump to File")) @@ -1738,7 +1740,7 @@ impl AcpThreadView { .overflow_x_scroll() .child(self.render_markdown( tool_call.label.clone(), - default_markdown_style(false, window, cx), + default_markdown_style(false, true, window, cx), )), ) .child(gradient_overlay(gradient_color)) @@ -1804,9 +1806,9 @@ impl AcpThreadView { .border_color(self.tool_card_border_color(cx)) .text_sm() .text_color(cx.theme().colors().text_muted) - .child(self.render_markdown(markdown, default_markdown_style(false, window, cx))) + .child(self.render_markdown(markdown, default_markdown_style(false, false, window, cx))) .child( - Button::new(button_id, "Collapse Output") + Button::new(button_id, "Collapse") .full_width() .style(ButtonStyle::Outlined) .label_size(LabelSize::Small) @@ -2131,7 +2133,7 @@ impl AcpThreadView { .to_string() } else { format!( - "Output is {} long—to avoid unexpected token usage, \ + "Output is {} long, and to avoid unexpected token usage, \ only 16 KB was sent back to the model.", format_file_size(output.original_content_len as u64, true), ) @@ -2199,7 +2201,7 @@ impl AcpThreadView { .border_1() .when(tool_failed || command_failed, |card| card.border_dashed()) .border_color(border_color) - .rounded_lg() + .rounded_md() .overflow_hidden() .child( v_flex() @@ -2553,9 +2555,10 @@ impl AcpThreadView { .into_any(), ) .children(description.map(|desc| { - div().text_ui(cx).text_center().child( - self.render_markdown(desc.clone(), default_markdown_style(false, window, cx)), - ) + div().text_ui(cx).text_center().child(self.render_markdown( + desc.clone(), + default_markdown_style(false, false, window, cx), + )) })) .children( configuration_view @@ -3379,7 +3382,7 @@ impl AcpThreadView { "used-tokens-label", Animation::new(Duration::from_secs(2)) .repeat() - .with_easing(pulsating_between(0.6, 1.)), + .with_easing(pulsating_between(0.3, 0.8)), |label, delta| label.alpha(delta), ) .into_any() @@ -4636,9 +4639,9 @@ impl Render for AcpThreadView { ThreadStatus::Idle | ThreadStatus::WaitingForToolConfirmation => None, ThreadStatus::Generating => div() - .px_5() .py_2() - .child(LoadingLabel::new("").size(LabelSize::Small)) + .px(rems_from_px(22.)) + .child(SpinnerLabel::new().size(LabelSize::Small)) .into(), }, ) @@ -4671,7 +4674,12 @@ impl Render for AcpThreadView { } } -fn default_markdown_style(buffer_font: bool, window: &Window, cx: &App) -> MarkdownStyle { +fn default_markdown_style( + buffer_font: bool, + muted_text: bool, + window: &Window, + cx: &App, +) -> MarkdownStyle { let theme_settings = ThemeSettings::get_global(cx); let colors = cx.theme().colors(); @@ -4692,20 +4700,26 @@ fn default_markdown_style(buffer_font: bool, window: &Window, cx: &App) -> Markd TextSize::Default.rems(cx) }; + let text_color = if muted_text { + colors.text_muted + } else { + colors.text + }; + text_style.refine(&TextStyleRefinement { font_family: Some(font_family), font_fallbacks: theme_settings.ui_font.fallbacks.clone(), font_features: Some(theme_settings.ui_font.features.clone()), font_size: Some(font_size.into()), line_height: Some(line_height.into()), - color: Some(cx.theme().colors().text), + color: Some(text_color), ..Default::default() }); MarkdownStyle { base_text_style: text_style.clone(), syntax: cx.theme().syntax().clone(), - selection_background_color: cx.theme().colors().element_selection_background, + selection_background_color: colors.element_selection_background, code_block_overflow_x_scroll: true, table_overflow_x_scroll: true, heading_level_styles: Some(HeadingLevelStyles { @@ -4791,7 +4805,7 @@ fn plan_label_markdown_style( window: &Window, cx: &App, ) -> MarkdownStyle { - let default_md_style = default_markdown_style(false, window, cx); + let default_md_style = default_markdown_style(false, false, window, cx); MarkdownStyle { base_text_style: TextStyle { @@ -4811,7 +4825,7 @@ fn plan_label_markdown_style( } fn terminal_command_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { - let default_md_style = default_markdown_style(true, window, cx); + let default_md_style = default_markdown_style(true, false, window, cx); MarkdownStyle { base_text_style: TextStyle { diff --git a/crates/ui/src/components/disclosure.rs b/crates/ui/src/components/disclosure.rs index 98406cd1e2..4bb3419176 100644 --- a/crates/ui/src/components/disclosure.rs +++ b/crates/ui/src/components/disclosure.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use gpui::{ClickEvent, CursorStyle}; +use gpui::{ClickEvent, CursorStyle, SharedString}; use crate::{Color, IconButton, IconButtonShape, IconName, IconSize, prelude::*}; @@ -14,6 +14,7 @@ pub struct Disclosure { cursor_style: CursorStyle, opened_icon: IconName, closed_icon: IconName, + visible_on_hover: Option, } impl Disclosure { @@ -27,6 +28,7 @@ impl Disclosure { cursor_style: CursorStyle::PointingHand, opened_icon: IconName::ChevronDown, closed_icon: IconName::ChevronRight, + visible_on_hover: None, } } @@ -73,6 +75,13 @@ impl Clickable for Disclosure { } } +impl VisibleOnHover for Disclosure { + fn visible_on_hover(mut self, group_name: impl Into) -> Self { + self.visible_on_hover = Some(group_name.into()); + self + } +} + impl RenderOnce for Disclosure { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { IconButton::new( @@ -87,6 +96,9 @@ impl RenderOnce for Disclosure { .icon_size(IconSize::Small) .disabled(self.disabled) .toggle_state(self.selected) + .when_some(self.visible_on_hover.clone(), |this, group_name| { + this.visible_on_hover(group_name) + }) .when_some(self.on_toggle, move |this, on_toggle| { this.on_click(move |event, window, cx| on_toggle(event, window, cx)) }) diff --git a/crates/ui/src/components/label.rs b/crates/ui/src/components/label.rs index 8c9ea62424..dc830559ca 100644 --- a/crates/ui/src/components/label.rs +++ b/crates/ui/src/components/label.rs @@ -2,8 +2,10 @@ mod highlighted_label; mod label; mod label_like; mod loading_label; +mod spinner_label; pub use highlighted_label::*; pub use label::*; pub use label_like::*; pub use loading_label::*; +pub use spinner_label::*; diff --git a/crates/ui/src/components/label/spinner_label.rs b/crates/ui/src/components/label/spinner_label.rs new file mode 100644 index 0000000000..b7b65fbcc9 --- /dev/null +++ b/crates/ui/src/components/label/spinner_label.rs @@ -0,0 +1,192 @@ +use crate::prelude::*; +use gpui::{Animation, AnimationExt, FontWeight}; +use std::time::Duration; + +/// Different types of spinner animations +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub enum SpinnerVariant { + #[default] + Dots, + DotsVariant, +} + +/// A spinner indication, based on the label component, that loops through +/// frames of the specified animation. It implements `LabelCommon` as well. +/// +/// # Default Example +/// +/// ``` +/// use ui::{SpinnerLabel}; +/// +/// SpinnerLabel::new(); +/// ``` +/// +/// # Variant Example +/// +/// ``` +/// use ui::{SpinnerLabel}; +/// +/// SpinnerLabel::dots_variant(); +/// ``` +#[derive(IntoElement, RegisterComponent)] +pub struct SpinnerLabel { + base: Label, + variant: SpinnerVariant, + frames: Vec<&'static str>, + duration: Duration, +} + +impl SpinnerVariant { + fn frames(&self) -> Vec<&'static str> { + match self { + SpinnerVariant::Dots => vec!["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], + SpinnerVariant::DotsVariant => vec!["⣼", "⣹", "⢻", "⠿", "⡟", "⣏", "⣧", "⣶"], + } + } + + fn duration(&self) -> Duration { + match self { + SpinnerVariant::Dots => Duration::from_millis(1000), + SpinnerVariant::DotsVariant => Duration::from_millis(1000), + } + } + + fn animation_id(&self) -> &'static str { + match self { + SpinnerVariant::Dots => "spinner_label_dots", + SpinnerVariant::DotsVariant => "spinner_label_dots_variant", + } + } +} + +impl SpinnerLabel { + pub fn new() -> Self { + Self::with_variant(SpinnerVariant::default()) + } + + pub fn with_variant(variant: SpinnerVariant) -> Self { + let frames = variant.frames(); + let duration = variant.duration(); + + SpinnerLabel { + base: Label::new(frames[0]), + variant, + frames, + duration, + } + } + + pub fn dots() -> Self { + Self::with_variant(SpinnerVariant::Dots) + } + + pub fn dots_variant() -> Self { + Self::with_variant(SpinnerVariant::DotsVariant) + } +} + +impl LabelCommon for SpinnerLabel { + fn size(mut self, size: LabelSize) -> Self { + self.base = self.base.size(size); + self + } + + fn weight(mut self, weight: FontWeight) -> Self { + self.base = self.base.weight(weight); + self + } + + fn line_height_style(mut self, line_height_style: LineHeightStyle) -> Self { + self.base = self.base.line_height_style(line_height_style); + self + } + + fn color(mut self, color: Color) -> Self { + self.base = self.base.color(color); + self + } + + fn strikethrough(mut self) -> Self { + self.base = self.base.strikethrough(); + self + } + + fn italic(mut self) -> Self { + self.base = self.base.italic(); + self + } + + fn alpha(mut self, alpha: f32) -> Self { + self.base = self.base.alpha(alpha); + self + } + + fn underline(mut self) -> Self { + self.base = self.base.underline(); + self + } + + fn truncate(mut self) -> Self { + self.base = self.base.truncate(); + self + } + + fn single_line(mut self) -> Self { + self.base = self.base.single_line(); + self + } + + fn buffer_font(mut self, cx: &App) -> Self { + self.base = self.base.buffer_font(cx); + self + } + + fn inline_code(mut self, cx: &App) -> Self { + self.base = self.base.inline_code(cx); + self + } +} + +impl RenderOnce for SpinnerLabel { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let frames = self.frames.clone(); + let duration = self.duration; + + self.base.color(Color::Muted).with_animation( + self.variant.animation_id(), + Animation::new(duration).repeat(), + move |mut label, delta| { + let frame_index = (delta * frames.len() as f32) as usize % frames.len(); + + label.set_text(frames[frame_index]); + label + }, + ) + } +} + +impl Component for SpinnerLabel { + fn scope() -> ComponentScope { + ComponentScope::Loading + } + + fn name() -> &'static str { + "Spinner Label" + } + + fn sort_name() -> &'static str { + "Spinner Label" + } + + fn preview(_window: &mut Window, _cx: &mut App) -> Option { + let examples = vec![ + single_example("Default", SpinnerLabel::new().into_any_element()), + single_example( + "Dots Variant", + SpinnerLabel::dots_variant().into_any_element(), + ), + ]; + + Some(example_group(examples).vertical().into_any_element()) + } +} From 3d80be626756d7307077d91f31757361704d9f36 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 21 Aug 2025 22:24:13 +0200 Subject: [PATCH 36/89] acp: Allow editing of thread titles in agent2 (#36706) Release Notes: - N/A --------- Co-authored-by: Richard Feldman --- crates/acp_thread/src/acp_thread.rs | 36 ++++---- crates/acp_thread/src/connection.rs | 28 +++++-- crates/agent2/src/agent.rs | 71 ++++++++++++---- crates/agent2/src/tests/mod.rs | 1 + crates/agent2/src/thread.rs | 109 +++++++++++++------------ crates/agent_ui/src/acp/thread_view.rs | 83 +++++++++++++++++-- crates/agent_ui/src/agent_panel.rs | 31 ++++++- 7 files changed, 254 insertions(+), 105 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index a45787f039..c748f22275 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -1020,10 +1020,19 @@ impl AcpThread { cx.emit(AcpThreadEvent::NewEntry); } - pub fn update_title(&mut self, title: SharedString, cx: &mut Context) -> Result<()> { - self.title = title; - cx.emit(AcpThreadEvent::TitleUpdated); - Ok(()) + pub fn can_set_title(&mut self, cx: &mut Context) -> bool { + self.connection.set_title(&self.session_id, cx).is_some() + } + + pub fn set_title(&mut self, title: SharedString, cx: &mut Context) -> Task> { + if title != self.title { + self.title = title.clone(); + cx.emit(AcpThreadEvent::TitleUpdated); + if let Some(set_title) = self.connection.set_title(&self.session_id, cx) { + return set_title.run(title, cx); + } + } + Task::ready(Ok(())) } pub fn update_token_usage(&mut self, usage: Option, cx: &mut Context) { @@ -1326,11 +1335,7 @@ impl AcpThread { }; let git_store = self.project.read(cx).git_store().clone(); - let message_id = if self - .connection - .session_editor(&self.session_id, cx) - .is_some() - { + let message_id = if self.connection.truncate(&self.session_id, cx).is_some() { Some(UserMessageId::new()) } else { None @@ -1476,7 +1481,7 @@ impl AcpThread { /// Rewinds this thread to before the entry at `index`, removing it and all /// subsequent entries while reverting any changes made from that point. pub fn rewind(&mut self, id: UserMessageId, cx: &mut Context) -> Task> { - let Some(session_editor) = self.connection.session_editor(&self.session_id, cx) else { + let Some(truncate) = self.connection.truncate(&self.session_id, cx) else { return Task::ready(Err(anyhow!("not supported"))); }; let Some(message) = self.user_message(&id) else { @@ -1496,8 +1501,7 @@ impl AcpThread { .await?; } - cx.update(|cx| session_editor.truncate(id.clone(), cx))? - .await?; + cx.update(|cx| truncate.run(id.clone(), cx))?.await?; this.update(cx, |this, cx| { if let Some((ix, _)) = this.user_message_mut(&id) { let range = ix..this.entries.len(); @@ -2652,11 +2656,11 @@ mod tests { .detach(); } - fn session_editor( + fn truncate( &self, session_id: &acp::SessionId, _cx: &mut App, - ) -> Option> { + ) -> Option> { Some(Rc::new(FakeAgentSessionEditor { _session_id: session_id.clone(), })) @@ -2671,8 +2675,8 @@ mod tests { _session_id: acp::SessionId, } - impl AgentSessionEditor for FakeAgentSessionEditor { - fn truncate(&self, _message_id: UserMessageId, _cx: &mut App) -> Task> { + impl AgentSessionTruncate for FakeAgentSessionEditor { + fn run(&self, _message_id: UserMessageId, _cx: &mut App) -> Task> { Task::ready(Ok(())) } } diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 2bbd364873..91e46dbac1 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -50,11 +50,19 @@ pub trait AgentConnection { fn cancel(&self, session_id: &acp::SessionId, cx: &mut App); - fn session_editor( + fn truncate( &self, _session_id: &acp::SessionId, _cx: &mut App, - ) -> Option> { + ) -> Option> { + None + } + + fn set_title( + &self, + _session_id: &acp::SessionId, + _cx: &mut App, + ) -> Option> { None } @@ -79,14 +87,18 @@ impl dyn AgentConnection { } } -pub trait AgentSessionEditor { - fn truncate(&self, message_id: UserMessageId, cx: &mut App) -> Task>; +pub trait AgentSessionTruncate { + fn run(&self, message_id: UserMessageId, cx: &mut App) -> Task>; } pub trait AgentSessionResume { fn run(&self, cx: &mut App) -> Task>; } +pub trait AgentSessionSetTitle { + fn run(&self, title: SharedString, cx: &mut App) -> Task>; +} + pub trait AgentTelemetry { /// The name of the agent used for telemetry. fn agent_name(&self) -> String; @@ -424,11 +436,11 @@ mod test_support { } } - fn session_editor( + fn truncate( &self, _session_id: &agent_client_protocol::SessionId, _cx: &mut App, - ) -> Option> { + ) -> Option> { Some(Rc::new(StubAgentSessionEditor)) } @@ -439,8 +451,8 @@ mod test_support { struct StubAgentSessionEditor; - impl AgentSessionEditor for StubAgentSessionEditor { - fn truncate(&self, _: UserMessageId, _: &mut App) -> Task> { + impl AgentSessionTruncate for StubAgentSessionEditor { + fn run(&self, _: UserMessageId, _: &mut App) -> Task> { Task::ready(Ok(())) } } diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index d5bc0fea63..bbc30b74bc 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -2,7 +2,7 @@ use crate::{ ContextServerRegistry, Thread, ThreadEvent, ThreadsDatabase, ToolCallAuthorization, UserMessageContent, templates::Templates, }; -use crate::{HistoryStore, TokenUsageUpdated}; +use crate::{HistoryStore, TitleUpdated, TokenUsageUpdated}; use acp_thread::{AcpThread, AgentModelSelector}; use action_log::ActionLog; use agent_client_protocol as acp; @@ -253,6 +253,7 @@ impl NativeAgent { cx.observe_release(&acp_thread, |this, acp_thread, _cx| { this.sessions.remove(acp_thread.session_id()); }), + cx.subscribe(&thread_handle, Self::handle_thread_title_updated), cx.subscribe(&thread_handle, Self::handle_thread_token_usage_updated), cx.observe(&thread_handle, move |this, thread, cx| { this.save_thread(thread, cx) @@ -441,6 +442,26 @@ impl NativeAgent { }) } + fn handle_thread_title_updated( + &mut self, + thread: Entity, + _: &TitleUpdated, + cx: &mut Context, + ) { + let session_id = thread.read(cx).id(); + let Some(session) = self.sessions.get(session_id) else { + return; + }; + let thread = thread.downgrade(); + let acp_thread = session.acp_thread.clone(); + cx.spawn(async move |_, cx| { + let title = thread.read_with(cx, |thread, _| thread.title())?; + let task = acp_thread.update(cx, |acp_thread, cx| acp_thread.set_title(title, cx))?; + task.await + }) + .detach_and_log_err(cx); + } + fn handle_thread_token_usage_updated( &mut self, thread: Entity, @@ -717,10 +738,6 @@ impl NativeAgentConnection { thread.update_tool_call(update, cx) })??; } - ThreadEvent::TitleUpdate(title) => { - acp_thread - .update(cx, |thread, cx| thread.update_title(title, cx))??; - } ThreadEvent::Retry(status) => { acp_thread.update(cx, |thread, cx| { thread.update_retry_status(status, cx) @@ -856,8 +873,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection { .models .model_from_id(&LanguageModels::model_id(&default_model.model)) }); - - let thread = cx.new(|cx| { + Ok(cx.new(|cx| { Thread::new( project.clone(), agent.project_context.clone(), @@ -867,9 +883,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection { default_model, cx, ) - }); - - Ok(thread) + })) }, )??; agent.update(cx, |agent, cx| agent.register_session(thread, cx)) @@ -941,11 +955,11 @@ impl acp_thread::AgentConnection for NativeAgentConnection { }); } - fn session_editor( + fn truncate( &self, session_id: &agent_client_protocol::SessionId, cx: &mut App, - ) -> Option> { + ) -> Option> { self.0.update(cx, |agent, _cx| { agent.sessions.get(session_id).map(|session| { Rc::new(NativeAgentSessionEditor { @@ -956,6 +970,17 @@ impl acp_thread::AgentConnection for NativeAgentConnection { }) } + fn set_title( + &self, + session_id: &acp::SessionId, + _cx: &mut App, + ) -> Option> { + Some(Rc::new(NativeAgentSessionSetTitle { + connection: self.clone(), + session_id: session_id.clone(), + }) as _) + } + fn telemetry(&self) -> Option> { Some(Rc::new(self.clone()) as Rc) } @@ -991,8 +1016,8 @@ struct NativeAgentSessionEditor { acp_thread: WeakEntity, } -impl acp_thread::AgentSessionEditor for NativeAgentSessionEditor { - fn truncate(&self, message_id: acp_thread::UserMessageId, cx: &mut App) -> Task> { +impl acp_thread::AgentSessionTruncate for NativeAgentSessionEditor { + fn run(&self, message_id: acp_thread::UserMessageId, cx: &mut App) -> Task> { match self.thread.update(cx, |thread, cx| { thread.truncate(message_id.clone(), cx)?; Ok(thread.latest_token_usage()) @@ -1024,6 +1049,22 @@ impl acp_thread::AgentSessionResume for NativeAgentSessionResume { } } +struct NativeAgentSessionSetTitle { + connection: NativeAgentConnection, + session_id: acp::SessionId, +} + +impl acp_thread::AgentSessionSetTitle for NativeAgentSessionSetTitle { + fn run(&self, title: SharedString, cx: &mut App) -> Task> { + let Some(session) = self.connection.0.read(cx).sessions.get(&self.session_id) else { + return Task::ready(Err(anyhow!("session not found"))); + }; + let thread = session.thread.clone(); + thread.update(cx, |thread, cx| thread.set_title(title, cx)); + Task::ready(Ok(())) + } +} + #[cfg(test)] mod tests { use crate::HistoryEntryId; @@ -1323,6 +1364,8 @@ mod tests { ) }); + cx.run_until_parked(); + // Drop the ACP thread, which should cause the session to be dropped as well. cx.update(|_| { drop(thread); diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index edba227da7..e7e28f495e 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -1383,6 +1383,7 @@ async fn test_title_generation(cx: &mut TestAppContext) { summary_model.send_last_completion_stream_text_chunk("oodnight Moon"); summary_model.end_last_completion_stream(); send.collect::>().await; + cx.run_until_parked(); thread.read_with(cx, |thread, _| assert_eq!(thread.title(), "Hello world")); // Send another message, ensuring no title is generated this time. diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 6f560cd390..f6ef11c20b 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -487,7 +487,6 @@ pub enum ThreadEvent { ToolCall(acp::ToolCall), ToolCallUpdate(acp_thread::ToolCallUpdate), ToolCallAuthorization(ToolCallAuthorization), - TitleUpdate(SharedString), Retry(acp_thread::RetryStatus), Stop(acp::StopReason), } @@ -514,6 +513,7 @@ pub struct Thread { prompt_id: PromptId, updated_at: DateTime, title: Option, + pending_title_generation: Option>, summary: Option, messages: Vec, completion_mode: CompletionMode, @@ -555,6 +555,7 @@ impl Thread { prompt_id: PromptId::new(), updated_at: Utc::now(), title: None, + pending_title_generation: None, summary: None, messages: Vec::new(), completion_mode: AgentSettings::get_global(cx).preferred_completion_mode, @@ -705,6 +706,7 @@ impl Thread { } else { Some(db_thread.title.clone()) }, + pending_title_generation: None, summary: db_thread.detailed_summary, messages: db_thread.messages, completion_mode: db_thread.completion_mode.unwrap_or_default(), @@ -1086,7 +1088,7 @@ impl Thread { event_stream: event_stream.clone(), _task: cx.spawn(async move |this, cx| { log::info!("Starting agent turn execution"); - let mut update_title = None; + let turn_result: Result<()> = async { let mut intent = CompletionIntent::UserPrompt; loop { @@ -1095,8 +1097,8 @@ impl Thread { let mut end_turn = true; this.update(cx, |this, cx| { // Generate title if needed. - if this.title.is_none() && update_title.is_none() { - update_title = Some(this.update_title(&event_stream, cx)); + if this.title.is_none() && this.pending_title_generation.is_none() { + this.generate_title(cx); } // End the turn if the model didn't use tools. @@ -1120,10 +1122,6 @@ impl Thread { .await; _ = this.update(cx, |this, cx| this.flush_pending_message(cx)); - if let Some(update_title) = update_title { - update_title.await.context("update title failed").log_err(); - } - match turn_result { Ok(()) => { log::info!("Turn execution completed"); @@ -1607,19 +1605,15 @@ impl Thread { }) } - fn update_title( - &mut self, - event_stream: &ThreadEventStream, - cx: &mut Context, - ) -> Task> { + fn generate_title(&mut self, cx: &mut Context) { + let Some(model) = self.summarization_model.clone() else { + return; + }; + log::info!( "Generating title with model: {:?}", self.summarization_model.as_ref().map(|model| model.name()) ); - let Some(model) = self.summarization_model.clone() else { - return Task::ready(Ok(())); - }; - let event_stream = event_stream.clone(); let mut request = LanguageModelRequest { intent: Some(CompletionIntent::ThreadSummarization), temperature: AgentSettings::temperature_for_model(&model, cx), @@ -1635,42 +1629,51 @@ impl Thread { content: vec![SUMMARIZE_THREAD_PROMPT.into()], cache: false, }); - cx.spawn(async move |this, cx| { + self.pending_title_generation = Some(cx.spawn(async move |this, cx| { let mut title = String::new(); - let mut messages = model.stream_completion(request, cx).await?; - while let Some(event) = messages.next().await { - let event = event?; - let text = match event { - LanguageModelCompletionEvent::Text(text) => text, - LanguageModelCompletionEvent::StatusUpdate( - CompletionRequestStatus::UsageUpdated { amount, limit }, - ) => { - this.update(cx, |thread, cx| { - thread.update_model_request_usage(amount, limit, cx); - })?; - continue; + + let generate = async { + let mut messages = model.stream_completion(request, cx).await?; + while let Some(event) = messages.next().await { + let event = event?; + let text = match event { + LanguageModelCompletionEvent::Text(text) => text, + LanguageModelCompletionEvent::StatusUpdate( + CompletionRequestStatus::UsageUpdated { amount, limit }, + ) => { + this.update(cx, |thread, cx| { + thread.update_model_request_usage(amount, limit, cx); + })?; + continue; + } + _ => continue, + }; + + let mut lines = text.lines(); + title.extend(lines.next()); + + // Stop if the LLM generated multiple lines. + if lines.next().is_some() { + break; } - _ => continue, - }; - - let mut lines = text.lines(); - title.extend(lines.next()); - - // Stop if the LLM generated multiple lines. - if lines.next().is_some() { - break; } + anyhow::Ok(()) + }; + + if generate.await.context("failed to generate title").is_ok() { + _ = this.update(cx, |this, cx| this.set_title(title.into(), cx)); } + _ = this.update(cx, |this, _| this.pending_title_generation = None); + })); + } - log::info!("Setting title: {}", title); - - this.update(cx, |this, cx| { - let title = SharedString::from(title); - event_stream.send_title_update(title.clone()); - this.title = Some(title); - cx.notify(); - }) - }) + pub fn set_title(&mut self, title: SharedString, cx: &mut Context) { + self.pending_title_generation = None; + if Some(&title) != self.title.as_ref() { + self.title = Some(title); + cx.emit(TitleUpdated); + cx.notify(); + } } fn last_user_message(&self) -> Option<&UserMessage> { @@ -1975,6 +1978,10 @@ pub struct TokenUsageUpdated(pub Option); impl EventEmitter for Thread {} +pub struct TitleUpdated; + +impl EventEmitter for Thread {} + pub trait AgentTool where Self: 'static + Sized, @@ -2132,12 +2139,6 @@ where struct ThreadEventStream(mpsc::UnboundedSender>); impl ThreadEventStream { - fn send_title_update(&self, text: SharedString) { - self.0 - .unbounded_send(Ok(ThreadEvent::TitleUpdate(text))) - .ok(); - } - fn send_user_message(&self, message: &UserMessage) { self.0 .unbounded_send(Ok(ThreadEvent::UserMessage(message.clone()))) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 05d31051b2..936f987864 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -15,7 +15,7 @@ use buffer_diff::BufferDiff; use client::zed_urls; use collections::{HashMap, HashSet}; use editor::scroll::Autoscroll; -use editor::{Editor, EditorMode, MultiBuffer, PathKey, SelectionEffects}; +use editor::{Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects}; use file_icons::FileIcons; use fs::Fs; use gpui::{ @@ -281,7 +281,8 @@ enum ThreadState { }, Ready { thread: Entity, - _subscription: [Subscription; 2], + title_editor: Option>, + _subscriptions: Vec, }, LoadError(LoadError), Unauthenticated { @@ -445,12 +446,7 @@ impl AcpThreadView { this.update_in(cx, |this, window, cx| { match result { Ok(thread) => { - let thread_subscription = - cx.subscribe_in(&thread, window, Self::handle_thread_event); - let action_log = thread.read(cx).action_log().clone(); - let action_log_subscription = - cx.observe(&action_log, |_, _, cx| cx.notify()); let count = thread.read(cx).entries().len(); this.list_state.splice(0..0, count); @@ -489,9 +485,31 @@ impl AcpThreadView { }) }); + let mut subscriptions = vec![ + cx.subscribe_in(&thread, window, Self::handle_thread_event), + cx.observe(&action_log, |_, _, cx| cx.notify()), + ]; + + let title_editor = + if thread.update(cx, |thread, cx| thread.can_set_title(cx)) { + let editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_text(thread.read(cx).title(), window, cx); + editor + }); + subscriptions.push(cx.subscribe_in( + &editor, + window, + Self::handle_title_editor_event, + )); + Some(editor) + } else { + None + }; this.thread_state = ThreadState::Ready { thread, - _subscription: [thread_subscription, action_log_subscription], + title_editor, + _subscriptions: subscriptions, }; this.profile_selector = this.as_native_thread(cx).map(|thread| { @@ -618,6 +636,14 @@ impl AcpThreadView { } } + pub fn title_editor(&self) -> Option> { + if let ThreadState::Ready { title_editor, .. } = &self.thread_state { + title_editor.clone() + } else { + None + } + } + pub fn cancel_generation(&mut self, cx: &mut Context) { self.thread_error.take(); self.thread_retry_status.take(); @@ -662,6 +688,35 @@ impl AcpThreadView { cx.notify(); } + pub fn handle_title_editor_event( + &mut self, + title_editor: &Entity, + event: &EditorEvent, + window: &mut Window, + cx: &mut Context, + ) { + let Some(thread) = self.thread() else { return }; + + match event { + EditorEvent::BufferEdited => { + let new_title = title_editor.read(cx).text(cx); + thread.update(cx, |thread, cx| { + thread + .set_title(new_title.into(), cx) + .detach_and_log_err(cx); + }) + } + EditorEvent::Blurred => { + if title_editor.read(cx).text(cx).is_empty() { + title_editor.update(cx, |editor, cx| { + editor.set_text("New Thread", window, cx); + }); + } + } + _ => {} + } + } + pub fn handle_message_editor_event( &mut self, _: &Entity, @@ -1009,7 +1064,17 @@ impl AcpThreadView { self.thread_retry_status.take(); self.thread_state = ThreadState::LoadError(error.clone()); } - AcpThreadEvent::TitleUpdated | AcpThreadEvent::TokenUsageUpdated => {} + AcpThreadEvent::TitleUpdated => { + let title = thread.read(cx).title(); + if let Some(title_editor) = self.title_editor() { + title_editor.update(cx, |editor, cx| { + if editor.text(cx) != title { + editor.set_text(title, window, cx); + } + }); + } + } + AcpThreadEvent::TokenUsageUpdated => {} } cx.notify(); } diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 65a9da573a..d2ff6aa4f3 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -905,7 +905,7 @@ impl AgentPanel { fn active_thread_view(&self) -> Option<&Entity> { match &self.active_view { - ActiveView::ExternalAgentThread { thread_view } => Some(thread_view), + ActiveView::ExternalAgentThread { thread_view, .. } => Some(thread_view), ActiveView::Thread { .. } | ActiveView::TextThread { .. } | ActiveView::History @@ -2075,9 +2075,32 @@ impl AgentPanel { } } ActiveView::ExternalAgentThread { thread_view } => { - Label::new(thread_view.read(cx).title(cx)) - .truncate() - .into_any_element() + if let Some(title_editor) = thread_view.read(cx).title_editor() { + div() + .w_full() + .on_action({ + let thread_view = thread_view.downgrade(); + move |_: &menu::Confirm, window, cx| { + if let Some(thread_view) = thread_view.upgrade() { + thread_view.focus_handle(cx).focus(window); + } + } + }) + .on_action({ + let thread_view = thread_view.downgrade(); + move |_: &editor::actions::Cancel, window, cx| { + if let Some(thread_view) = thread_view.upgrade() { + thread_view.focus_handle(cx).focus(window); + } + } + }) + .child(title_editor) + .into_any_element() + } else { + Label::new(thread_view.read(cx).title(cx)) + .truncate() + .into_any_element() + } } ActiveView::TextThread { title_editor, From 14a50e2b2310a3ea18e6ec232206421433518c45 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 21 Aug 2025 17:29:53 -0300 Subject: [PATCH 37/89] acp: Fix `MessageEditor::set_message` for sent messages (#36715) The `PromptCapabilities` introduced in previous PRs were only getting set on the main message editor and not for the editors in user messages. This caused a bug where mentions would disappear after resending the message, and for the completion provider to be limited to files. Release Notes: - N/A --- crates/agent_ui/src/acp/entry_view_state.rs | 9 ++++-- crates/agent_ui/src/acp/message_editor.rs | 34 +++++++++++---------- crates/agent_ui/src/acp/thread_view.rs | 16 ++++++---- 3 files changed, 35 insertions(+), 24 deletions(-) diff --git a/crates/agent_ui/src/acp/entry_view_state.rs b/crates/agent_ui/src/acp/entry_view_state.rs index c310473259..0e4080d689 100644 --- a/crates/agent_ui/src/acp/entry_view_state.rs +++ b/crates/agent_ui/src/acp/entry_view_state.rs @@ -1,7 +1,7 @@ -use std::ops::Range; +use std::{cell::Cell, ops::Range, rc::Rc}; use acp_thread::{AcpThread, AgentThreadEntry}; -use agent_client_protocol::ToolCallId; +use agent_client_protocol::{PromptCapabilities, ToolCallId}; use agent2::HistoryStore; use collections::HashMap; use editor::{Editor, EditorMode, MinimapVisibility}; @@ -27,6 +27,7 @@ pub struct EntryViewState { prompt_store: Option>, entries: Vec, prevent_slash_commands: bool, + prompt_capabilities: Rc>, } impl EntryViewState { @@ -35,6 +36,7 @@ impl EntryViewState { project: Entity, history_store: Entity, prompt_store: Option>, + prompt_capabilities: Rc>, prevent_slash_commands: bool, ) -> Self { Self { @@ -44,6 +46,7 @@ impl EntryViewState { prompt_store, entries: Vec::new(), prevent_slash_commands, + prompt_capabilities, } } @@ -81,6 +84,7 @@ impl EntryViewState { self.project.clone(), self.history_store.clone(), self.prompt_store.clone(), + self.prompt_capabilities.clone(), "Edit message - @ to include context", self.prevent_slash_commands, editor::EditorMode::AutoHeight { @@ -403,6 +407,7 @@ mod tests { project.clone(), history_store, None, + Default::default(), false, ) }); diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index cd90ee99d9..b82f1968a7 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -87,6 +87,7 @@ impl MessageEditor { project: Entity, history_store: Entity, prompt_store: Option>, + prompt_capabilities: Rc>, placeholder: impl Into>, prevent_slash_commands: bool, mode: EditorMode, @@ -100,7 +101,6 @@ impl MessageEditor { }, None, ); - let prompt_capabilities = Rc::new(Cell::new(acp::PromptCapabilities::default())); let completion_provider = ContextPickerCompletionProvider::new( cx.weak_entity(), workspace.clone(), @@ -203,10 +203,6 @@ impl MessageEditor { .detach(); } - pub fn set_prompt_capabilities(&mut self, capabilities: acp::PromptCapabilities) { - self.prompt_capabilities.set(capabilities); - } - #[cfg(test)] pub(crate) fn editor(&self) -> &Entity { &self.editor @@ -1095,15 +1091,21 @@ impl MessageEditor { mentions.push((start..end, mention_uri, resource.text)); } } + acp::ContentBlock::ResourceLink(resource) => { + if let Some(mention_uri) = MentionUri::parse(&resource.uri).log_err() { + let start = text.len(); + write!(&mut text, "{}", mention_uri.as_link()).ok(); + let end = text.len(); + mentions.push((start..end, mention_uri, resource.uri)); + } + } acp::ContentBlock::Image(content) => { let start = text.len(); text.push_str("image"); let end = text.len(); images.push((start..end, content)); } - acp::ContentBlock::Audio(_) - | acp::ContentBlock::Resource(_) - | acp::ContentBlock::ResourceLink(_) => {} + acp::ContentBlock::Audio(_) | acp::ContentBlock::Resource(_) => {} } } @@ -1850,7 +1852,7 @@ impl Addon for MessageEditorAddon { #[cfg(test)] mod tests { - use std::{ops::Range, path::Path, sync::Arc}; + use std::{cell::Cell, ops::Range, path::Path, rc::Rc, sync::Arc}; use acp_thread::MentionUri; use agent_client_protocol as acp; @@ -1896,6 +1898,7 @@ mod tests { project.clone(), history_store.clone(), None, + Default::default(), "Test", false, EditorMode::AutoHeight { @@ -2086,6 +2089,7 @@ mod tests { let context_store = cx.new(|cx| ContextStore::fake(project.clone(), cx)); let history_store = cx.new(|cx| HistoryStore::new(context_store, cx)); + let prompt_capabilities = Rc::new(Cell::new(acp::PromptCapabilities::default())); let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { let workspace_handle = cx.weak_entity(); @@ -2095,6 +2099,7 @@ mod tests { project.clone(), history_store.clone(), None, + prompt_capabilities.clone(), "Test", false, EditorMode::AutoHeight { @@ -2139,13 +2144,10 @@ mod tests { editor.set_text("", window, cx); }); - message_editor.update(&mut cx, |editor, _cx| { - // Enable all prompt capabilities - editor.set_prompt_capabilities(acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - }); + prompt_capabilities.set(acp::PromptCapabilities { + image: true, + audio: true, + embedded_context: true, }); cx.simulate_input("Lorem "); diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 936f987864..c7d6bb439f 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -5,7 +5,7 @@ use acp_thread::{ }; use acp_thread::{AgentConnection, Plan}; use action_log::ActionLog; -use agent_client_protocol::{self as acp}; +use agent_client_protocol::{self as acp, PromptCapabilities}; use agent_servers::{AgentServer, ClaudeCode}; use agent_settings::{AgentProfileId, AgentSettings, CompletionMode, NotifyWhenAgentWaiting}; use agent2::{DbThreadMetadata, HistoryEntry, HistoryEntryId, HistoryStore}; @@ -34,6 +34,7 @@ use project::{Project, ProjectEntryId}; use prompt_store::{PromptId, PromptStore}; use rope::Point; use settings::{Settings as _, SettingsStore}; +use std::cell::Cell; use std::sync::Arc; use std::time::Instant; use std::{collections::BTreeMap, rc::Rc, time::Duration}; @@ -271,6 +272,7 @@ pub struct AcpThreadView { plan_expanded: bool, editor_expanded: bool, editing_message: Option, + prompt_capabilities: Rc>, _cancel_task: Option>, _subscriptions: [Subscription; 3], } @@ -306,6 +308,7 @@ impl AcpThreadView { window: &mut Window, cx: &mut Context, ) -> Self { + let prompt_capabilities = Rc::new(Cell::new(acp::PromptCapabilities::default())); let prevent_slash_commands = agent.clone().downcast::().is_some(); let message_editor = cx.new(|cx| { let mut editor = MessageEditor::new( @@ -313,6 +316,7 @@ impl AcpThreadView { project.clone(), history_store.clone(), prompt_store.clone(), + prompt_capabilities.clone(), "Message the agent — @ to include context", prevent_slash_commands, editor::EditorMode::AutoHeight { @@ -336,6 +340,7 @@ impl AcpThreadView { project.clone(), history_store.clone(), prompt_store.clone(), + prompt_capabilities.clone(), prevent_slash_commands, ) }); @@ -371,6 +376,7 @@ impl AcpThreadView { editor_expanded: false, history_store, hovered_recent_history_item: None, + prompt_capabilities, _subscriptions: subscriptions, _cancel_task: None, } @@ -448,6 +454,9 @@ impl AcpThreadView { Ok(thread) => { let action_log = thread.read(cx).action_log().clone(); + this.prompt_capabilities + .set(connection.prompt_capabilities()); + let count = thread.read(cx).entries().len(); this.list_state.splice(0..0, count); this.entry_view_state.update(cx, |view_state, cx| { @@ -523,11 +532,6 @@ impl AcpThreadView { }) }); - this.message_editor.update(cx, |message_editor, _cx| { - message_editor - .set_prompt_capabilities(connection.prompt_capabilities()); - }); - cx.notify(); } Err(err) => { From 52d14c4473ff849d8895e90d96fb13df699a3c97 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 21 Aug 2025 20:51:36 -0300 Subject: [PATCH 38/89] thread view: Add small refinements to tool call UI (#36723) Release Notes: - N/A --- crates/agent_ui/src/acp/thread_view.rs | 66 ++++++++++++++------------ crates/markdown/src/markdown.rs | 3 +- 2 files changed, 38 insertions(+), 31 deletions(-) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index c7d6bb439f..4d89a55139 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -1372,7 +1372,7 @@ impl AcpThreadView { AgentThreadEntry::ToolCall(tool_call) => { let has_terminals = tool_call.terminals().next().is_some(); - div().w_full().py_1p5().px_5().map(|this| { + div().w_full().py_1().px_5().map(|this| { if has_terminals { this.children(tool_call.terminals().map(|terminal| { self.render_terminal_tool_call( @@ -1570,7 +1570,7 @@ impl AcpThreadView { .size(IconSize::Small) .color(Color::Muted); - let base_container = h_flex().size_4().justify_center(); + let base_container = h_flex().flex_shrink_0().size_4().justify_center(); if is_collapsible { base_container @@ -1623,20 +1623,32 @@ impl AcpThreadView { | ToolCallStatus::WaitingForConfirmation { .. } | ToolCallStatus::Completed => None, ToolCallStatus::InProgress => Some( - Icon::new(IconName::ArrowCircle) - .color(Color::Muted) - .size(IconSize::Small) - .with_animation( - "running", - Animation::new(Duration::from_secs(3)).repeat(), - |icon, delta| icon.transform(Transformation::rotate(percentage(delta))), + div() + .absolute() + .right_2() + .child( + Icon::new(IconName::ArrowCircle) + .color(Color::Muted) + .size(IconSize::Small) + .with_animation( + "running", + Animation::new(Duration::from_secs(3)).repeat(), + |icon, delta| { + icon.transform(Transformation::rotate(percentage(delta))) + }, + ), ) .into_any(), ), ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed => Some( - Icon::new(IconName::Close) - .color(Color::Error) - .size(IconSize::Small) + div() + .absolute() + .right_2() + .child( + Icon::new(IconName::Close) + .color(Color::Error) + .size(IconSize::Small), + ) .into_any_element(), ), }; @@ -1734,13 +1746,14 @@ impl AcpThreadView { .child( h_flex() .id(header_id) + .relative() .w_full() + .max_w_full() .gap_1() - .justify_between() .when(use_card_layout, |this| { - this.pl_2() - .pr_1p5() - .py_1() + this.pl_1p5() + .pr_1() + .py_0p5() .rounded_t_md() .when(is_open && !failed_tool_call, |this| { this.border_b_1() @@ -1753,7 +1766,7 @@ impl AcpThreadView { .group(&card_header_id) .relative() .w_full() - .min_h_6() + .h(window.line_height() - px(2.)) .text_size(self.tool_name_font_size()) .child(self.render_tool_call_icon( card_header_id, @@ -1797,21 +1810,14 @@ impl AcpThreadView { } else { h_flex() .id("non-card-label-container") - .w_full() .relative() + .w_full() + .max_w_full() .ml_1p5() - .overflow_hidden() - .child( - h_flex() - .id("non-card-label") - .pr_8() - .w_full() - .overflow_x_scroll() - .child(self.render_markdown( - tool_call.label.clone(), - default_markdown_style(false, true, window, cx), - )), - ) + .child(h_flex().pr_8().child(self.render_markdown( + tool_call.label.clone(), + default_markdown_style(false, true, window, cx), + ))) .child(gradient_overlay(gradient_color)) .on_click(cx.listener({ let id = tool_call.id.clone(); diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 755506bd12..39a438c512 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -1089,7 +1089,7 @@ impl Element for MarkdownElement { .absolute() .top_1() .right_1() - .justify_center() + .justify_end() .child(codeblock), ) }); @@ -1320,6 +1320,7 @@ fn render_copy_code_block_button( ) .icon_color(Color::Muted) .icon_size(IconSize::Small) + .style(ButtonStyle::Filled) .shape(ui::IconButtonShape::Square) .tooltip(Tooltip::text("Copy Code")) .on_click({ From e5588fc9ea4e39a3933e2c04c6b5e9fdae4260b2 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 21 Aug 2025 17:37:41 -0700 Subject: [PATCH 39/89] acp: Tool name prep (#36726) Prep work for deduping tool names Release Notes: - N/A --- crates/agent2/src/agent.rs | 2 - crates/agent2/src/tests/mod.rs | 55 +++++++++---------- crates/agent2/src/tests/test_tools.rs | 30 +++++----- crates/agent2/src/thread.rs | 16 +++--- crates/agent2/src/tools.rs | 25 +++++++++ crates/agent2/src/tools/copy_path_tool.rs | 8 +-- .../agent2/src/tools/create_directory_tool.rs | 6 +- crates/agent2/src/tools/delete_path_tool.rs | 6 +- crates/agent2/src/tools/diagnostics_tool.rs | 6 +- crates/agent2/src/tools/edit_file_tool.rs | 29 ++-------- crates/agent2/src/tools/fetch_tool.rs | 6 +- crates/agent2/src/tools/find_path_tool.rs | 6 +- crates/agent2/src/tools/grep_tool.rs | 6 +- .../agent2/src/tools/list_directory_tool.rs | 6 +- crates/agent2/src/tools/move_path_tool.rs | 6 +- crates/agent2/src/tools/now_tool.rs | 6 +- crates/agent2/src/tools/open_tool.rs | 6 +- crates/agent2/src/tools/read_file_tool.rs | 6 +- crates/agent2/src/tools/terminal_tool.rs | 6 +- crates/agent2/src/tools/thinking_tool.rs | 6 +- crates/agent2/src/tools/web_search_tool.rs | 6 +- 21 files changed, 126 insertions(+), 123 deletions(-) diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index bbc30b74bc..215f8f454b 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -857,7 +857,6 @@ impl acp_thread::AgentConnection for NativeAgentConnection { cx.spawn(async move |cx| { log::debug!("Starting thread creation in async context"); - let action_log = cx.new(|_cx| ActionLog::new(project.clone()))?; // Create Thread let thread = agent.update( cx, @@ -878,7 +877,6 @@ impl acp_thread::AgentConnection for NativeAgentConnection { project.clone(), agent.project_context.clone(), agent.context_server_registry.clone(), - action_log.clone(), agent.templates.clone(), default_model, cx, diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index e7e28f495e..ac7b40c64f 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -1,6 +1,5 @@ use super::*; use acp_thread::{AgentConnection, AgentModelGroupName, AgentModelList, UserMessageId}; -use action_log::ActionLog; use agent_client_protocol::{self as acp}; use agent_settings::AgentProfileId; use anyhow::Result; @@ -224,7 +223,7 @@ async fn test_prompt_caching(cx: &mut TestAppContext) { let tool_use = LanguageModelToolUse { id: "tool_1".into(), - name: EchoTool.name().into(), + name: EchoTool::name().into(), raw_input: json!({"text": "test"}).to_string(), input: json!({"text": "test"}), is_input_complete: true, @@ -237,7 +236,7 @@ async fn test_prompt_caching(cx: &mut TestAppContext) { let completion = fake_model.pending_completions().pop().unwrap(); let tool_result = LanguageModelToolResult { tool_use_id: "tool_1".into(), - tool_name: EchoTool.name().into(), + tool_name: EchoTool::name().into(), is_error: false, content: "test".into(), output: Some("test".into()), @@ -307,7 +306,7 @@ async fn test_basic_tool_calls(cx: &mut TestAppContext) { // Test a tool calls that's likely to complete *after* streaming stops. let events = thread .update(cx, |thread, cx| { - thread.remove_tool(&AgentTool::name(&EchoTool)); + thread.remove_tool(&EchoTool::name()); thread.add_tool(DelayTool); thread.send( UserMessageId::new(), @@ -411,7 +410,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { id: "tool_id_1".into(), - name: ToolRequiringPermission.name().into(), + name: ToolRequiringPermission::name().into(), raw_input: "{}".into(), input: json!({}), is_input_complete: true, @@ -420,7 +419,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { id: "tool_id_2".into(), - name: ToolRequiringPermission.name().into(), + name: ToolRequiringPermission::name().into(), raw_input: "{}".into(), input: json!({}), is_input_complete: true, @@ -451,14 +450,14 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { vec![ language_model::MessageContent::ToolResult(LanguageModelToolResult { tool_use_id: tool_call_auth_1.tool_call.id.0.to_string().into(), - tool_name: ToolRequiringPermission.name().into(), + tool_name: ToolRequiringPermission::name().into(), is_error: false, content: "Allowed".into(), output: Some("Allowed".into()) }), language_model::MessageContent::ToolResult(LanguageModelToolResult { tool_use_id: tool_call_auth_2.tool_call.id.0.to_string().into(), - tool_name: ToolRequiringPermission.name().into(), + tool_name: ToolRequiringPermission::name().into(), is_error: true, content: "Permission to run tool denied by user".into(), output: None @@ -470,7 +469,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { id: "tool_id_3".into(), - name: ToolRequiringPermission.name().into(), + name: ToolRequiringPermission::name().into(), raw_input: "{}".into(), input: json!({}), is_input_complete: true, @@ -492,7 +491,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { vec![language_model::MessageContent::ToolResult( LanguageModelToolResult { tool_use_id: tool_call_auth_3.tool_call.id.0.to_string().into(), - tool_name: ToolRequiringPermission.name().into(), + tool_name: ToolRequiringPermission::name().into(), is_error: false, content: "Allowed".into(), output: Some("Allowed".into()) @@ -504,7 +503,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { id: "tool_id_4".into(), - name: ToolRequiringPermission.name().into(), + name: ToolRequiringPermission::name().into(), raw_input: "{}".into(), input: json!({}), is_input_complete: true, @@ -519,7 +518,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { vec![language_model::MessageContent::ToolResult( LanguageModelToolResult { tool_use_id: "tool_id_4".into(), - tool_name: ToolRequiringPermission.name().into(), + tool_name: ToolRequiringPermission::name().into(), is_error: false, content: "Allowed".into(), output: Some("Allowed".into()) @@ -571,7 +570,7 @@ async fn test_resume_after_tool_use_limit(cx: &mut TestAppContext) { cx.run_until_parked(); let tool_use = LanguageModelToolUse { id: "tool_id_1".into(), - name: EchoTool.name().into(), + name: EchoTool::name().into(), raw_input: "{}".into(), input: serde_json::to_value(&EchoToolInput { text: "def".into() }).unwrap(), is_input_complete: true, @@ -584,7 +583,7 @@ async fn test_resume_after_tool_use_limit(cx: &mut TestAppContext) { let completion = fake_model.pending_completions().pop().unwrap(); let tool_result = LanguageModelToolResult { tool_use_id: "tool_id_1".into(), - tool_name: EchoTool.name().into(), + tool_name: EchoTool::name().into(), is_error: false, content: "def".into(), output: Some("def".into()), @@ -690,14 +689,14 @@ async fn test_send_after_tool_use_limit(cx: &mut TestAppContext) { let tool_use = LanguageModelToolUse { id: "tool_id_1".into(), - name: EchoTool.name().into(), + name: EchoTool::name().into(), raw_input: "{}".into(), input: serde_json::to_value(&EchoToolInput { text: "def".into() }).unwrap(), is_input_complete: true, }; let tool_result = LanguageModelToolResult { tool_use_id: "tool_id_1".into(), - tool_name: EchoTool.name().into(), + tool_name: EchoTool::name().into(), is_error: false, content: "def".into(), output: Some("def".into()), @@ -874,14 +873,14 @@ async fn test_profiles(cx: &mut TestAppContext) { "test-1": { "name": "Test Profile 1", "tools": { - EchoTool.name(): true, - DelayTool.name(): true, + EchoTool::name(): true, + DelayTool::name(): true, } }, "test-2": { "name": "Test Profile 2", "tools": { - InfiniteTool.name(): true, + InfiniteTool::name(): true, } } } @@ -910,7 +909,7 @@ async fn test_profiles(cx: &mut TestAppContext) { .iter() .map(|tool| tool.name.clone()) .collect(); - assert_eq!(tool_names, vec![DelayTool.name(), EchoTool.name()]); + assert_eq!(tool_names, vec![DelayTool::name(), EchoTool::name()]); fake_model.end_last_completion_stream(); // Switch to test-2 profile, and verify that it has only the infinite tool. @@ -929,7 +928,7 @@ async fn test_profiles(cx: &mut TestAppContext) { .iter() .map(|tool| tool.name.clone()) .collect(); - assert_eq!(tool_names, vec![InfiniteTool.name()]); + assert_eq!(tool_names, vec![InfiniteTool::name()]); } #[gpui::test] @@ -1552,7 +1551,7 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { id: "1".into(), - name: ThinkingTool.name().into(), + name: ThinkingTool::name().into(), raw_input: input.to_string(), input, is_input_complete: false, @@ -1840,11 +1839,11 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest { "test-profile": { "name": "Test Profile", "tools": { - EchoTool.name(): true, - DelayTool.name(): true, - WordListTool.name(): true, - ToolRequiringPermission.name(): true, - InfiniteTool.name(): true, + EchoTool::name(): true, + DelayTool::name(): true, + WordListTool::name(): true, + ToolRequiringPermission::name(): true, + InfiniteTool::name(): true, } } } @@ -1903,13 +1902,11 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest { let project_context = cx.new(|_cx| ProjectContext::default()); let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let action_log = cx.new(|_| ActionLog::new(project.clone())); let thread = cx.new(|cx| { Thread::new( project, project_context.clone(), context_server_registry, - action_log, templates, Some(model.clone()), cx, diff --git a/crates/agent2/src/tests/test_tools.rs b/crates/agent2/src/tests/test_tools.rs index cbff44cedf..27be7b6ac3 100644 --- a/crates/agent2/src/tests/test_tools.rs +++ b/crates/agent2/src/tests/test_tools.rs @@ -16,11 +16,11 @@ impl AgentTool for EchoTool { type Input = EchoToolInput; type Output = String; - fn name(&self) -> SharedString { - "echo".into() + fn name() -> &'static str { + "echo" } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Other } @@ -51,8 +51,8 @@ impl AgentTool for DelayTool { type Input = DelayToolInput; type Output = String; - fn name(&self) -> SharedString { - "delay".into() + fn name() -> &'static str { + "delay" } fn initial_title(&self, input: Result) -> SharedString { @@ -63,7 +63,7 @@ impl AgentTool for DelayTool { } } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Other } @@ -92,11 +92,11 @@ impl AgentTool for ToolRequiringPermission { type Input = ToolRequiringPermissionInput; type Output = String; - fn name(&self) -> SharedString { - "tool_requiring_permission".into() + fn name() -> &'static str { + "tool_requiring_permission" } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Other } @@ -127,11 +127,11 @@ impl AgentTool for InfiniteTool { type Input = InfiniteToolInput; type Output = String; - fn name(&self) -> SharedString { - "infinite".into() + fn name() -> &'static str { + "infinite" } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Other } @@ -178,11 +178,11 @@ impl AgentTool for WordListTool { type Input = WordListInput; type Output = String; - fn name(&self) -> SharedString { - "word_list".into() + fn name() -> &'static str { + "word_list" } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Other } diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index f6ef11c20b..af18afa055 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -544,12 +544,12 @@ impl Thread { project: Entity, project_context: Entity, context_server_registry: Entity, - action_log: Entity, templates: Arc, model: Option>, cx: &mut Context, ) -> Self { let profile_id = AgentSettings::get_global(cx).default_profile.clone(); + let action_log = cx.new(|_cx| ActionLog::new(project.clone())); Self { id: acp::SessionId(uuid::Uuid::new_v4().to_string().into()), prompt_id: PromptId::new(), @@ -959,11 +959,11 @@ impl Thread { )); self.add_tool(TerminalTool::new(self.project.clone(), cx)); self.add_tool(ThinkingTool); - self.add_tool(WebSearchTool); // TODO: Enable this only if it's a zed model. + self.add_tool(WebSearchTool); } - pub fn add_tool(&mut self, tool: impl AgentTool) { - self.tools.insert(tool.name(), tool.erase()); + pub fn add_tool(&mut self, tool: T) { + self.tools.insert(T::name().into(), tool.erase()); } pub fn remove_tool(&mut self, name: &str) -> bool { @@ -1989,7 +1989,7 @@ where type Input: for<'de> Deserialize<'de> + Serialize + JsonSchema; type Output: for<'de> Deserialize<'de> + Serialize + Into; - fn name(&self) -> SharedString; + fn name() -> &'static str; fn description(&self) -> SharedString { let schema = schemars::schema_for!(Self::Input); @@ -2001,7 +2001,7 @@ where ) } - fn kind(&self) -> acp::ToolKind; + fn kind() -> acp::ToolKind; /// The initial tool title to display. Can be updated during the tool run. fn initial_title(&self, input: Result) -> SharedString; @@ -2077,7 +2077,7 @@ where T: AgentTool, { fn name(&self) -> SharedString { - self.0.name() + T::name().into() } fn description(&self) -> SharedString { @@ -2085,7 +2085,7 @@ where } fn kind(&self) -> agent_client_protocol::ToolKind { - self.0.kind() + T::kind() } fn initial_title(&self, input: serde_json::Value) -> SharedString { diff --git a/crates/agent2/src/tools.rs b/crates/agent2/src/tools.rs index d1f2b3b1c7..bcca7eecd1 100644 --- a/crates/agent2/src/tools.rs +++ b/crates/agent2/src/tools.rs @@ -16,6 +16,29 @@ mod terminal_tool; mod thinking_tool; mod web_search_tool; +/// A list of all built in tool names, for use in deduplicating MCP tool names +pub fn default_tool_names() -> impl Iterator { + [ + CopyPathTool::name(), + CreateDirectoryTool::name(), + DeletePathTool::name(), + DiagnosticsTool::name(), + EditFileTool::name(), + FetchTool::name(), + FindPathTool::name(), + GrepTool::name(), + ListDirectoryTool::name(), + MovePathTool::name(), + NowTool::name(), + OpenTool::name(), + ReadFileTool::name(), + TerminalTool::name(), + ThinkingTool::name(), + WebSearchTool::name(), + ] + .into_iter() +} + pub use context_server_registry::*; pub use copy_path_tool::*; pub use create_directory_tool::*; @@ -33,3 +56,5 @@ pub use read_file_tool::*; pub use terminal_tool::*; pub use thinking_tool::*; pub use web_search_tool::*; + +use crate::AgentTool; diff --git a/crates/agent2/src/tools/copy_path_tool.rs b/crates/agent2/src/tools/copy_path_tool.rs index 4b40a9842f..819a6ff209 100644 --- a/crates/agent2/src/tools/copy_path_tool.rs +++ b/crates/agent2/src/tools/copy_path_tool.rs @@ -1,7 +1,7 @@ use crate::{AgentTool, ToolCallEventStream}; use agent_client_protocol::ToolKind; use anyhow::{Context as _, Result, anyhow}; -use gpui::{App, AppContext, Entity, SharedString, Task}; +use gpui::{App, AppContext, Entity, Task}; use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -50,11 +50,11 @@ impl AgentTool for CopyPathTool { type Input = CopyPathToolInput; type Output = String; - fn name(&self) -> SharedString { - "copy_path".into() + fn name() -> &'static str { + "copy_path" } - fn kind(&self) -> ToolKind { + fn kind() -> ToolKind { ToolKind::Move } diff --git a/crates/agent2/src/tools/create_directory_tool.rs b/crates/agent2/src/tools/create_directory_tool.rs index 7720eb3595..652363d5fa 100644 --- a/crates/agent2/src/tools/create_directory_tool.rs +++ b/crates/agent2/src/tools/create_directory_tool.rs @@ -41,11 +41,11 @@ impl AgentTool for CreateDirectoryTool { type Input = CreateDirectoryToolInput; type Output = String; - fn name(&self) -> SharedString { - "create_directory".into() + fn name() -> &'static str { + "create_directory" } - fn kind(&self) -> ToolKind { + fn kind() -> ToolKind { ToolKind::Read } diff --git a/crates/agent2/src/tools/delete_path_tool.rs b/crates/agent2/src/tools/delete_path_tool.rs index c281f1b5b6..0f9641127f 100644 --- a/crates/agent2/src/tools/delete_path_tool.rs +++ b/crates/agent2/src/tools/delete_path_tool.rs @@ -44,11 +44,11 @@ impl AgentTool for DeletePathTool { type Input = DeletePathToolInput; type Output = String; - fn name(&self) -> SharedString { - "delete_path".into() + fn name() -> &'static str { + "delete_path" } - fn kind(&self) -> ToolKind { + fn kind() -> ToolKind { ToolKind::Delete } diff --git a/crates/agent2/src/tools/diagnostics_tool.rs b/crates/agent2/src/tools/diagnostics_tool.rs index 6ba8b7b377..558bb918ce 100644 --- a/crates/agent2/src/tools/diagnostics_tool.rs +++ b/crates/agent2/src/tools/diagnostics_tool.rs @@ -63,11 +63,11 @@ impl AgentTool for DiagnosticsTool { type Input = DiagnosticsToolInput; type Output = String; - fn name(&self) -> SharedString { - "diagnostics".into() + fn name() -> &'static str { + "diagnostics" } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Read } diff --git a/crates/agent2/src/tools/edit_file_tool.rs b/crates/agent2/src/tools/edit_file_tool.rs index f89cace9a8..5a68d0c70a 100644 --- a/crates/agent2/src/tools/edit_file_tool.rs +++ b/crates/agent2/src/tools/edit_file_tool.rs @@ -186,11 +186,11 @@ impl AgentTool for EditFileTool { type Input = EditFileToolInput; type Output = EditFileToolOutput; - fn name(&self) -> SharedString { - "edit_file".into() + fn name() -> &'static str { + "edit_file" } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Edit } @@ -517,7 +517,6 @@ fn resolve_path( mod tests { use super::*; use crate::{ContextServerRegistry, Templates}; - use action_log::ActionLog; use client::TelemetrySettings; use fs::Fs; use gpui::{TestAppContext, UpdateGlobal}; @@ -535,7 +534,6 @@ mod tests { fs.insert_tree("/root", json!({})).await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); - let action_log = cx.new(|_| ActionLog::new(project.clone())); let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); let model = Arc::new(FakeLanguageModel::default()); @@ -544,7 +542,6 @@ mod tests { project, cx.new(|_cx| ProjectContext::default()), context_server_registry, - action_log, Templates::new(), Some(model), cx, @@ -735,7 +732,6 @@ mod tests { } }); - let action_log = cx.new(|_| ActionLog::new(project.clone())); let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); let model = Arc::new(FakeLanguageModel::default()); @@ -744,7 +740,6 @@ mod tests { project, cx.new(|_cx| ProjectContext::default()), context_server_registry, - action_log.clone(), Templates::new(), Some(model.clone()), cx, @@ -801,7 +796,9 @@ mod tests { "Code should be formatted when format_on_save is enabled" ); - let stale_buffer_count = action_log.read_with(cx, |log, cx| log.stale_buffers(cx).count()); + let stale_buffer_count = thread + .read_with(cx, |thread, _cx| thread.action_log.clone()) + .read_with(cx, |log, cx| log.stale_buffers(cx).count()); assert_eq!( stale_buffer_count, 0, @@ -879,14 +876,12 @@ mod tests { let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); - let action_log = cx.new(|_| ActionLog::new(project.clone())); let model = Arc::new(FakeLanguageModel::default()); let thread = cx.new(|cx| { Thread::new( project, cx.new(|_cx| ProjectContext::default()), context_server_registry, - action_log.clone(), Templates::new(), Some(model.clone()), cx, @@ -1008,14 +1003,12 @@ mod tests { let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); - let action_log = cx.new(|_| ActionLog::new(project.clone())); let model = Arc::new(FakeLanguageModel::default()); let thread = cx.new(|cx| { Thread::new( project, cx.new(|_cx| ProjectContext::default()), context_server_registry, - action_log.clone(), Templates::new(), Some(model.clone()), cx, @@ -1146,14 +1139,12 @@ mod tests { let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let action_log = cx.new(|_| ActionLog::new(project.clone())); let model = Arc::new(FakeLanguageModel::default()); let thread = cx.new(|cx| { Thread::new( project, cx.new(|_cx| ProjectContext::default()), context_server_registry, - action_log.clone(), Templates::new(), Some(model.clone()), cx, @@ -1254,7 +1245,6 @@ mod tests { ) .await; let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); - let action_log = cx.new(|_| ActionLog::new(project.clone())); let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); let model = Arc::new(FakeLanguageModel::default()); @@ -1263,7 +1253,6 @@ mod tests { project.clone(), cx.new(|_cx| ProjectContext::default()), context_server_registry.clone(), - action_log.clone(), Templates::new(), Some(model.clone()), cx, @@ -1336,7 +1325,6 @@ mod tests { .await; let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); - let action_log = cx.new(|_| ActionLog::new(project.clone())); let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); let model = Arc::new(FakeLanguageModel::default()); @@ -1345,7 +1333,6 @@ mod tests { project.clone(), cx.new(|_cx| ProjectContext::default()), context_server_registry.clone(), - action_log.clone(), Templates::new(), Some(model.clone()), cx, @@ -1421,7 +1408,6 @@ mod tests { .await; let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); - let action_log = cx.new(|_| ActionLog::new(project.clone())); let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); let model = Arc::new(FakeLanguageModel::default()); @@ -1430,7 +1416,6 @@ mod tests { project.clone(), cx.new(|_cx| ProjectContext::default()), context_server_registry.clone(), - action_log.clone(), Templates::new(), Some(model.clone()), cx, @@ -1503,7 +1488,6 @@ mod tests { let fs = project::FakeFs::new(cx.executor()); let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); - let action_log = cx.new(|_| ActionLog::new(project.clone())); let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); let model = Arc::new(FakeLanguageModel::default()); @@ -1512,7 +1496,6 @@ mod tests { project.clone(), cx.new(|_cx| ProjectContext::default()), context_server_registry, - action_log.clone(), Templates::new(), Some(model.clone()), cx, diff --git a/crates/agent2/src/tools/fetch_tool.rs b/crates/agent2/src/tools/fetch_tool.rs index ae26c5fe19..0313c4e4c2 100644 --- a/crates/agent2/src/tools/fetch_tool.rs +++ b/crates/agent2/src/tools/fetch_tool.rs @@ -118,11 +118,11 @@ impl AgentTool for FetchTool { type Input = FetchToolInput; type Output = String; - fn name(&self) -> SharedString { - "fetch".into() + fn name() -> &'static str { + "fetch" } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Fetch } diff --git a/crates/agent2/src/tools/find_path_tool.rs b/crates/agent2/src/tools/find_path_tool.rs index 9e11ca6a37..5b35c40f85 100644 --- a/crates/agent2/src/tools/find_path_tool.rs +++ b/crates/agent2/src/tools/find_path_tool.rs @@ -85,11 +85,11 @@ impl AgentTool for FindPathTool { type Input = FindPathToolInput; type Output = FindPathToolOutput; - fn name(&self) -> SharedString { - "find_path".into() + fn name() -> &'static str { + "find_path" } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Search } diff --git a/crates/agent2/src/tools/grep_tool.rs b/crates/agent2/src/tools/grep_tool.rs index 955dae7235..b24e773903 100644 --- a/crates/agent2/src/tools/grep_tool.rs +++ b/crates/agent2/src/tools/grep_tool.rs @@ -67,11 +67,11 @@ impl AgentTool for GrepTool { type Input = GrepToolInput; type Output = String; - fn name(&self) -> SharedString { - "grep".into() + fn name() -> &'static str { + "grep" } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Search } diff --git a/crates/agent2/src/tools/list_directory_tool.rs b/crates/agent2/src/tools/list_directory_tool.rs index 31575a92e4..e6fa8d7431 100644 --- a/crates/agent2/src/tools/list_directory_tool.rs +++ b/crates/agent2/src/tools/list_directory_tool.rs @@ -51,11 +51,11 @@ impl AgentTool for ListDirectoryTool { type Input = ListDirectoryToolInput; type Output = String; - fn name(&self) -> SharedString { - "list_directory".into() + fn name() -> &'static str { + "list_directory" } - fn kind(&self) -> ToolKind { + fn kind() -> ToolKind { ToolKind::Read } diff --git a/crates/agent2/src/tools/move_path_tool.rs b/crates/agent2/src/tools/move_path_tool.rs index 2a173a4404..d9fb60651b 100644 --- a/crates/agent2/src/tools/move_path_tool.rs +++ b/crates/agent2/src/tools/move_path_tool.rs @@ -52,11 +52,11 @@ impl AgentTool for MovePathTool { type Input = MovePathToolInput; type Output = String; - fn name(&self) -> SharedString { - "move_path".into() + fn name() -> &'static str { + "move_path" } - fn kind(&self) -> ToolKind { + fn kind() -> ToolKind { ToolKind::Move } diff --git a/crates/agent2/src/tools/now_tool.rs b/crates/agent2/src/tools/now_tool.rs index a72ede26fe..9467e7db68 100644 --- a/crates/agent2/src/tools/now_tool.rs +++ b/crates/agent2/src/tools/now_tool.rs @@ -32,11 +32,11 @@ impl AgentTool for NowTool { type Input = NowToolInput; type Output = String; - fn name(&self) -> SharedString { - "now".into() + fn name() -> &'static str { + "now" } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Other } diff --git a/crates/agent2/src/tools/open_tool.rs b/crates/agent2/src/tools/open_tool.rs index c20369c2d8..df7b04c787 100644 --- a/crates/agent2/src/tools/open_tool.rs +++ b/crates/agent2/src/tools/open_tool.rs @@ -37,11 +37,11 @@ impl AgentTool for OpenTool { type Input = OpenToolInput; type Output = String; - fn name(&self) -> SharedString { - "open".into() + fn name() -> &'static str { + "open" } - fn kind(&self) -> ToolKind { + fn kind() -> ToolKind { ToolKind::Execute } diff --git a/crates/agent2/src/tools/read_file_tool.rs b/crates/agent2/src/tools/read_file_tool.rs index 11a57506fb..903e1582ac 100644 --- a/crates/agent2/src/tools/read_file_tool.rs +++ b/crates/agent2/src/tools/read_file_tool.rs @@ -59,11 +59,11 @@ impl AgentTool for ReadFileTool { type Input = ReadFileToolInput; type Output = LanguageModelToolResultContent; - fn name(&self) -> SharedString { - "read_file".into() + fn name() -> &'static str { + "read_file" } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Read } diff --git a/crates/agent2/src/tools/terminal_tool.rs b/crates/agent2/src/tools/terminal_tool.rs index 3d4faf2e03..f41b909d0b 100644 --- a/crates/agent2/src/tools/terminal_tool.rs +++ b/crates/agent2/src/tools/terminal_tool.rs @@ -63,11 +63,11 @@ impl AgentTool for TerminalTool { type Input = TerminalToolInput; type Output = String; - fn name(&self) -> SharedString { - "terminal".into() + fn name() -> &'static str { + "terminal" } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Execute } diff --git a/crates/agent2/src/tools/thinking_tool.rs b/crates/agent2/src/tools/thinking_tool.rs index c5e9451162..61fb9eb0d6 100644 --- a/crates/agent2/src/tools/thinking_tool.rs +++ b/crates/agent2/src/tools/thinking_tool.rs @@ -21,11 +21,11 @@ impl AgentTool for ThinkingTool { type Input = ThinkingToolInput; type Output = String; - fn name(&self) -> SharedString { - "thinking".into() + fn name() -> &'static str { + "thinking" } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Think } diff --git a/crates/agent2/src/tools/web_search_tool.rs b/crates/agent2/src/tools/web_search_tool.rs index ffcd4ad3be..d7a34bec29 100644 --- a/crates/agent2/src/tools/web_search_tool.rs +++ b/crates/agent2/src/tools/web_search_tool.rs @@ -40,11 +40,11 @@ impl AgentTool for WebSearchTool { type Input = WebSearchToolInput; type Output = WebSearchToolOutput; - fn name(&self) -> SharedString { - "web_search".into() + fn name() -> &'static str { + "web_search" } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Fetch } From 51d678d33bb7ec57034a404d934b12b65747a73d Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Thu, 21 Aug 2025 23:57:30 -0600 Subject: [PATCH 40/89] acp: Fix history search (#36734) Release Notes: - N/A --- crates/agent2/src/agent.rs | 5 +- crates/agent2/src/history_store.rs | 31 +- .../agent_ui/src/acp/completion_provider.rs | 2 +- crates/agent_ui/src/acp/thread_history.rs | 495 ++++++++---------- crates/agent_ui/src/acp/thread_view.rs | 6 +- 5 files changed, 228 insertions(+), 311 deletions(-) diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index 215f8f454b..e32e407b3f 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -1406,10 +1406,9 @@ mod tests { history: &Entity, cx: &mut TestAppContext, ) -> Vec<(HistoryEntryId, String)> { - history.read_with(cx, |history, cx| { + history.read_with(cx, |history, _| { history - .entries(cx) - .iter() + .entries() .map(|e| (e.id(), e.title().to_string())) .collect::>() }) diff --git a/crates/agent2/src/history_store.rs b/crates/agent2/src/history_store.rs index 78d83cc1d0..c656456e01 100644 --- a/crates/agent2/src/history_store.rs +++ b/crates/agent2/src/history_store.rs @@ -86,6 +86,7 @@ enum SerializedRecentOpen { pub struct HistoryStore { threads: Vec, + entries: Vec, context_store: Entity, recently_opened_entries: VecDeque, _subscriptions: Vec, @@ -97,7 +98,7 @@ impl HistoryStore { context_store: Entity, cx: &mut Context, ) -> Self { - let subscriptions = vec![cx.observe(&context_store, |_, _, cx| cx.notify())]; + let subscriptions = vec![cx.observe(&context_store, |this, _, cx| this.update_entries(cx))]; cx.spawn(async move |this, cx| { let entries = Self::load_recently_opened_entries(cx).await; @@ -116,6 +117,7 @@ impl HistoryStore { context_store, recently_opened_entries: VecDeque::default(), threads: Vec::default(), + entries: Vec::default(), _subscriptions: subscriptions, _save_recently_opened_entries_task: Task::ready(()), } @@ -181,20 +183,18 @@ impl HistoryStore { } } this.threads = threads; - cx.notify(); + this.update_entries(cx); }) }) .detach_and_log_err(cx); } - pub fn entries(&self, cx: &App) -> Vec { - let mut history_entries = Vec::new(); - + fn update_entries(&mut self, cx: &mut Context) { #[cfg(debug_assertions)] if std::env::var("ZED_SIMULATE_NO_THREAD_HISTORY").is_ok() { - return history_entries; + return; } - + let mut history_entries = Vec::new(); history_entries.extend(self.threads.iter().cloned().map(HistoryEntry::AcpThread)); history_entries.extend( self.context_store @@ -205,17 +205,12 @@ impl HistoryStore { ); history_entries.sort_unstable_by_key(|entry| std::cmp::Reverse(entry.updated_at())); - history_entries + self.entries = history_entries; + cx.notify() } - pub fn is_empty(&self, cx: &App) -> bool { - self.threads.is_empty() - && self - .context_store - .read(cx) - .unordered_contexts() - .next() - .is_none() + pub fn is_empty(&self, _cx: &App) -> bool { + self.entries.is_empty() } pub fn recently_opened_entries(&self, cx: &App) -> Vec { @@ -356,7 +351,7 @@ impl HistoryStore { self.save_recently_opened_entries(cx); } - pub fn recent_entries(&self, limit: usize, cx: &mut Context) -> Vec { - self.entries(cx).into_iter().take(limit).collect() + pub fn entries(&self) -> impl Iterator { + self.entries.iter().cloned() } } diff --git a/crates/agent_ui/src/acp/completion_provider.rs b/crates/agent_ui/src/acp/completion_provider.rs index 3587e5144e..22a9ea6773 100644 --- a/crates/agent_ui/src/acp/completion_provider.rs +++ b/crates/agent_ui/src/acp/completion_provider.rs @@ -805,7 +805,7 @@ pub(crate) fn search_threads( history_store: &Entity, cx: &mut App, ) -> Task> { - let threads = history_store.read(cx).entries(cx); + let threads = history_store.read(cx).entries().collect(); if query.is_empty() { return Task::ready(threads); } diff --git a/crates/agent_ui/src/acp/thread_history.rs b/crates/agent_ui/src/acp/thread_history.rs index d76969378c..5d852f0ddc 100644 --- a/crates/agent_ui/src/acp/thread_history.rs +++ b/crates/agent_ui/src/acp/thread_history.rs @@ -3,18 +3,18 @@ use crate::{AgentPanel, RemoveSelectedThread}; use agent2::{HistoryEntry, HistoryStore}; use chrono::{Datelike as _, Local, NaiveDate, TimeDelta}; use editor::{Editor, EditorEvent}; -use fuzzy::{StringMatch, StringMatchCandidate}; +use fuzzy::StringMatchCandidate; use gpui::{ - App, Empty, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Stateful, Task, + App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Stateful, Task, UniformListScrollHandle, WeakEntity, Window, uniform_list, }; -use std::{fmt::Display, ops::Range, sync::Arc}; +use std::{fmt::Display, ops::Range}; +use text::Bias; use time::{OffsetDateTime, UtcOffset}; use ui::{ HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Scrollbar, ScrollbarState, Tooltip, prelude::*, }; -use util::ResultExt; pub struct AcpThreadHistory { pub(crate) history_store: Entity, @@ -22,38 +22,38 @@ pub struct AcpThreadHistory { selected_index: usize, hovered_index: Option, search_editor: Entity, - all_entries: Arc>, - // When the search is empty, we display date separators between history entries - // This vector contains an enum of either a separator or an actual entry - separated_items: Vec, - // Maps entry indexes to list item indexes - separated_item_indexes: Vec, - _separated_items_task: Option>, - search_state: SearchState, + search_query: SharedString, + + visible_items: Vec, + scrollbar_visibility: bool, scrollbar_state: ScrollbarState, local_timezone: UtcOffset, - _subscriptions: Vec, -} -enum SearchState { - Empty, - Searching { - query: SharedString, - _task: Task<()>, - }, - Searched { - query: SharedString, - matches: Vec, - }, + _update_task: Task<()>, + _subscriptions: Vec, } enum ListItemType { BucketSeparator(TimeBucket), Entry { - index: usize, + entry: HistoryEntry, format: EntryTimeFormat, }, + SearchResult { + entry: HistoryEntry, + positions: Vec, + }, +} + +impl ListItemType { + fn history_entry(&self) -> Option<&HistoryEntry> { + match self { + ListItemType::Entry { entry, .. } => Some(entry), + ListItemType::SearchResult { entry, .. } => Some(entry), + _ => None, + } + } } pub enum ThreadHistoryEvent { @@ -78,12 +78,15 @@ impl AcpThreadHistory { cx.subscribe(&search_editor, |this, search_editor, event, cx| { if let EditorEvent::BufferEdited = event { let query = search_editor.read(cx).text(cx); - this.search(query.into(), cx); + if this.search_query != query { + this.search_query = query.into(); + this.update_visible_items(false, cx); + } } }); let history_store_subscription = cx.observe(&history_store, |this, _, cx| { - this.update_all_entries(cx); + this.update_visible_items(true, cx); }); let scroll_handle = UniformListScrollHandle::default(); @@ -94,10 +97,7 @@ impl AcpThreadHistory { scroll_handle, selected_index: 0, hovered_index: None, - search_state: SearchState::Empty, - all_entries: Default::default(), - separated_items: Default::default(), - separated_item_indexes: Default::default(), + visible_items: Default::default(), search_editor, scrollbar_visibility: true, scrollbar_state, @@ -105,29 +105,61 @@ impl AcpThreadHistory { chrono::Local::now().offset().local_minus_utc(), ) .unwrap(), + search_query: SharedString::default(), _subscriptions: vec![search_editor_subscription, history_store_subscription], - _separated_items_task: None, + _update_task: Task::ready(()), }; - this.update_all_entries(cx); + this.update_visible_items(false, cx); this } - fn update_all_entries(&mut self, cx: &mut Context) { - let new_entries: Arc> = self + fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) { + let entries = self .history_store - .update(cx, |store, cx| store.entries(cx)) - .into(); + .update(cx, |store, _| store.entries().collect()); + let new_list_items = if self.search_query.is_empty() { + self.add_list_separators(entries, cx) + } else { + self.filter_search_results(entries, cx) + }; + let selected_history_entry = if preserve_selected_item { + self.selected_history_entry().cloned() + } else { + None + }; - self._separated_items_task.take(); + self._update_task = cx.spawn(async move |this, cx| { + let new_visible_items = new_list_items.await; + this.update(cx, |this, cx| { + let new_selected_index = if let Some(history_entry) = selected_history_entry { + let history_entry_id = history_entry.id(); + new_visible_items + .iter() + .position(|visible_entry| { + visible_entry + .history_entry() + .is_some_and(|entry| entry.id() == history_entry_id) + }) + .unwrap_or(0) + } else { + 0 + }; - let mut items = Vec::with_capacity(new_entries.len() + 1); - let mut indexes = Vec::with_capacity(new_entries.len() + 1); + this.visible_items = new_visible_items; + this.set_selected_index(new_selected_index, Bias::Right, cx); + cx.notify(); + }) + .ok(); + }); + } - let bg_task = cx.background_spawn(async move { + fn add_list_separators(&self, entries: Vec, cx: &App) -> Task> { + cx.background_spawn(async move { + let mut items = Vec::with_capacity(entries.len() + 1); let mut bucket = None; let today = Local::now().naive_local().date(); - for (index, entry) in new_entries.iter().enumerate() { + for entry in entries.into_iter() { let entry_date = entry .updated_at() .with_timezone(&Local) @@ -140,75 +172,33 @@ impl AcpThreadHistory { items.push(ListItemType::BucketSeparator(entry_bucket)); } - indexes.push(items.len() as u32); items.push(ListItemType::Entry { - index, + entry, format: entry_bucket.into(), }); } - (new_entries, items, indexes) - }); - - let task = cx.spawn(async move |this, cx| { - let (new_entries, items, indexes) = bg_task.await; - this.update(cx, |this, cx| { - let previously_selected_entry = - this.all_entries.get(this.selected_index).map(|e| e.id()); - - this.all_entries = new_entries; - this.separated_items = items; - this.separated_item_indexes = indexes; - - match &this.search_state { - SearchState::Empty => { - if this.selected_index >= this.all_entries.len() { - this.set_selected_entry_index( - this.all_entries.len().saturating_sub(1), - cx, - ); - } else if let Some(prev_id) = previously_selected_entry - && let Some(new_ix) = this - .all_entries - .iter() - .position(|probe| probe.id() == prev_id) - { - this.set_selected_entry_index(new_ix, cx); - } - } - SearchState::Searching { query, .. } | SearchState::Searched { query, .. } => { - this.search(query.clone(), cx); - } - } - - cx.notify(); - }) - .log_err(); - }); - self._separated_items_task = Some(task); + items + }) } - fn search(&mut self, query: SharedString, cx: &mut Context) { - if query.is_empty() { - self.search_state = SearchState::Empty; - cx.notify(); - return; - } - - let all_entries = self.all_entries.clone(); - - let fuzzy_search_task = cx.background_spawn({ - let query = query.clone(); + fn filter_search_results( + &self, + entries: Vec, + cx: &App, + ) -> Task> { + let query = self.search_query.clone(); + cx.background_spawn({ let executor = cx.background_executor().clone(); async move { - let mut candidates = Vec::with_capacity(all_entries.len()); + let mut candidates = Vec::with_capacity(entries.len()); - for (idx, entry) in all_entries.iter().enumerate() { + for (idx, entry) in entries.iter().enumerate() { candidates.push(StringMatchCandidate::new(idx, entry.title())); } const MAX_MATCHES: usize = 100; - fuzzy::match_strings( + let matches = fuzzy::match_strings( &candidates, &query, false, @@ -217,74 +207,61 @@ impl AcpThreadHistory { &Default::default(), executor, ) - .await + .await; + + matches + .into_iter() + .map(|search_match| ListItemType::SearchResult { + entry: entries[search_match.candidate_id].clone(), + positions: search_match.positions, + }) + .collect() } - }); - - let task = cx.spawn({ - let query = query.clone(); - async move |this, cx| { - let matches = fuzzy_search_task.await; - - this.update(cx, |this, cx| { - let SearchState::Searching { - query: current_query, - _task, - } = &this.search_state - else { - return; - }; - - if &query == current_query { - this.search_state = SearchState::Searched { - query: query.clone(), - matches, - }; - - this.set_selected_entry_index(0, cx); - cx.notify(); - }; - }) - .log_err(); - } - }); - - self.search_state = SearchState::Searching { query, _task: task }; - cx.notify(); - } - - fn matched_count(&self) -> usize { - match &self.search_state { - SearchState::Empty => self.all_entries.len(), - SearchState::Searching { .. } => 0, - SearchState::Searched { matches, .. } => matches.len(), - } - } - - fn list_item_count(&self) -> usize { - match &self.search_state { - SearchState::Empty => self.separated_items.len(), - SearchState::Searching { .. } => 0, - SearchState::Searched { matches, .. } => matches.len(), - } + }) } fn search_produced_no_matches(&self) -> bool { - match &self.search_state { - SearchState::Empty => false, - SearchState::Searching { .. } => false, - SearchState::Searched { matches, .. } => matches.is_empty(), - } + self.visible_items.is_empty() && !self.search_query.is_empty() } - fn get_match(&self, ix: usize) -> Option<&HistoryEntry> { - match &self.search_state { - SearchState::Empty => self.all_entries.get(ix), - SearchState::Searching { .. } => None, - SearchState::Searched { matches, .. } => matches - .get(ix) - .and_then(|m| self.all_entries.get(m.candidate_id)), + fn selected_history_entry(&self) -> Option<&HistoryEntry> { + self.get_history_entry(self.selected_index) + } + + fn get_history_entry(&self, visible_items_ix: usize) -> Option<&HistoryEntry> { + self.visible_items.get(visible_items_ix)?.history_entry() + } + + fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context) { + if self.visible_items.len() == 0 { + self.selected_index = 0; + return; } + while matches!( + self.visible_items.get(index), + None | Some(ListItemType::BucketSeparator(..)) + ) { + index = match bias { + Bias::Left => { + if index == 0 { + self.visible_items.len() - 1 + } else { + index - 1 + } + } + Bias::Right => { + if index >= self.visible_items.len() - 1 { + 0 + } else { + index + 1 + } + } + }; + } + self.selected_index = index; + self.scroll_handle + .scroll_to_item(index, ScrollStrategy::Top); + cx.notify() } pub fn select_previous( @@ -293,13 +270,10 @@ impl AcpThreadHistory { _window: &mut Window, cx: &mut Context, ) { - let count = self.matched_count(); - if count > 0 { - if self.selected_index == 0 { - self.set_selected_entry_index(count - 1, cx); - } else { - self.set_selected_entry_index(self.selected_index - 1, cx); - } + if self.selected_index == 0 { + self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); + } else { + self.set_selected_index(self.selected_index - 1, Bias::Left, cx); } } @@ -309,13 +283,10 @@ impl AcpThreadHistory { _window: &mut Window, cx: &mut Context, ) { - let count = self.matched_count(); - if count > 0 { - if self.selected_index == count - 1 { - self.set_selected_entry_index(0, cx); - } else { - self.set_selected_entry_index(self.selected_index + 1, cx); - } + if self.selected_index == self.visible_items.len() - 1 { + self.set_selected_index(0, Bias::Right, cx); + } else { + self.set_selected_index(self.selected_index + 1, Bias::Right, cx); } } @@ -325,35 +296,47 @@ impl AcpThreadHistory { _window: &mut Window, cx: &mut Context, ) { - let count = self.matched_count(); - if count > 0 { - self.set_selected_entry_index(0, cx); - } + self.set_selected_index(0, Bias::Right, cx); } fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { - let count = self.matched_count(); - if count > 0 { - self.set_selected_entry_index(count - 1, cx); - } + self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); } - fn set_selected_entry_index(&mut self, entry_index: usize, cx: &mut Context) { - self.selected_index = entry_index; + fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { + self.confirm_entry(self.selected_index, cx); + } - let scroll_ix = match self.search_state { - SearchState::Empty | SearchState::Searching { .. } => self - .separated_item_indexes - .get(entry_index) - .map(|ix| *ix as usize) - .unwrap_or(entry_index + 1), - SearchState::Searched { .. } => entry_index, + fn confirm_entry(&mut self, ix: usize, cx: &mut Context) { + let Some(entry) = self.get_history_entry(ix) else { + return; + }; + cx.emit(ThreadHistoryEvent::Open(entry.clone())); + } + + fn remove_selected_thread( + &mut self, + _: &RemoveSelectedThread, + _window: &mut Window, + cx: &mut Context, + ) { + self.remove_thread(self.selected_index, cx) + } + + fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context) { + let Some(entry) = self.get_history_entry(visible_item_ix) else { + return; }; - self.scroll_handle - .scroll_to_item(scroll_ix, ScrollStrategy::Top); - - cx.notify(); + let task = match entry { + HistoryEntry::AcpThread(thread) => self + .history_store + .update(cx, |this, cx| this.delete_thread(thread.id.clone(), cx)), + HistoryEntry::TextThread(context) => self.history_store.update(cx, |this, cx| { + this.delete_text_thread(context.path.clone(), cx) + }), + }; + task.detach_and_log_err(cx); } fn render_scrollbar(&self, cx: &mut Context) -> Option> { @@ -393,91 +376,33 @@ impl AcpThreadHistory { ) } - fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { - self.confirm_entry(self.selected_index, cx); - } - - fn confirm_entry(&mut self, ix: usize, cx: &mut Context) { - let Some(entry) = self.get_match(ix) else { - return; - }; - cx.emit(ThreadHistoryEvent::Open(entry.clone())); - } - - fn remove_selected_thread( - &mut self, - _: &RemoveSelectedThread, - _window: &mut Window, - cx: &mut Context, - ) { - self.remove_thread(self.selected_index, cx) - } - - fn remove_thread(&mut self, ix: usize, cx: &mut Context) { - let Some(entry) = self.get_match(ix) else { - return; - }; - - let task = match entry { - HistoryEntry::AcpThread(thread) => self - .history_store - .update(cx, |this, cx| this.delete_thread(thread.id.clone(), cx)), - HistoryEntry::TextThread(context) => self.history_store.update(cx, |this, cx| { - this.delete_text_thread(context.path.clone(), cx) - }), - }; - task.detach_and_log_err(cx); - } - - fn list_items( + fn render_list_items( &mut self, range: Range, _window: &mut Window, cx: &mut Context, ) -> Vec { - match &self.search_state { - SearchState::Empty => self - .separated_items - .get(range) - .iter() - .flat_map(|items| { - items - .iter() - .map(|item| self.render_list_item(item, vec![], cx)) - }) - .collect(), - SearchState::Searched { matches, .. } => matches[range] - .iter() - .filter_map(|m| { - let entry = self.all_entries.get(m.candidate_id)?; - Some(self.render_history_entry( - entry, - EntryTimeFormat::DateAndTime, - m.candidate_id, - m.positions.clone(), - cx, - )) - }) - .collect(), - SearchState::Searching { .. } => { - vec![] - } - } + self.visible_items + .get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx)) + .collect() } - fn render_list_item( - &self, - item: &ListItemType, - highlight_positions: Vec, - cx: &Context, - ) -> AnyElement { + fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context) -> AnyElement { match item { - ListItemType::Entry { index, format } => match self.all_entries.get(*index) { - Some(entry) => self - .render_history_entry(entry, *format, *index, highlight_positions, cx) - .into_any(), - None => Empty.into_any_element(), - }, + ListItemType::Entry { entry, format } => self + .render_history_entry(entry, *format, ix, Vec::default(), cx) + .into_any(), + ListItemType::SearchResult { entry, positions } => self.render_history_entry( + entry, + EntryTimeFormat::DateAndTime, + ix, + positions.clone(), + cx, + ), ListItemType::BucketSeparator(bucket) => div() .px(DynamicSpacing::Base06.rems(cx)) .pt_2() @@ -495,12 +420,12 @@ impl AcpThreadHistory { &self, entry: &HistoryEntry, format: EntryTimeFormat, - list_entry_ix: usize, + ix: usize, highlight_positions: Vec, cx: &Context, ) -> AnyElement { - let selected = list_entry_ix == self.selected_index; - let hovered = Some(list_entry_ix) == self.hovered_index; + let selected = ix == self.selected_index; + let hovered = Some(ix) == self.hovered_index; let timestamp = entry.updated_at().timestamp(); let thread_timestamp = format.format_timestamp(timestamp, self.local_timezone); @@ -508,7 +433,7 @@ impl AcpThreadHistory { .w_full() .pb_1() .child( - ListItem::new(list_entry_ix) + ListItem::new(ix) .rounded() .toggle_state(selected) .spacing(ListItemSpacing::Sparse) @@ -530,8 +455,8 @@ impl AcpThreadHistory { ) .on_hover(cx.listener(move |this, is_hovered, _window, cx| { if *is_hovered { - this.hovered_index = Some(list_entry_ix); - } else if this.hovered_index == Some(list_entry_ix) { + this.hovered_index = Some(ix); + } else if this.hovered_index == Some(ix) { this.hovered_index = None; } @@ -546,16 +471,14 @@ impl AcpThreadHistory { .tooltip(move |window, cx| { Tooltip::for_action("Delete", &RemoveSelectedThread, window, cx) }) - .on_click(cx.listener(move |this, _, _, cx| { - this.remove_thread(list_entry_ix, cx) - })), + .on_click( + cx.listener(move |this, _, _, cx| this.remove_thread(ix, cx)), + ), ) } else { None }) - .on_click( - cx.listener(move |this, _, _, cx| this.confirm_entry(list_entry_ix, cx)), - ), + .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))), ) .into_any_element() } @@ -578,7 +501,7 @@ impl Render for AcpThreadHistory { .on_action(cx.listener(Self::select_last)) .on_action(cx.listener(Self::confirm)) .on_action(cx.listener(Self::remove_selected_thread)) - .when(!self.all_entries.is_empty(), |parent| { + .when(!self.history_store.read(cx).is_empty(cx), |parent| { parent.child( h_flex() .h(px(41.)) // Match the toolbar perfectly @@ -604,7 +527,7 @@ impl Render for AcpThreadHistory { .overflow_hidden() .flex_grow(); - if self.all_entries.is_empty() { + if self.history_store.read(cx).is_empty(cx) { view.justify_center() .child( h_flex().w_full().justify_center().child( @@ -623,9 +546,9 @@ impl Render for AcpThreadHistory { .child( uniform_list( "thread-history", - self.list_item_count(), + self.visible_items.len(), cx.processor(|this, range: Range, window, cx| { - this.list_items(range, window, cx) + this.render_list_items(range, window, cx) }), ) .p_1() diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 4d89a55139..dae89b3283 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -2538,9 +2538,9 @@ impl AcpThreadView { ) }) .when(render_history, |this| { - let recent_history = self - .history_store - .update(cx, |history_store, cx| history_store.recent_entries(3, cx)); + let recent_history: Vec<_> = self.history_store.update(cx, |history_store, _| { + history_store.entries().take(3).collect() + }); this.justify_end().child( v_flex() .child( From 6b4c9119d8ea1ea033a933528a6f0da1e9979509 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Fri, 22 Aug 2025 03:48:47 -0400 Subject: [PATCH 41/89] acp: Fix panic with edit file tool (#36732) We had a frequent panic when the agent was using our edit file tool. The root cause was that we were constructing a `BufferDiff` with `BufferDiff::new`, then calling `set_base_text`, but not waiting for that asynchronous operation to finish. This means there was a window of time where the diff's base text was set to the initial value of `""`--that's not a problem in itself, but it was possible for us to call `PendingDiff::update` during that window, which calls `BufferDiff::update_diff`, which calls `BufferDiffSnapshot::new_with_base_buffer`, which takes two arguments `base_text` and `base_text_snapshot` that are supposed to represent the same text. We were getting the first of those arguments from the `base_text` field of `PendingDiff`, which is set immediately to the target base text without waiting for `BufferDiff::set_base_text` to run to completion; and the second from the `BufferDiff` itself, which still has the empty base text during that window. As a result of that mismatch, we could end up adding `DeletedHunk` diff transforms to the multibuffer for the diff card even though the multibuffer's base text was empty, ultimately leading to a panic very far away in rendering code. I've fixed this by adding a new `BufferDiff` constructor for the case where the buffer contents and the base text are (initially) the same, like for the diff cards, and so we don't need an async diff calculation. I also added a debug assertion to catch the basic issue here earlier, when `BufferDiffSnapshot::new_with_base_buffer` is called with two base texts that don't match. Release Notes: - N/A --------- Co-authored-by: Conrad --- crates/acp_thread/src/diff.rs | 40 +++++++++++++++++---------- crates/buffer_diff/src/buffer_diff.rs | 33 +++++++++++++++++++++- 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/crates/acp_thread/src/diff.rs b/crates/acp_thread/src/diff.rs index 59f907dcc4..0fec6809e0 100644 --- a/crates/acp_thread/src/diff.rs +++ b/crates/acp_thread/src/diff.rs @@ -85,27 +85,19 @@ impl Diff { } pub fn new(buffer: Entity, cx: &mut Context) -> Self { - let buffer_snapshot = buffer.read(cx).snapshot(); - let base_text = buffer_snapshot.text(); - let language_registry = buffer.read(cx).language_registry(); - let text_snapshot = buffer.read(cx).text_snapshot(); + let buffer_text_snapshot = buffer.read(cx).text_snapshot(); + let base_text_snapshot = buffer.read(cx).snapshot(); + let base_text = base_text_snapshot.text(); + debug_assert_eq!(buffer_text_snapshot.text(), base_text); let buffer_diff = cx.new(|cx| { - let mut diff = BufferDiff::new(&text_snapshot, cx); - let _ = diff.set_base_text( - buffer_snapshot.clone(), - language_registry, - text_snapshot, - cx, - ); + let mut diff = BufferDiff::new_unchanged(&buffer_text_snapshot, base_text_snapshot); let snapshot = diff.snapshot(cx); - let secondary_diff = cx.new(|cx| { - let mut diff = BufferDiff::new(&buffer_snapshot, cx); - diff.set_snapshot(snapshot, &buffer_snapshot, cx); + let mut diff = BufferDiff::new(&buffer_text_snapshot, cx); + diff.set_snapshot(snapshot, &buffer_text_snapshot, cx); diff }); diff.set_secondary_diff(secondary_diff); - diff }); @@ -412,3 +404,21 @@ async fn build_buffer_diff( diff }) } + +#[cfg(test)] +mod tests { + use gpui::{AppContext as _, TestAppContext}; + use language::Buffer; + + use crate::Diff; + + #[gpui::test] + async fn test_pending_diff(cx: &mut TestAppContext) { + let buffer = cx.new(|cx| Buffer::local("hello!", cx)); + let _diff = cx.new(|cx| Diff::new(buffer.clone(), cx)); + buffer.update(cx, |buffer, cx| { + buffer.set_text("HELLO!", cx); + }); + cx.run_until_parked(); + } +} diff --git a/crates/buffer_diff/src/buffer_diff.rs b/crates/buffer_diff/src/buffer_diff.rs index 10b59d0ba2..b20dad4ebb 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -162,6 +162,22 @@ impl BufferDiffSnapshot { } } + fn unchanged( + buffer: &text::BufferSnapshot, + base_text: language::BufferSnapshot, + ) -> BufferDiffSnapshot { + debug_assert_eq!(buffer.text(), base_text.text()); + BufferDiffSnapshot { + inner: BufferDiffInner { + base_text, + hunks: SumTree::new(buffer), + pending_hunks: SumTree::new(buffer), + base_text_exists: false, + }, + secondary_diff: None, + } + } + fn new_with_base_text( buffer: text::BufferSnapshot, base_text: Option>, @@ -213,7 +229,10 @@ impl BufferDiffSnapshot { cx: &App, ) -> impl Future + use<> { let base_text_exists = base_text.is_some(); - let base_text_pair = base_text.map(|text| (text, base_text_snapshot.as_rope().clone())); + let base_text_pair = base_text.map(|text| { + debug_assert_eq!(&*text, &base_text_snapshot.text()); + (text, base_text_snapshot.as_rope().clone()) + }); cx.background_executor() .spawn_labeled(*CALCULATE_DIFF_TASK, async move { Self { @@ -873,6 +892,18 @@ impl BufferDiff { } } + pub fn new_unchanged( + buffer: &text::BufferSnapshot, + base_text: language::BufferSnapshot, + ) -> Self { + debug_assert_eq!(buffer.text(), base_text.text()); + BufferDiff { + buffer_id: buffer.remote_id(), + inner: BufferDiffSnapshot::unchanged(buffer, base_text).inner, + secondary_diff: None, + } + } + #[cfg(any(test, feature = "test-support"))] pub fn new_with_base_text( base_text: &str, From d53dedca64e7a5efb8f77aa8b484cc35e7063d66 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 22 Aug 2025 15:16:42 +0200 Subject: [PATCH 42/89] acp: Support calling tools provided by MCP servers (#36752) Release Notes: - N/A --- crates/agent2/src/tests/mod.rs | 441 +++++++++++++++++++++++++++++- crates/agent2/src/thread.rs | 148 +++++++--- crates/context_server/src/test.rs | 36 ++- 3 files changed, 561 insertions(+), 64 deletions(-) diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index ac7b40c64f..9ead8b9da3 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -4,26 +4,35 @@ use agent_client_protocol::{self as acp}; use agent_settings::AgentProfileId; use anyhow::Result; use client::{Client, UserStore}; +use context_server::{ContextServer, ContextServerCommand, ContextServerId}; use fs::{FakeFs, Fs}; -use futures::{StreamExt, channel::mpsc::UnboundedReceiver}; +use futures::{ + StreamExt, + channel::{ + mpsc::{self, UnboundedReceiver}, + oneshot, + }, +}; use gpui::{ App, AppContext, Entity, Task, TestAppContext, UpdateGlobal, http_client::FakeHttpClient, }; use indoc::indoc; use language_model::{ LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, - LanguageModelProviderName, LanguageModelRegistry, LanguageModelRequestMessage, - LanguageModelToolResult, LanguageModelToolUse, MessageContent, Role, StopReason, - fake_provider::FakeLanguageModel, + LanguageModelProviderName, LanguageModelRegistry, LanguageModelRequest, + LanguageModelRequestMessage, LanguageModelToolResult, LanguageModelToolSchemaFormat, + LanguageModelToolUse, MessageContent, Role, StopReason, fake_provider::FakeLanguageModel, }; use pretty_assertions::assert_eq; -use project::Project; +use project::{ + Project, context_server_store::ContextServerStore, project_settings::ProjectSettings, +}; use prompt_store::ProjectContext; use reqwest_client::ReqwestClient; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::json; -use settings::SettingsStore; +use settings::{Settings, SettingsStore}; use std::{path::Path, rc::Rc, sync::Arc, time::Duration}; use util::path; @@ -931,6 +940,334 @@ async fn test_profiles(cx: &mut TestAppContext) { assert_eq!(tool_names, vec![InfiniteTool::name()]); } +#[gpui::test] +async fn test_mcp_tools(cx: &mut TestAppContext) { + let ThreadTest { + model, + thread, + context_server_store, + fs, + .. + } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + // Override profiles and wait for settings to be loaded. + fs.insert_file( + paths::settings_file(), + json!({ + "agent": { + "profiles": { + "test": { + "name": "Test Profile", + "enable_all_context_servers": true, + "tools": { + EchoTool::name(): true, + } + }, + } + } + }) + .to_string() + .into_bytes(), + ) + .await; + cx.run_until_parked(); + thread.update(cx, |thread, _| { + thread.set_profile(AgentProfileId("test".into())) + }); + + let mut mcp_tool_calls = setup_context_server( + "test_server", + vec![context_server::types::Tool { + name: "echo".into(), + description: None, + input_schema: serde_json::to_value( + EchoTool.input_schema(LanguageModelToolSchemaFormat::JsonSchema), + ) + .unwrap(), + output_schema: None, + annotations: None, + }], + &context_server_store, + cx, + ); + + let events = thread.update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Hey"], cx).unwrap() + }); + cx.run_until_parked(); + + // Simulate the model calling the MCP tool. + let completion = fake_model.pending_completions().pop().unwrap(); + assert_eq!(tool_names_for_completion(&completion), vec!["echo"]); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "tool_1".into(), + name: "echo".into(), + raw_input: json!({"text": "test"}).to_string(), + input: json!({"text": "test"}), + is_input_complete: true, + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + let (tool_call_params, tool_call_response) = mcp_tool_calls.next().await.unwrap(); + assert_eq!(tool_call_params.name, "echo"); + assert_eq!(tool_call_params.arguments, Some(json!({"text": "test"}))); + tool_call_response + .send(context_server::types::CallToolResponse { + content: vec![context_server::types::ToolResponseContent::Text { + text: "test".into(), + }], + is_error: None, + meta: None, + structured_content: None, + }) + .unwrap(); + cx.run_until_parked(); + + assert_eq!(tool_names_for_completion(&completion), vec!["echo"]); + fake_model.send_last_completion_stream_text_chunk("Done!"); + fake_model.end_last_completion_stream(); + events.collect::>().await; + + // Send again after adding the echo tool, ensuring the name collision is resolved. + let events = thread.update(cx, |thread, cx| { + thread.add_tool(EchoTool); + thread.send(UserMessageId::new(), ["Go"], cx).unwrap() + }); + cx.run_until_parked(); + let completion = fake_model.pending_completions().pop().unwrap(); + assert_eq!( + tool_names_for_completion(&completion), + vec!["echo", "test_server_echo"] + ); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "tool_2".into(), + name: "test_server_echo".into(), + raw_input: json!({"text": "mcp"}).to_string(), + input: json!({"text": "mcp"}), + is_input_complete: true, + }, + )); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "tool_3".into(), + name: "echo".into(), + raw_input: json!({"text": "native"}).to_string(), + input: json!({"text": "native"}), + is_input_complete: true, + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + let (tool_call_params, tool_call_response) = mcp_tool_calls.next().await.unwrap(); + assert_eq!(tool_call_params.name, "echo"); + assert_eq!(tool_call_params.arguments, Some(json!({"text": "mcp"}))); + tool_call_response + .send(context_server::types::CallToolResponse { + content: vec![context_server::types::ToolResponseContent::Text { text: "mcp".into() }], + is_error: None, + meta: None, + structured_content: None, + }) + .unwrap(); + cx.run_until_parked(); + + // Ensure the tool results were inserted with the correct names. + let completion = fake_model.pending_completions().pop().unwrap(); + assert_eq!( + completion.messages.last().unwrap().content, + vec![ + MessageContent::ToolResult(LanguageModelToolResult { + tool_use_id: "tool_3".into(), + tool_name: "echo".into(), + is_error: false, + content: "native".into(), + output: Some("native".into()), + },), + MessageContent::ToolResult(LanguageModelToolResult { + tool_use_id: "tool_2".into(), + tool_name: "test_server_echo".into(), + is_error: false, + content: "mcp".into(), + output: Some("mcp".into()), + },), + ] + ); + fake_model.end_last_completion_stream(); + events.collect::>().await; +} + +#[gpui::test] +async fn test_mcp_tool_truncation(cx: &mut TestAppContext) { + let ThreadTest { + model, + thread, + context_server_store, + fs, + .. + } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + // Set up a profile with all tools enabled + fs.insert_file( + paths::settings_file(), + json!({ + "agent": { + "profiles": { + "test": { + "name": "Test Profile", + "enable_all_context_servers": true, + "tools": { + EchoTool::name(): true, + DelayTool::name(): true, + WordListTool::name(): true, + ToolRequiringPermission::name(): true, + InfiniteTool::name(): true, + } + }, + } + } + }) + .to_string() + .into_bytes(), + ) + .await; + cx.run_until_parked(); + + thread.update(cx, |thread, _| { + thread.set_profile(AgentProfileId("test".into())); + thread.add_tool(EchoTool); + thread.add_tool(DelayTool); + thread.add_tool(WordListTool); + thread.add_tool(ToolRequiringPermission); + thread.add_tool(InfiniteTool); + }); + + // Set up multiple context servers with some overlapping tool names + let _server1_calls = setup_context_server( + "xxx", + vec![ + context_server::types::Tool { + name: "echo".into(), // Conflicts with native EchoTool + description: None, + input_schema: serde_json::to_value( + EchoTool.input_schema(LanguageModelToolSchemaFormat::JsonSchema), + ) + .unwrap(), + output_schema: None, + annotations: None, + }, + context_server::types::Tool { + name: "unique_tool_1".into(), + description: None, + input_schema: json!({"type": "object", "properties": {}}), + output_schema: None, + annotations: None, + }, + ], + &context_server_store, + cx, + ); + + let _server2_calls = setup_context_server( + "yyy", + vec![ + context_server::types::Tool { + name: "echo".into(), // Also conflicts with native EchoTool + description: None, + input_schema: serde_json::to_value( + EchoTool.input_schema(LanguageModelToolSchemaFormat::JsonSchema), + ) + .unwrap(), + output_schema: None, + annotations: None, + }, + context_server::types::Tool { + name: "unique_tool_2".into(), + description: None, + input_schema: json!({"type": "object", "properties": {}}), + output_schema: None, + annotations: None, + }, + context_server::types::Tool { + name: "a".repeat(MAX_TOOL_NAME_LENGTH - 2), + description: None, + input_schema: json!({"type": "object", "properties": {}}), + output_schema: None, + annotations: None, + }, + context_server::types::Tool { + name: "b".repeat(MAX_TOOL_NAME_LENGTH - 1), + description: None, + input_schema: json!({"type": "object", "properties": {}}), + output_schema: None, + annotations: None, + }, + ], + &context_server_store, + cx, + ); + let _server3_calls = setup_context_server( + "zzz", + vec![ + context_server::types::Tool { + name: "a".repeat(MAX_TOOL_NAME_LENGTH - 2), + description: None, + input_schema: json!({"type": "object", "properties": {}}), + output_schema: None, + annotations: None, + }, + context_server::types::Tool { + name: "b".repeat(MAX_TOOL_NAME_LENGTH - 1), + description: None, + input_schema: json!({"type": "object", "properties": {}}), + output_schema: None, + annotations: None, + }, + context_server::types::Tool { + name: "c".repeat(MAX_TOOL_NAME_LENGTH + 1), + description: None, + input_schema: json!({"type": "object", "properties": {}}), + output_schema: None, + annotations: None, + }, + ], + &context_server_store, + cx, + ); + + thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Go"], cx) + }) + .unwrap(); + cx.run_until_parked(); + let completion = fake_model.pending_completions().pop().unwrap(); + assert_eq!( + tool_names_for_completion(&completion), + vec![ + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "delay", + "echo", + "infinite", + "tool_requiring_permission", + "unique_tool_1", + "unique_tool_2", + "word_list", + "xxx_echo", + "y_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "yyy_echo", + "z_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ] + ); +} + #[gpui::test] #[cfg_attr(not(feature = "e2e"), ignore)] async fn test_cancellation(cx: &mut TestAppContext) { @@ -1806,6 +2143,7 @@ struct ThreadTest { model: Arc, thread: Entity, project_context: Entity, + context_server_store: Entity, fs: Arc, } @@ -1844,6 +2182,7 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest { WordListTool::name(): true, ToolRequiringPermission::name(): true, InfiniteTool::name(): true, + ThinkingTool::name(): true, } } } @@ -1900,8 +2239,9 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest { .await; let project_context = cx.new(|_cx| ProjectContext::default()); + let context_server_store = project.read_with(cx, |project, _| project.context_server_store()); let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx)); let thread = cx.new(|cx| { Thread::new( project, @@ -1916,6 +2256,7 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest { model, thread, project_context, + context_server_store, fs, } } @@ -1950,3 +2291,89 @@ fn watch_settings(fs: Arc, cx: &mut App) { }) .detach(); } + +fn tool_names_for_completion(completion: &LanguageModelRequest) -> Vec { + completion + .tools + .iter() + .map(|tool| tool.name.clone()) + .collect() +} + +fn setup_context_server( + name: &'static str, + tools: Vec, + context_server_store: &Entity, + cx: &mut TestAppContext, +) -> mpsc::UnboundedReceiver<( + context_server::types::CallToolParams, + oneshot::Sender, +)> { + cx.update(|cx| { + let mut settings = ProjectSettings::get_global(cx).clone(); + settings.context_servers.insert( + name.into(), + project::project_settings::ContextServerSettings::Custom { + enabled: true, + command: ContextServerCommand { + path: "somebinary".into(), + args: Vec::new(), + env: None, + }, + }, + ); + ProjectSettings::override_global(settings, cx); + }); + + let (mcp_tool_calls_tx, mcp_tool_calls_rx) = mpsc::unbounded(); + let fake_transport = context_server::test::create_fake_transport(name, cx.executor()) + .on_request::(move |_params| async move { + context_server::types::InitializeResponse { + protocol_version: context_server::types::ProtocolVersion( + context_server::types::LATEST_PROTOCOL_VERSION.to_string(), + ), + server_info: context_server::types::Implementation { + name: name.into(), + version: "1.0.0".to_string(), + }, + capabilities: context_server::types::ServerCapabilities { + tools: Some(context_server::types::ToolsCapabilities { + list_changed: Some(true), + }), + ..Default::default() + }, + meta: None, + } + }) + .on_request::(move |_params| { + let tools = tools.clone(); + async move { + context_server::types::ListToolsResponse { + tools, + next_cursor: None, + meta: None, + } + } + }) + .on_request::(move |params| { + let mcp_tool_calls_tx = mcp_tool_calls_tx.clone(); + async move { + let (response_tx, response_rx) = oneshot::channel(); + mcp_tool_calls_tx + .unbounded_send((params, response_tx)) + .unwrap(); + response_rx.await.unwrap() + } + }); + context_server_store.update(cx, |store, cx| { + store.start_server( + Arc::new(ContextServer::new( + ContextServerId(name.into()), + Arc::new(fake_transport), + )), + cx, + ); + }); + cx.run_until_parked(); + mcp_tool_calls_rx +} diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index af18afa055..c89e5875f9 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -9,15 +9,15 @@ use action_log::ActionLog; use agent::thread::{GitState, ProjectSnapshot, WorktreeSnapshot}; use agent_client_protocol as acp; use agent_settings::{ - AgentProfileId, AgentSettings, CompletionMode, SUMMARIZE_THREAD_DETAILED_PROMPT, - SUMMARIZE_THREAD_PROMPT, + AgentProfileId, AgentProfileSettings, AgentSettings, CompletionMode, + SUMMARIZE_THREAD_DETAILED_PROMPT, SUMMARIZE_THREAD_PROMPT, }; use anyhow::{Context as _, Result, anyhow}; use assistant_tool::adapt_schema_to_format; use chrono::{DateTime, Utc}; use client::{ModelRequestUsage, RequestUsage}; use cloud_llm_client::{CompletionIntent, CompletionRequestStatus, UsageLimit}; -use collections::{HashMap, IndexMap}; +use collections::{HashMap, HashSet, IndexMap}; use fs::Fs; use futures::{ FutureExt, @@ -56,6 +56,7 @@ use util::{ResultExt, markdown::MarkdownCodeBlock}; use uuid::Uuid; const TOOL_CANCELED_MESSAGE: &str = "Tool canceled by user"; +pub const MAX_TOOL_NAME_LENGTH: usize = 64; /// The ID of the user prompt that initiated a request. /// @@ -627,7 +628,20 @@ impl Thread { stream: &ThreadEventStream, cx: &mut Context, ) { - let Some(tool) = self.tools.get(tool_use.name.as_ref()) else { + let tool = self.tools.get(tool_use.name.as_ref()).cloned().or_else(|| { + self.context_server_registry + .read(cx) + .servers() + .find_map(|(_, tools)| { + if let Some(tool) = tools.get(tool_use.name.as_ref()) { + Some(tool.clone()) + } else { + None + } + }) + }); + + let Some(tool) = tool else { stream .0 .unbounded_send(Ok(ThreadEvent::ToolCall(acp::ToolCall { @@ -1079,6 +1093,10 @@ impl Thread { self.cancel(cx); let model = self.model.clone().context("No language model configured")?; + let profile = AgentSettings::get_global(cx) + .profiles + .get(&self.profile_id) + .context("Profile not found")?; let (events_tx, events_rx) = mpsc::unbounded::>(); let event_stream = ThreadEventStream(events_tx); let message_ix = self.messages.len().saturating_sub(1); @@ -1086,6 +1104,7 @@ impl Thread { self.summary = None; self.running_turn = Some(RunningTurn { event_stream: event_stream.clone(), + tools: self.enabled_tools(profile, &model, cx), _task: cx.spawn(async move |this, cx| { log::info!("Starting agent turn execution"); @@ -1417,7 +1436,7 @@ impl Thread { ) -> Option> { cx.notify(); - let tool = self.tools.get(tool_use.name.as_ref()).cloned(); + let tool = self.tool(tool_use.name.as_ref()); let mut title = SharedString::from(&tool_use.name); let mut kind = acp::ToolKind::Other; if let Some(tool) = tool.as_ref() { @@ -1727,6 +1746,21 @@ impl Thread { cx: &mut App, ) -> Result { let model = self.model().context("No language model configured")?; + let tools = if let Some(turn) = self.running_turn.as_ref() { + turn.tools + .iter() + .filter_map(|(tool_name, tool)| { + log::trace!("Including tool: {}", tool_name); + Some(LanguageModelRequestTool { + name: tool_name.to_string(), + description: tool.description().to_string(), + input_schema: tool.input_schema(model.tool_input_format()).log_err()?, + }) + }) + .collect::>() + } else { + Vec::new() + }; log::debug!("Building completion request"); log::debug!("Completion intent: {:?}", completion_intent); @@ -1734,23 +1768,6 @@ impl Thread { let messages = self.build_request_messages(cx); log::info!("Request will include {} messages", messages.len()); - - let tools = if let Some(tools) = self.tools(cx).log_err() { - tools - .filter_map(|tool| { - let tool_name = tool.name().to_string(); - log::trace!("Including tool: {}", tool_name); - Some(LanguageModelRequestTool { - name: tool_name, - description: tool.description().to_string(), - input_schema: tool.input_schema(model.tool_input_format()).log_err()?, - }) - }) - .collect() - } else { - Vec::new() - }; - log::info!("Request includes {} tools", tools.len()); let request = LanguageModelRequest { @@ -1770,37 +1787,76 @@ impl Thread { Ok(request) } - fn tools<'a>(&'a self, cx: &'a App) -> Result>> { - let model = self.model().context("No language model configured")?; + fn enabled_tools( + &self, + profile: &AgentProfileSettings, + model: &Arc, + cx: &App, + ) -> BTreeMap> { + fn truncate(tool_name: &SharedString) -> SharedString { + if tool_name.len() > MAX_TOOL_NAME_LENGTH { + let mut truncated = tool_name.to_string(); + truncated.truncate(MAX_TOOL_NAME_LENGTH); + truncated.into() + } else { + tool_name.clone() + } + } - let profile = AgentSettings::get_global(cx) - .profiles - .get(&self.profile_id) - .context("profile not found")?; - let provider_id = model.provider_id(); - - Ok(self + let mut tools = self .tools .iter() - .filter(move |(_, tool)| tool.supported_provider(&provider_id)) .filter_map(|(tool_name, tool)| { - if profile.is_tool_enabled(tool_name) { - Some(tool) + if tool.supported_provider(&model.provider_id()) + && profile.is_tool_enabled(tool_name) + { + Some((truncate(tool_name), tool.clone())) } else { None } }) - .chain(self.context_server_registry.read(cx).servers().flat_map( - |(server_id, tools)| { - tools.iter().filter_map(|(tool_name, tool)| { - if profile.is_context_server_tool_enabled(&server_id.0, tool_name) { - Some(tool) - } else { - None - } - }) - }, - ))) + .collect::>(); + + let mut context_server_tools = Vec::new(); + let mut seen_tools = tools.keys().cloned().collect::>(); + let mut duplicate_tool_names = HashSet::default(); + for (server_id, server_tools) in self.context_server_registry.read(cx).servers() { + for (tool_name, tool) in server_tools { + if profile.is_context_server_tool_enabled(&server_id.0, &tool_name) { + let tool_name = truncate(tool_name); + if !seen_tools.insert(tool_name.clone()) { + duplicate_tool_names.insert(tool_name.clone()); + } + context_server_tools.push((server_id.clone(), tool_name, tool.clone())); + } + } + } + + // When there are duplicate tool names, disambiguate by prefixing them + // with the server ID. In the rare case there isn't enough space for the + // disambiguated tool name, keep only the last tool with this name. + for (server_id, tool_name, tool) in context_server_tools { + if duplicate_tool_names.contains(&tool_name) { + let available = MAX_TOOL_NAME_LENGTH.saturating_sub(tool_name.len()); + if available >= 2 { + let mut disambiguated = server_id.0.to_string(); + disambiguated.truncate(available - 1); + disambiguated.push('_'); + disambiguated.push_str(&tool_name); + tools.insert(disambiguated.into(), tool.clone()); + } else { + tools.insert(tool_name, tool.clone()); + } + } else { + tools.insert(tool_name, tool.clone()); + } + } + + tools + } + + fn tool(&self, name: &str) -> Option> { + self.running_turn.as_ref()?.tools.get(name).cloned() } fn build_request_messages(&self, cx: &App) -> Vec { @@ -1965,6 +2021,8 @@ struct RunningTurn { /// The current event stream for the running turn. Used to report a final /// cancellation event if we cancel the turn. event_stream: ThreadEventStream, + /// The tools that were enabled for this turn. + tools: BTreeMap>, } impl RunningTurn { diff --git a/crates/context_server/src/test.rs b/crates/context_server/src/test.rs index dedf589664..008542ab24 100644 --- a/crates/context_server/src/test.rs +++ b/crates/context_server/src/test.rs @@ -1,6 +1,6 @@ use anyhow::Context as _; use collections::HashMap; -use futures::{Stream, StreamExt as _, lock::Mutex}; +use futures::{FutureExt, Stream, StreamExt as _, future::BoxFuture, lock::Mutex}; use gpui::BackgroundExecutor; use std::{pin::Pin, sync::Arc}; @@ -14,9 +14,12 @@ pub fn create_fake_transport( executor: BackgroundExecutor, ) -> FakeTransport { let name = name.into(); - FakeTransport::new(executor).on_request::(move |_params| { - create_initialize_response(name.clone()) - }) + FakeTransport::new(executor).on_request::( + move |_params| { + let name = name.clone(); + async move { create_initialize_response(name.clone()) } + }, + ) } fn create_initialize_response(server_name: String) -> InitializeResponse { @@ -32,8 +35,10 @@ fn create_initialize_response(server_name: String) -> InitializeResponse { } pub struct FakeTransport { - request_handlers: - HashMap<&'static str, Arc serde_json::Value + Send + Sync>>, + request_handlers: HashMap< + &'static str, + Arc BoxFuture<'static, serde_json::Value>>, + >, tx: futures::channel::mpsc::UnboundedSender, rx: Arc>>, executor: BackgroundExecutor, @@ -50,18 +55,25 @@ impl FakeTransport { } } - pub fn on_request( + pub fn on_request( mut self, - handler: impl Fn(T::Params) -> T::Response + Send + Sync + 'static, - ) -> Self { + handler: impl 'static + Send + Sync + Fn(T::Params) -> Fut, + ) -> Self + where + T: crate::types::Request, + Fut: 'static + Send + Future, + { self.request_handlers.insert( T::METHOD, Arc::new(move |value| { - let params = value.get("params").expect("Missing parameters").clone(); + let params = value + .get("params") + .cloned() + .unwrap_or(serde_json::Value::Null); let params: T::Params = serde_json::from_value(params).expect("Invalid parameters received"); let response = handler(params); - serde_json::to_value(response).unwrap() + async move { serde_json::to_value(response.await).unwrap() }.boxed() }), ); self @@ -77,7 +89,7 @@ impl Transport for FakeTransport { if let Some(method) = msg.get("method") { let method = method.as_str().expect("Invalid method received"); if let Some(handler) = self.request_handlers.get(method) { - let payload = handler(msg); + let payload = handler(msg).await; let response = serde_json::json!({ "jsonrpc": "2.0", "id": id, From c9758465d172b454958e374d1fb599ef6af75cfe Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Fri, 22 Aug 2025 01:12:12 -0400 Subject: [PATCH 43/89] ai: Auto select user model when there's no default (#36722) This PR identifies automatic configuration options that users can select from the agent panel. If no default provider is set in their settings, the PR defaults to the first recommended option. Additionally, it updates the selected provider for a thread when a user changes the default provider through the settings file, if the thread hasn't had any queries yet. Release Notes: - agent: automatically select a language model provider if there's no user set provider. --------- Co-authored-by: Michael Sloan --- crates/agent/src/thread.rs | 17 ++- crates/agent2/src/agent.rs | 4 +- crates/agent2/src/tests/mod.rs | 4 +- .../agent_ui/src/language_model_selector.rs | 55 +-------- crates/git_ui/src/git_panel.rs | 2 +- crates/language_model/src/registry.rs | 114 ++++++++++-------- crates/language_models/Cargo.toml | 1 + crates/language_models/src/language_models.rs | 103 +++++++++++++++- crates/language_models/src/provider/cloud.rs | 6 +- 9 files changed, 184 insertions(+), 122 deletions(-) diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 7b70fde56a..899e360ab0 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -664,7 +664,7 @@ impl Thread { } pub fn get_or_init_configured_model(&mut self, cx: &App) -> Option { - if self.configured_model.is_none() { + if self.configured_model.is_none() || self.messages.is_empty() { self.configured_model = LanguageModelRegistry::read_global(cx).default_model(); } self.configured_model.clone() @@ -2097,7 +2097,7 @@ impl Thread { } pub fn summarize(&mut self, cx: &mut Context) { - let Some(model) = LanguageModelRegistry::read_global(cx).thread_summary_model() else { + let Some(model) = LanguageModelRegistry::read_global(cx).thread_summary_model(cx) else { println!("No thread summary model"); return; }; @@ -2416,7 +2416,7 @@ impl Thread { } let Some(ConfiguredModel { model, provider }) = - LanguageModelRegistry::read_global(cx).thread_summary_model() + LanguageModelRegistry::read_global(cx).thread_summary_model(cx) else { return; }; @@ -5410,13 +5410,10 @@ fn main() {{ }), cx, ); - registry.set_thread_summary_model( - Some(ConfiguredModel { - provider, - model: model.clone(), - }), - cx, - ); + registry.set_thread_summary_model(Some(ConfiguredModel { + provider, + model: model.clone(), + })); }) }); diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index e32e407b3f..4eaf87e218 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -228,7 +228,7 @@ impl NativeAgent { ) -> Entity { let connection = Rc::new(NativeAgentConnection(cx.entity())); let registry = LanguageModelRegistry::read_global(cx); - let summarization_model = registry.thread_summary_model().map(|c| c.model); + let summarization_model = registry.thread_summary_model(cx).map(|c| c.model); thread_handle.update(cx, |thread, cx| { thread.set_summarization_model(summarization_model, cx); @@ -521,7 +521,7 @@ impl NativeAgent { let registry = LanguageModelRegistry::read_global(cx); let default_model = registry.default_model().map(|m| m.model); - let summarization_model = registry.thread_summary_model().map(|m| m.model); + let summarization_model = registry.thread_summary_model(cx).map(|m| m.model); for session in self.sessions.values_mut() { session.thread.update(cx, |thread, cx| { diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index 9ead8b9da3..60b3198081 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -1751,11 +1751,11 @@ async fn test_agent_connection(cx: &mut TestAppContext) { let clock = Arc::new(clock::FakeSystemClock::new()); let client = Client::new(clock, http_client, cx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + Project::init_settings(cx); + agent_settings::init(cx); language_model::init(client.clone(), cx); language_models::init(user_store, client.clone(), cx); - Project::init_settings(cx); LanguageModelRegistry::test(cx); - agent_settings::init(cx); }); cx.executor().forbid_parking(); diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs index 3633e533da..aceca79dbf 100644 --- a/crates/agent_ui/src/language_model_selector.rs +++ b/crates/agent_ui/src/language_model_selector.rs @@ -6,8 +6,7 @@ use feature_flags::ZedProFeatureFlag; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{Action, AnyElement, App, BackgroundExecutor, DismissEvent, Subscription, Task}; use language_model::{ - AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelProviderId, - LanguageModelRegistry, + ConfiguredModel, LanguageModel, LanguageModelProviderId, LanguageModelRegistry, }; use ordered_float::OrderedFloat; use picker::{Picker, PickerDelegate}; @@ -77,7 +76,6 @@ pub struct LanguageModelPickerDelegate { all_models: Arc, filtered_entries: Vec, selected_index: usize, - _authenticate_all_providers_task: Task<()>, _subscriptions: Vec, } @@ -98,7 +96,6 @@ impl LanguageModelPickerDelegate { selected_index: Self::get_active_model_index(&entries, get_active_model(cx)), filtered_entries: entries, get_active_model: Arc::new(get_active_model), - _authenticate_all_providers_task: Self::authenticate_all_providers(cx), _subscriptions: vec![cx.subscribe_in( &LanguageModelRegistry::global(cx), window, @@ -142,56 +139,6 @@ impl LanguageModelPickerDelegate { .unwrap_or(0) } - /// Authenticates all providers in the [`LanguageModelRegistry`]. - /// - /// We do this so that we can populate the language selector with all of the - /// models from the configured providers. - fn authenticate_all_providers(cx: &mut App) -> Task<()> { - let authenticate_all_providers = LanguageModelRegistry::global(cx) - .read(cx) - .providers() - .iter() - .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx))) - .collect::>(); - - cx.spawn(async move |_cx| { - for (provider_id, provider_name, authenticate_task) in authenticate_all_providers { - if let Err(err) = authenticate_task.await { - if matches!(err, AuthenticateError::CredentialsNotFound) { - // Since we're authenticating these providers in the - // background for the purposes of populating the - // language selector, we don't care about providers - // where the credentials are not found. - } else { - // Some providers have noisy failure states that we - // don't want to spam the logs with every time the - // language model selector is initialized. - // - // Ideally these should have more clear failure modes - // that we know are safe to ignore here, like what we do - // with `CredentialsNotFound` above. - match provider_id.0.as_ref() { - "lmstudio" | "ollama" => { - // LM Studio and Ollama both make fetch requests to the local APIs to determine if they are "authenticated". - // - // These fail noisily, so we don't log them. - } - "copilot_chat" => { - // Copilot Chat returns an error if Copilot is not enabled, so we don't log those errors. - } - _ => { - log::error!( - "Failed to authenticate provider: {}: {err}", - provider_name.0 - ); - } - } - } - } - } - }) - } - pub fn active_model(&self, cx: &App) -> Option { (self.get_active_model)(cx) } diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 4ecb4a8829..958a609a09 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -4466,7 +4466,7 @@ fn current_language_model(cx: &Context<'_, GitPanel>) -> Option, - default_fast_model: Option, + /// This model is automatically configured by a user's environment after + /// authenticating all providers. It's only used when default_model is not available. + environment_fallback_model: Option, inline_assistant_model: Option, commit_message_model: Option, thread_summary_model: Option, @@ -104,9 +105,6 @@ impl ConfiguredModel { pub enum Event { DefaultModelChanged, - InlineAssistantModelChanged, - CommitMessageModelChanged, - ThreadSummaryModelChanged, ProviderStateChanged(LanguageModelProviderId), AddedProvider(LanguageModelProviderId), RemovedProvider(LanguageModelProviderId), @@ -238,7 +236,7 @@ impl LanguageModelRegistry { cx: &mut Context, ) { let configured_model = model.and_then(|model| self.select_model(model, cx)); - self.set_inline_assistant_model(configured_model, cx); + self.set_inline_assistant_model(configured_model); } pub fn select_commit_message_model( @@ -247,7 +245,7 @@ impl LanguageModelRegistry { cx: &mut Context, ) { let configured_model = model.and_then(|model| self.select_model(model, cx)); - self.set_commit_message_model(configured_model, cx); + self.set_commit_message_model(configured_model); } pub fn select_thread_summary_model( @@ -256,7 +254,7 @@ impl LanguageModelRegistry { cx: &mut Context, ) { let configured_model = model.and_then(|model| self.select_model(model, cx)); - self.set_thread_summary_model(configured_model, cx); + self.set_thread_summary_model(configured_model); } /// Selects and sets the inline alternatives for language models based on @@ -290,68 +288,60 @@ impl LanguageModelRegistry { } pub fn set_default_model(&mut self, model: Option, cx: &mut Context) { - match (self.default_model.as_ref(), model.as_ref()) { + match (self.default_model(), model.as_ref()) { (Some(old), Some(new)) if old.is_same_as(new) => {} (None, None) => {} _ => cx.emit(Event::DefaultModelChanged), } - self.default_fast_model = maybe!({ - let provider = &model.as_ref()?.provider; - let fast_model = provider.default_fast_model(cx)?; - Some(ConfiguredModel { - provider: provider.clone(), - model: fast_model, - }) - }); self.default_model = model; } - pub fn set_inline_assistant_model( + pub fn set_environment_fallback_model( &mut self, model: Option, cx: &mut Context, ) { - match (self.inline_assistant_model.as_ref(), model.as_ref()) { - (Some(old), Some(new)) if old.is_same_as(new) => {} - (None, None) => {} - _ => cx.emit(Event::InlineAssistantModelChanged), + if self.default_model.is_none() { + match (self.environment_fallback_model.as_ref(), model.as_ref()) { + (Some(old), Some(new)) if old.is_same_as(new) => {} + (None, None) => {} + _ => cx.emit(Event::DefaultModelChanged), + } } + self.environment_fallback_model = model; + } + + pub fn set_inline_assistant_model(&mut self, model: Option) { self.inline_assistant_model = model; } - pub fn set_commit_message_model( - &mut self, - model: Option, - cx: &mut Context, - ) { - match (self.commit_message_model.as_ref(), model.as_ref()) { - (Some(old), Some(new)) if old.is_same_as(new) => {} - (None, None) => {} - _ => cx.emit(Event::CommitMessageModelChanged), - } + pub fn set_commit_message_model(&mut self, model: Option) { self.commit_message_model = model; } - pub fn set_thread_summary_model( - &mut self, - model: Option, - cx: &mut Context, - ) { - match (self.thread_summary_model.as_ref(), model.as_ref()) { - (Some(old), Some(new)) if old.is_same_as(new) => {} - (None, None) => {} - _ => cx.emit(Event::ThreadSummaryModelChanged), - } + pub fn set_thread_summary_model(&mut self, model: Option) { self.thread_summary_model = model; } + #[track_caller] pub fn default_model(&self) -> Option { #[cfg(debug_assertions)] if std::env::var("ZED_SIMULATE_NO_LLM_PROVIDER").is_ok() { return None; } - self.default_model.clone() + self.default_model + .clone() + .or_else(|| self.environment_fallback_model.clone()) + } + + pub fn default_fast_model(&self, cx: &App) -> Option { + let provider = self.default_model()?.provider; + let fast_model = provider.default_fast_model(cx)?; + Some(ConfiguredModel { + provider, + model: fast_model, + }) } pub fn inline_assistant_model(&self) -> Option { @@ -365,7 +355,7 @@ impl LanguageModelRegistry { .or_else(|| self.default_model.clone()) } - pub fn commit_message_model(&self) -> Option { + pub fn commit_message_model(&self, cx: &App) -> Option { #[cfg(debug_assertions)] if std::env::var("ZED_SIMULATE_NO_LLM_PROVIDER").is_ok() { return None; @@ -373,11 +363,11 @@ impl LanguageModelRegistry { self.commit_message_model .clone() - .or_else(|| self.default_fast_model.clone()) + .or_else(|| self.default_fast_model(cx)) .or_else(|| self.default_model.clone()) } - pub fn thread_summary_model(&self) -> Option { + pub fn thread_summary_model(&self, cx: &App) -> Option { #[cfg(debug_assertions)] if std::env::var("ZED_SIMULATE_NO_LLM_PROVIDER").is_ok() { return None; @@ -385,7 +375,7 @@ impl LanguageModelRegistry { self.thread_summary_model .clone() - .or_else(|| self.default_fast_model.clone()) + .or_else(|| self.default_fast_model(cx)) .or_else(|| self.default_model.clone()) } @@ -422,4 +412,34 @@ mod tests { let providers = registry.read(cx).providers(); assert!(providers.is_empty()); } + + #[gpui::test] + async fn test_configure_environment_fallback_model(cx: &mut gpui::TestAppContext) { + let registry = cx.new(|_| LanguageModelRegistry::default()); + + let provider = FakeLanguageModelProvider::default(); + registry.update(cx, |registry, cx| { + registry.register_provider(provider.clone(), cx); + }); + + cx.update(|cx| provider.authenticate(cx)).await.unwrap(); + + registry.update(cx, |registry, cx| { + let provider = registry.provider(&provider.id()).unwrap(); + + registry.set_environment_fallback_model( + Some(ConfiguredModel { + provider: provider.clone(), + model: provider.default_model(cx).unwrap(), + }), + cx, + ); + + let default_model = registry.default_model().unwrap(); + let fallback_model = registry.environment_fallback_model.clone().unwrap(); + + assert_eq!(default_model.model.id(), fallback_model.model.id()); + assert_eq!(default_model.provider.id(), fallback_model.provider.id()); + }); + } } diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index b5bfb870f6..cd41478668 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -44,6 +44,7 @@ ollama = { workspace = true, features = ["schemars"] } open_ai = { workspace = true, features = ["schemars"] } open_router = { workspace = true, features = ["schemars"] } partial-json-fixer.workspace = true +project.workspace = true release_channel.workspace = true schemars.workspace = true serde.workspace = true diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index 738b72b0c9..beed306e74 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -3,8 +3,12 @@ use std::sync::Arc; use ::settings::{Settings, SettingsStore}; use client::{Client, UserStore}; use collections::HashSet; -use gpui::{App, Context, Entity}; -use language_model::{LanguageModelProviderId, LanguageModelRegistry}; +use futures::future; +use gpui::{App, AppContext as _, Context, Entity}; +use language_model::{ + AuthenticateError, ConfiguredModel, LanguageModelProviderId, LanguageModelRegistry, +}; +use project::DisableAiSettings; use provider::deepseek::DeepSeekLanguageModelProvider; pub mod provider; @@ -13,7 +17,7 @@ pub mod ui; use crate::provider::anthropic::AnthropicLanguageModelProvider; use crate::provider::bedrock::BedrockLanguageModelProvider; -use crate::provider::cloud::CloudLanguageModelProvider; +use crate::provider::cloud::{self, CloudLanguageModelProvider}; use crate::provider::copilot_chat::CopilotChatLanguageModelProvider; use crate::provider::google::GoogleLanguageModelProvider; use crate::provider::lmstudio::LmStudioLanguageModelProvider; @@ -48,6 +52,13 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { cx, ); }); + + let mut already_authenticated = false; + if !DisableAiSettings::get_global(cx).disable_ai { + authenticate_all_providers(registry.clone(), cx); + already_authenticated = true; + } + cx.observe_global::(move |cx| { let openai_compatible_providers_new = AllLanguageModelSettings::get_global(cx) .openai_compatible @@ -65,6 +76,12 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { ); }); openai_compatible_providers = openai_compatible_providers_new; + already_authenticated = false; + } + + if !DisableAiSettings::get_global(cx).disable_ai && !already_authenticated { + authenticate_all_providers(registry.clone(), cx); + already_authenticated = true; } }) .detach(); @@ -151,3 +168,83 @@ fn register_language_model_providers( registry.register_provider(XAiLanguageModelProvider::new(client.http_client(), cx), cx); registry.register_provider(CopilotChatLanguageModelProvider::new(cx), cx); } + +/// Authenticates all providers in the [`LanguageModelRegistry`]. +/// +/// We do this so that we can populate the language selector with all of the +/// models from the configured providers. +/// +/// This function won't do anything if AI is disabled. +fn authenticate_all_providers(registry: Entity, cx: &mut App) { + let providers_to_authenticate = registry + .read(cx) + .providers() + .iter() + .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx))) + .collect::>(); + + let mut tasks = Vec::with_capacity(providers_to_authenticate.len()); + + for (provider_id, provider_name, authenticate_task) in providers_to_authenticate { + tasks.push(cx.background_spawn(async move { + if let Err(err) = authenticate_task.await { + if matches!(err, AuthenticateError::CredentialsNotFound) { + // Since we're authenticating these providers in the + // background for the purposes of populating the + // language selector, we don't care about providers + // where the credentials are not found. + } else { + // Some providers have noisy failure states that we + // don't want to spam the logs with every time the + // language model selector is initialized. + // + // Ideally these should have more clear failure modes + // that we know are safe to ignore here, like what we do + // with `CredentialsNotFound` above. + match provider_id.0.as_ref() { + "lmstudio" | "ollama" => { + // LM Studio and Ollama both make fetch requests to the local APIs to determine if they are "authenticated". + // + // These fail noisily, so we don't log them. + } + "copilot_chat" => { + // Copilot Chat returns an error if Copilot is not enabled, so we don't log those errors. + } + _ => { + log::error!( + "Failed to authenticate provider: {}: {err}", + provider_name.0 + ); + } + } + } + } + })); + } + + let all_authenticated_future = future::join_all(tasks); + + cx.spawn(async move |cx| { + all_authenticated_future.await; + + registry + .update(cx, |registry, cx| { + let cloud_provider = registry.provider(&cloud::PROVIDER_ID); + let fallback_model = cloud_provider + .iter() + .chain(registry.providers().iter()) + .find(|provider| provider.is_authenticated(cx)) + .and_then(|provider| { + Some(ConfiguredModel { + provider: provider.clone(), + model: provider + .default_model(cx) + .or_else(|| provider.recommended_models(cx).first().cloned())?, + }) + }); + registry.set_environment_fallback_model(fallback_model, cx); + }) + .ok(); + }) + .detach(); +} diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index b1b5ff3eb3..8e4b786935 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -44,8 +44,8 @@ use crate::provider::anthropic::{AnthropicEventMapper, count_anthropic_tokens, i use crate::provider::google::{GoogleEventMapper, into_google}; use crate::provider::open_ai::{OpenAiEventMapper, count_open_ai_tokens, into_open_ai}; -const PROVIDER_ID: LanguageModelProviderId = language_model::ZED_CLOUD_PROVIDER_ID; -const PROVIDER_NAME: LanguageModelProviderName = language_model::ZED_CLOUD_PROVIDER_NAME; +pub const PROVIDER_ID: LanguageModelProviderId = language_model::ZED_CLOUD_PROVIDER_ID; +pub const PROVIDER_NAME: LanguageModelProviderName = language_model::ZED_CLOUD_PROVIDER_NAME; #[derive(Default, Clone, Debug, PartialEq)] pub struct ZedDotDevSettings { @@ -148,7 +148,7 @@ impl State { default_fast_model: None, recommended_models: Vec::new(), _fetch_models_task: cx.spawn(async move |this, cx| { - maybe!(async move { + maybe!(async { let (client, llm_api_token) = this .read_with(cx, |this, _cx| (client.clone(), this.llm_api_token.clone()))?; From 6d7add47590cead783dccd49aa5cd337b6a8e375 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 22 Aug 2025 08:28:03 -0300 Subject: [PATCH 44/89] thread view: Inform when editing previous messages is unavailable (#36727) Release Notes: - N/A --- assets/icons/pencil_unavailable.svg | 6 ++ crates/agent_ui/src/acp/thread_view.rs | 97 ++++++++++++------- crates/agent_ui/src/ui.rs | 2 + .../src/ui/unavailable_editing_tooltip.rs | 29 ++++++ crates/icons/src/icons.rs | 1 + 5 files changed, 98 insertions(+), 37 deletions(-) create mode 100644 assets/icons/pencil_unavailable.svg create mode 100644 crates/agent_ui/src/ui/unavailable_editing_tooltip.rs diff --git a/assets/icons/pencil_unavailable.svg b/assets/icons/pencil_unavailable.svg new file mode 100644 index 0000000000..4241d766ac --- /dev/null +++ b/assets/icons/pencil_unavailable.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index dae89b3283..619885144a 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -57,7 +57,9 @@ use crate::agent_diff::AgentDiff; use crate::profile_selector::{ProfileProvider, ProfileSelector}; use crate::ui::preview::UsageCallout; -use crate::ui::{AgentNotification, AgentNotificationEvent, BurnModeTooltip}; +use crate::ui::{ + AgentNotification, AgentNotificationEvent, BurnModeTooltip, UnavailableEditingTooltip, +}; use crate::{ AgentDiffPane, AgentPanel, ContinueThread, ContinueWithBurnMode, ExpandMessageEditor, Follow, KeepAll, OpenAgentDiff, OpenHistory, RejectAll, ToggleBurnMode, ToggleProfileSelector, @@ -1239,6 +1241,8 @@ impl AcpThreadView { None }; + let agent_name = self.agent.name(); + v_flex() .id(("user_message", entry_ix)) .pt_2() @@ -1292,42 +1296,61 @@ impl AcpThreadView { .text_xs() .child(editor.clone().into_any_element()), ) - .when(editing && editor_focus, |this| - this.child( - h_flex() - .absolute() - .top_neg_3p5() - .right_3() - .gap_1() - .rounded_sm() - .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().editor_background) - .overflow_hidden() - .child( - IconButton::new("cancel", IconName::Close) - .icon_color(Color::Error) - .icon_size(IconSize::XSmall) - .on_click(cx.listener(Self::cancel_editing)) - ) - .child( - IconButton::new("regenerate", IconName::Return) - .icon_color(Color::Muted) - .icon_size(IconSize::XSmall) - .tooltip(Tooltip::text( - "Editing will restart the thread from this point." - )) - .on_click(cx.listener({ - let editor = editor.clone(); - move |this, _, window, cx| { - this.regenerate( - entry_ix, &editor, window, cx, - ); - } - })), - ) - ) - ), + .when(editor_focus, |this| { + let base_container = h_flex() + .absolute() + .top_neg_3p5() + .right_3() + .gap_1() + .rounded_sm() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().editor_background) + .overflow_hidden(); + + if message.id.is_some() { + this.child( + base_container + .child( + IconButton::new("cancel", IconName::Close) + .icon_color(Color::Error) + .icon_size(IconSize::XSmall) + .on_click(cx.listener(Self::cancel_editing)) + ) + .child( + IconButton::new("regenerate", IconName::Return) + .icon_color(Color::Muted) + .icon_size(IconSize::XSmall) + .tooltip(Tooltip::text( + "Editing will restart the thread from this point." + )) + .on_click(cx.listener({ + let editor = editor.clone(); + move |this, _, window, cx| { + this.regenerate( + entry_ix, &editor, window, cx, + ); + } + })), + ) + ) + } else { + this.child( + base_container + .border_dashed() + .child( + IconButton::new("editing_unavailable", IconName::PencilUnavailable) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .style(ButtonStyle::Transparent) + .tooltip(move |_window, cx| { + cx.new(|_| UnavailableEditingTooltip::new(agent_name.into())) + .into() + }) + ) + ) + } + }), ) .into_any() } diff --git a/crates/agent_ui/src/ui.rs b/crates/agent_ui/src/ui.rs index e27a224240..ada973cddf 100644 --- a/crates/agent_ui/src/ui.rs +++ b/crates/agent_ui/src/ui.rs @@ -4,9 +4,11 @@ mod context_pill; mod end_trial_upsell; mod onboarding_modal; pub mod preview; +mod unavailable_editing_tooltip; pub use agent_notification::*; pub use burn_mode_tooltip::*; pub use context_pill::*; pub use end_trial_upsell::*; pub use onboarding_modal::*; +pub use unavailable_editing_tooltip::*; diff --git a/crates/agent_ui/src/ui/unavailable_editing_tooltip.rs b/crates/agent_ui/src/ui/unavailable_editing_tooltip.rs new file mode 100644 index 0000000000..78d4c64e0a --- /dev/null +++ b/crates/agent_ui/src/ui/unavailable_editing_tooltip.rs @@ -0,0 +1,29 @@ +use gpui::{Context, IntoElement, Render, Window}; +use ui::{prelude::*, tooltip_container}; + +pub struct UnavailableEditingTooltip { + agent_name: SharedString, +} + +impl UnavailableEditingTooltip { + pub fn new(agent_name: SharedString) -> Self { + Self { agent_name } + } +} + +impl Render for UnavailableEditingTooltip { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + tooltip_container(window, cx, |this, _, _| { + this.child(Label::new("Unavailable Editing")).child( + div().max_w_64().child( + Label::new(format!( + "Editing previous messages is not available for {} yet.", + self.agent_name + )) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + }) + } +} diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 38f02c2206..b5f891713a 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -164,6 +164,7 @@ pub enum IconName { PageDown, PageUp, Pencil, + PencilUnavailable, Person, Pin, PlayOutlined, From 8550b27c4a96ed79febe84b5a2009ba6bfdb26fb Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 22 Aug 2025 09:52:44 -0300 Subject: [PATCH 45/89] thread view: Add more UI improvements (#36750) Release Notes: - N/A --- assets/icons/attach.svg | 3 ++ assets/icons/tool_think.svg | 2 +- crates/agent_servers/src/claude.rs | 2 +- crates/agent_servers/src/gemini.rs | 2 +- crates/agent_ui/src/acp/thread_view.rs | 68 +++++++++----------------- crates/agent_ui/src/agent_panel.rs | 5 ++ crates/icons/src/icons.rs | 1 + 7 files changed, 36 insertions(+), 47 deletions(-) create mode 100644 assets/icons/attach.svg diff --git a/assets/icons/attach.svg b/assets/icons/attach.svg new file mode 100644 index 0000000000..f923a3c7c8 --- /dev/null +++ b/assets/icons/attach.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/tool_think.svg b/assets/icons/tool_think.svg index efd5908a90..773f5e7fa7 100644 --- a/assets/icons/tool_think.svg +++ b/assets/icons/tool_think.svg @@ -1,3 +1,3 @@ - + diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index d6ccabb130..ef666974f1 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -44,7 +44,7 @@ pub struct ClaudeCode; impl AgentServer for ClaudeCode { fn name(&self) -> &'static str { - "Welcome to Claude Code" + "Claude Code" } fn empty_state_headline(&self) -> &'static str { diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index 3b892e7931..29120fff6e 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -23,7 +23,7 @@ impl AgentServer for Gemini { } fn empty_state_headline(&self) -> &'static str { - "Welcome to Gemini CLI" + self.name() } fn empty_state_message(&self) -> &'static str { diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 619885144a..d27dee1fe6 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -1697,7 +1697,7 @@ impl AcpThreadView { .absolute() .top_0() .right_0() - .w_12() + .w_16() .h_full() .bg(linear_gradient( 90., @@ -1837,6 +1837,7 @@ impl AcpThreadView { .w_full() .max_w_full() .ml_1p5() + .overflow_hidden() .child(h_flex().pr_8().child(self.render_markdown( tool_call.label.clone(), default_markdown_style(false, true, window, cx), @@ -1906,13 +1907,10 @@ impl AcpThreadView { .text_color(cx.theme().colors().text_muted) .child(self.render_markdown(markdown, default_markdown_style(false, false, window, cx))) .child( - Button::new(button_id, "Collapse") + IconButton::new(button_id, IconName::ChevronUp) .full_width() .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .icon(IconName::ChevronUp) .icon_color(Color::Muted) - .icon_position(IconPosition::Start) .on_click(cx.listener({ move |this: &mut Self, _, _, cx: &mut Context| { this.expanded_tool_calls.remove(&tool_call_id); @@ -2414,39 +2412,32 @@ impl AcpThreadView { return None; } + let has_both = user_rules_text.is_some() && rules_file_text.is_some(); + Some( - v_flex() + h_flex() .px_2p5() - .gap_1() + .pb_1() + .child( + Icon::new(IconName::Attach) + .size(IconSize::XSmall) + .color(Color::Disabled), + ) .when_some(user_rules_text, |parent, user_rules_text| { parent.child( h_flex() - .group("user-rules") .id("user-rules") - .w_full() - .child( - Icon::new(IconName::Reader) - .size(IconSize::XSmall) - .color(Color::Disabled), - ) + .ml_1() + .mr_1p5() .child( Label::new(user_rules_text) .size(LabelSize::XSmall) .color(Color::Muted) .truncate() - .buffer_font(cx) - .ml_1p5() - .mr_0p5(), - ) - .child( - IconButton::new("open-prompt-library", IconName::ArrowUpRight) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::XSmall) - .icon_color(Color::Ignored) - .visible_on_hover("user-rules") - // TODO: Figure out a way to pass focus handle here so we can display the `OpenRulesLibrary` keybinding - .tooltip(Tooltip::text("View User Rules")), + .buffer_font(cx), ) + .hover(|s| s.bg(cx.theme().colors().element_hover)) + .tooltip(Tooltip::text("View User Rules")) .on_click(move |_event, window, cx| { window.dispatch_action( Box::new(OpenRulesLibrary { @@ -2457,33 +2448,20 @@ impl AcpThreadView { }), ) }) + .when(has_both, |this| this.child(Divider::vertical())) .when_some(rules_file_text, |parent, rules_file_text| { parent.child( h_flex() - .group("project-rules") .id("project-rules") - .w_full() - .child( - Icon::new(IconName::Reader) - .size(IconSize::XSmall) - .color(Color::Disabled), - ) + .ml_1p5() .child( Label::new(rules_file_text) .size(LabelSize::XSmall) .color(Color::Muted) - .buffer_font(cx) - .ml_1p5() - .mr_0p5(), - ) - .child( - IconButton::new("open-rule", IconName::ArrowUpRight) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::XSmall) - .icon_color(Color::Ignored) - .visible_on_hover("project-rules") - .tooltip(Tooltip::text("View Project Rules")), + .buffer_font(cx), ) + .hover(|s| s.bg(cx.theme().colors().element_hover)) + .tooltip(Tooltip::text("View Project Rules")) .on_click(cx.listener(Self::handle_open_rules)), ) }) @@ -4080,8 +4058,10 @@ impl AcpThreadView { .group("thread-controls-container") .w_full() .mr_1() + .pt_1() .pb_2() .px(RESPONSE_PADDING_X) + .gap_px() .opacity(0.4) .hover(|style| style.opacity(1.)) .flex_wrap() diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index d2ff6aa4f3..469898d10f 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -2041,9 +2041,11 @@ impl AgentPanel { match state { ThreadSummary::Pending => Label::new(ThreadSummary::DEFAULT) .truncate() + .color(Color::Muted) .into_any_element(), ThreadSummary::Generating => Label::new(LOADING_SUMMARY_PLACEHOLDER) .truncate() + .color(Color::Muted) .into_any_element(), ThreadSummary::Ready(_) => div() .w_full() @@ -2098,6 +2100,7 @@ impl AgentPanel { .into_any_element() } else { Label::new(thread_view.read(cx).title(cx)) + .color(Color::Muted) .truncate() .into_any_element() } @@ -2111,6 +2114,7 @@ impl AgentPanel { match summary { ContextSummary::Pending => Label::new(ContextSummary::DEFAULT) + .color(Color::Muted) .truncate() .into_any_element(), ContextSummary::Content(summary) => { @@ -2122,6 +2126,7 @@ impl AgentPanel { } else { Label::new(LOADING_SUMMARY_PLACEHOLDER) .truncate() + .color(Color::Muted) .into_any_element() } } diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index b5f891713a..4fc6039fd7 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -34,6 +34,7 @@ pub enum IconName { ArrowRightLeft, ArrowUp, ArrowUpRight, + Attach, AudioOff, AudioOn, Backspace, From e96612f0ff0da314c142b2606f70260e8893a94b Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Fri, 22 Aug 2025 19:03:47 +0300 Subject: [PATCH 46/89] themes: Implement Bright Black and Bright White colors (#36761) Before: image After: image Release Notes: - Fixed ANSI Bright Black and Bright White colors --- assets/themes/ayu/ayu.json | 6 +++--- assets/themes/gruvbox/gruvbox.json | 12 ++++++------ assets/themes/one/one.json | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/assets/themes/ayu/ayu.json b/assets/themes/ayu/ayu.json index f9f8720729..0ffbb9f61e 100644 --- a/assets/themes/ayu/ayu.json +++ b/assets/themes/ayu/ayu.json @@ -93,7 +93,7 @@ "terminal.ansi.bright_cyan": "#4c806fff", "terminal.ansi.dim_cyan": "#cbf2e4ff", "terminal.ansi.white": "#bfbdb6ff", - "terminal.ansi.bright_white": "#bfbdb6ff", + "terminal.ansi.bright_white": "#fafafaff", "terminal.ansi.dim_white": "#787876ff", "link_text.hover": "#5ac1feff", "conflict": "#feb454ff", @@ -479,7 +479,7 @@ "terminal.ansi.bright_cyan": "#ace0cbff", "terminal.ansi.dim_cyan": "#2a5f4aff", "terminal.ansi.white": "#fcfcfcff", - "terminal.ansi.bright_white": "#fcfcfcff", + "terminal.ansi.bright_white": "#ffffffff", "terminal.ansi.dim_white": "#bcbec0ff", "link_text.hover": "#3b9ee5ff", "conflict": "#f1ad49ff", @@ -865,7 +865,7 @@ "terminal.ansi.bright_cyan": "#4c806fff", "terminal.ansi.dim_cyan": "#cbf2e4ff", "terminal.ansi.white": "#cccac2ff", - "terminal.ansi.bright_white": "#cccac2ff", + "terminal.ansi.bright_white": "#fafafaff", "terminal.ansi.dim_white": "#898a8aff", "link_text.hover": "#72cffeff", "conflict": "#fecf72ff", diff --git a/assets/themes/gruvbox/gruvbox.json b/assets/themes/gruvbox/gruvbox.json index 459825c733..f0f0358b76 100644 --- a/assets/themes/gruvbox/gruvbox.json +++ b/assets/themes/gruvbox/gruvbox.json @@ -94,7 +94,7 @@ "terminal.ansi.bright_cyan": "#45603eff", "terminal.ansi.dim_cyan": "#c7dfbdff", "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#fbf1c7ff", + "terminal.ansi.bright_white": "#ffffffff", "terminal.ansi.dim_white": "#b0a189ff", "link_text.hover": "#83a598ff", "version_control.added": "#b7bb26ff", @@ -494,7 +494,7 @@ "terminal.ansi.bright_cyan": "#45603eff", "terminal.ansi.dim_cyan": "#c7dfbdff", "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#fbf1c7ff", + "terminal.ansi.bright_white": "#ffffffff", "terminal.ansi.dim_white": "#b0a189ff", "link_text.hover": "#83a598ff", "version_control.added": "#b7bb26ff", @@ -894,7 +894,7 @@ "terminal.ansi.bright_cyan": "#45603eff", "terminal.ansi.dim_cyan": "#c7dfbdff", "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#fbf1c7ff", + "terminal.ansi.bright_white": "#ffffffff", "terminal.ansi.dim_white": "#b0a189ff", "link_text.hover": "#83a598ff", "version_control.added": "#b7bb26ff", @@ -1294,7 +1294,7 @@ "terminal.ansi.bright_cyan": "#9fbca8ff", "terminal.ansi.dim_cyan": "#253e2eff", "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#fbf1c7ff", + "terminal.ansi.bright_white": "#ffffffff", "terminal.ansi.dim_white": "#b0a189ff", "link_text.hover": "#0b6678ff", "version_control.added": "#797410ff", @@ -1694,7 +1694,7 @@ "terminal.ansi.bright_cyan": "#9fbca8ff", "terminal.ansi.dim_cyan": "#253e2eff", "terminal.ansi.white": "#f9f5d7ff", - "terminal.ansi.bright_white": "#f9f5d7ff", + "terminal.ansi.bright_white": "#ffffffff", "terminal.ansi.dim_white": "#b0a189ff", "link_text.hover": "#0b6678ff", "version_control.added": "#797410ff", @@ -2094,7 +2094,7 @@ "terminal.ansi.bright_cyan": "#9fbca8ff", "terminal.ansi.dim_cyan": "#253e2eff", "terminal.ansi.white": "#f2e5bcff", - "terminal.ansi.bright_white": "#f2e5bcff", + "terminal.ansi.bright_white": "#ffffffff", "terminal.ansi.dim_white": "#b0a189ff", "link_text.hover": "#0b6678ff", "version_control.added": "#797410ff", diff --git a/assets/themes/one/one.json b/assets/themes/one/one.json index 23ebbcc67e..33f6d3c622 100644 --- a/assets/themes/one/one.json +++ b/assets/themes/one/one.json @@ -93,7 +93,7 @@ "terminal.ansi.bright_cyan": "#3a565bff", "terminal.ansi.dim_cyan": "#b9d9dfff", "terminal.ansi.white": "#dce0e5ff", - "terminal.ansi.bright_white": "#dce0e5ff", + "terminal.ansi.bright_white": "#fafafaff", "terminal.ansi.dim_white": "#575d65ff", "link_text.hover": "#74ade8ff", "version_control.added": "#27a657ff", @@ -468,7 +468,7 @@ "terminal.bright_foreground": "#242529ff", "terminal.dim_foreground": "#fafafaff", "terminal.ansi.black": "#242529ff", - "terminal.ansi.bright_black": "#242529ff", + "terminal.ansi.bright_black": "#747579ff", "terminal.ansi.dim_black": "#97979aff", "terminal.ansi.red": "#d36151ff", "terminal.ansi.bright_red": "#f0b0a4ff", @@ -489,7 +489,7 @@ "terminal.ansi.bright_cyan": "#a3bedaff", "terminal.ansi.dim_cyan": "#254058ff", "terminal.ansi.white": "#fafafaff", - "terminal.ansi.bright_white": "#fafafaff", + "terminal.ansi.bright_white": "#ffffffff", "terminal.ansi.dim_white": "#aaaaaaff", "link_text.hover": "#5c78e2ff", "version_control.added": "#27a657ff", From 399d059b5f4407f9ead041eb2a6b7d85332fae53 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:45:47 -0400 Subject: [PATCH 47/89] onboarding: Remove accept AI ToS from within Zed (#36612) Users now accept ToS from Zed's website when they sign in to Zed the first time. So it's no longer possible that a signed in account could not have accepted the ToS. Release Notes: - N/A --------- Co-authored-by: Mikayla Maki --- crates/agent_ui/src/active_thread.rs | 5 - crates/agent_ui/src/agent_configuration.rs | 10 +- crates/agent_ui/src/agent_panel.rs | 15 +- crates/agent_ui/src/message_editor.rs | 7 +- crates/agent_ui/src/text_thread_editor.rs | 16 -- crates/ai_onboarding/src/ai_onboarding.rs | 77 +------ crates/client/src/user.rs | 44 +--- .../cloud_api_client/src/cloud_api_client.rs | 28 --- crates/edit_prediction/src/edit_prediction.rs | 8 - .../src/edit_prediction_button.rs | 8 +- crates/editor/src/editor.rs | 41 ---- crates/language_model/src/language_model.rs | 12 +- crates/language_model/src/registry.rs | 12 - crates/language_models/src/provider/cloud.rs | 213 ++---------------- .../zed/src/zed/edit_prediction_registry.rs | 25 -- crates/zeta/src/zeta.rs | 22 +- 16 files changed, 44 insertions(+), 499 deletions(-) diff --git a/crates/agent_ui/src/active_thread.rs b/crates/agent_ui/src/active_thread.rs index 2cad913295..e0cecad6e2 100644 --- a/crates/agent_ui/src/active_thread.rs +++ b/crates/agent_ui/src/active_thread.rs @@ -1595,11 +1595,6 @@ impl ActiveThread { return; }; - if model.provider.must_accept_terms(cx) { - cx.notify(); - return; - } - let edited_text = state.editor.read(cx).text(cx); let creases = state.editor.update(cx, extract_message_creases); diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 6da84758ee..6ed353b31a 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -93,14 +93,6 @@ impl AgentConfiguration { let scroll_handle = ScrollHandle::new(); let scrollbar_state = ScrollbarState::new(scroll_handle.clone()); - let mut expanded_provider_configurations = HashMap::default(); - if LanguageModelRegistry::read_global(cx) - .provider(&ZED_CLOUD_PROVIDER_ID) - .is_some_and(|cloud_provider| cloud_provider.must_accept_terms(cx)) - { - expanded_provider_configurations.insert(ZED_CLOUD_PROVIDER_ID, true); - } - let mut this = Self { fs, language_registry, @@ -109,7 +101,7 @@ impl AgentConfiguration { configuration_views_by_provider: HashMap::default(), context_server_store, expanded_context_server_tools: HashMap::default(), - expanded_provider_configurations, + expanded_provider_configurations: HashMap::default(), tools, _registry_subscription: registry_subscription, scroll_handle, diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 469898d10f..d0fb676fd2 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -54,9 +54,7 @@ use gpui::{ Pixels, Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between, }; use language::LanguageRegistry; -use language_model::{ - ConfigurationError, ConfiguredModel, LanguageModelProviderTosView, LanguageModelRegistry, -}; +use language_model::{ConfigurationError, ConfiguredModel, LanguageModelRegistry}; use project::{DisableAiSettings, Project, ProjectPath, Worktree}; use prompt_store::{PromptBuilder, PromptStore, UserPromptId}; use rules_library::{RulesLibrary, open_rules_library}; @@ -3203,17 +3201,6 @@ impl AgentPanel { ConfigurationError::ModelNotFound | ConfigurationError::ProviderNotAuthenticated(_) | ConfigurationError::NoProvider => callout.into_any_element(), - ConfigurationError::ProviderPendingTermsAcceptance(provider) => { - Banner::new() - .severity(Severity::Warning) - .child(h_flex().w_full().children( - provider.render_accept_terms( - LanguageModelProviderTosView::ThreadEmptyState, - cx, - ), - )) - .into_any_element() - } } } diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index bed10e90a7..45e7529ec2 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -378,18 +378,13 @@ impl MessageEditor { } fn send_to_model(&mut self, window: &mut Window, cx: &mut Context) { - let Some(ConfiguredModel { model, provider }) = self + let Some(ConfiguredModel { model, .. }) = self .thread .update(cx, |thread, cx| thread.get_or_init_configured_model(cx)) else { return; }; - if provider.must_accept_terms(cx) { - cx.notify(); - return; - } - let (user_message, user_message_creases) = self.editor.update(cx, |editor, cx| { let creases = extract_message_creases(editor, cx); let text = editor.text(cx); diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs index 9fbd90c4a6..edb672a872 100644 --- a/crates/agent_ui/src/text_thread_editor.rs +++ b/crates/agent_ui/src/text_thread_editor.rs @@ -190,7 +190,6 @@ pub struct TextThreadEditor { invoked_slash_command_creases: HashMap, _subscriptions: Vec, last_error: Option, - show_accept_terms: bool, pub(crate) slash_menu_handle: PopoverMenuHandle>, // dragged_file_worktrees is used to keep references to worktrees that were added @@ -289,7 +288,6 @@ impl TextThreadEditor { invoked_slash_command_creases: HashMap::default(), _subscriptions, last_error: None, - show_accept_terms: false, slash_menu_handle: Default::default(), dragged_file_worktrees: Vec::new(), language_model_selector: cx.new(|cx| { @@ -367,20 +365,7 @@ impl TextThreadEditor { } fn send_to_model(&mut self, window: &mut Window, cx: &mut Context) { - let provider = LanguageModelRegistry::read_global(cx) - .default_model() - .map(|default| default.provider); - if provider - .as_ref() - .is_some_and(|provider| provider.must_accept_terms(cx)) - { - self.show_accept_terms = true; - cx.notify(); - return; - } - self.last_error = None; - if let Some(user_message) = self.context.update(cx, |context, cx| context.assist(cx)) { let new_selection = { let cursor = user_message @@ -1930,7 +1915,6 @@ impl TextThreadEditor { ConfigurationError::NoProvider | ConfigurationError::ModelNotFound | ConfigurationError::ProviderNotAuthenticated(_) => true, - ConfigurationError::ProviderPendingTermsAcceptance(_) => self.show_accept_terms, } } diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs index 717abebfd1..6d8ac64725 100644 --- a/crates/ai_onboarding/src/ai_onboarding.rs +++ b/crates/ai_onboarding/src/ai_onboarding.rs @@ -19,7 +19,7 @@ use std::sync::Arc; use client::{Client, UserStore, zed_urls}; use gpui::{AnyElement, Entity, IntoElement, ParentElement}; -use ui::{Divider, RegisterComponent, TintColor, Tooltip, prelude::*}; +use ui::{Divider, RegisterComponent, Tooltip, prelude::*}; #[derive(PartialEq)] pub enum SignInStatus { @@ -43,12 +43,10 @@ impl From for SignInStatus { #[derive(RegisterComponent, IntoElement)] pub struct ZedAiOnboarding { pub sign_in_status: SignInStatus, - pub has_accepted_terms_of_service: bool, pub plan: Option, pub account_too_young: bool, pub continue_with_zed_ai: Arc, pub sign_in: Arc, - pub accept_terms_of_service: Arc, pub dismiss_onboarding: Option>, } @@ -64,17 +62,9 @@ impl ZedAiOnboarding { Self { sign_in_status: status.into(), - has_accepted_terms_of_service: store.has_accepted_terms_of_service(), plan: store.plan(), account_too_young: store.account_too_young(), continue_with_zed_ai, - accept_terms_of_service: Arc::new({ - let store = user_store.clone(); - move |_window, cx| { - let task = store.update(cx, |store, cx| store.accept_terms_of_service(cx)); - task.detach_and_log_err(cx); - } - }), sign_in: Arc::new(move |_window, cx| { cx.spawn({ let client = client.clone(); @@ -94,42 +84,6 @@ impl ZedAiOnboarding { self } - fn render_accept_terms_of_service(&self) -> AnyElement { - v_flex() - .gap_1() - .w_full() - .child(Headline::new("Accept Terms of Service")) - .child( - Label::new("We don’t sell your data, track you across the web, or compromise your privacy.") - .color(Color::Muted) - .mb_2(), - ) - .child( - Button::new("terms_of_service", "Review Terms of Service") - .full_width() - .style(ButtonStyle::Outlined) - .icon(IconName::ArrowUpRight) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .on_click(move |_, _window, cx| { - telemetry::event!("Review Terms of Service Clicked"); - cx.open_url(&zed_urls::terms_of_service(cx)) - }), - ) - .child( - Button::new("accept_terms", "Accept") - .full_width() - .style(ButtonStyle::Tinted(TintColor::Accent)) - .on_click({ - let callback = self.accept_terms_of_service.clone(); - move |_, window, cx| { - telemetry::event!("Terms of Service Accepted"); - (callback)(window, cx)} - }), - ) - .into_any_element() - } - fn render_sign_in_disclaimer(&self, _cx: &mut App) -> AnyElement { let signing_in = matches!(self.sign_in_status, SignInStatus::SigningIn); let plan_definitions = PlanDefinitions; @@ -359,14 +313,10 @@ impl ZedAiOnboarding { impl RenderOnce for ZedAiOnboarding { fn render(self, _window: &mut ui::Window, cx: &mut App) -> impl IntoElement { if matches!(self.sign_in_status, SignInStatus::SignedIn) { - if self.has_accepted_terms_of_service { - match self.plan { - None | Some(Plan::ZedFree) => self.render_free_plan_state(cx), - Some(Plan::ZedProTrial) => self.render_trial_state(cx), - Some(Plan::ZedPro) => self.render_pro_plan_state(cx), - } - } else { - self.render_accept_terms_of_service() + match self.plan { + None | Some(Plan::ZedFree) => self.render_free_plan_state(cx), + Some(Plan::ZedProTrial) => self.render_trial_state(cx), + Some(Plan::ZedPro) => self.render_pro_plan_state(cx), } } else { self.render_sign_in_disclaimer(cx) @@ -390,18 +340,15 @@ impl Component for ZedAiOnboarding { fn preview(_window: &mut Window, _cx: &mut App) -> Option { fn onboarding( sign_in_status: SignInStatus, - has_accepted_terms_of_service: bool, plan: Option, account_too_young: bool, ) -> AnyElement { ZedAiOnboarding { sign_in_status, - has_accepted_terms_of_service, plan, account_too_young, continue_with_zed_ai: Arc::new(|_, _| {}), sign_in: Arc::new(|_, _| {}), - accept_terms_of_service: Arc::new(|_, _| {}), dismiss_onboarding: None, } .into_any_element() @@ -415,27 +362,23 @@ impl Component for ZedAiOnboarding { .children(vec![ single_example( "Not Signed-in", - onboarding(SignInStatus::SignedOut, false, None, false), - ), - single_example( - "Not Accepted ToS", - onboarding(SignInStatus::SignedIn, false, None, false), + onboarding(SignInStatus::SignedOut, None, false), ), single_example( "Young Account", - onboarding(SignInStatus::SignedIn, true, None, true), + onboarding(SignInStatus::SignedIn, None, true), ), single_example( "Free Plan", - onboarding(SignInStatus::SignedIn, true, Some(Plan::ZedFree), false), + onboarding(SignInStatus::SignedIn, Some(Plan::ZedFree), false), ), single_example( "Pro Trial", - onboarding(SignInStatus::SignedIn, true, Some(Plan::ZedProTrial), false), + onboarding(SignInStatus::SignedIn, Some(Plan::ZedProTrial), false), ), single_example( "Pro Plan", - onboarding(SignInStatus::SignedIn, true, Some(Plan::ZedPro), false), + onboarding(SignInStatus::SignedIn, Some(Plan::ZedPro), false), ), ]) .into_any_element(), diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index 20f99e3944..1f8174dbc3 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -1,5 +1,5 @@ use super::{Client, Status, TypedEnvelope, proto}; -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Context as _, Result}; use chrono::{DateTime, Utc}; use cloud_api_client::websocket_protocol::MessageToClient; use cloud_api_client::{GetAuthenticatedUserResponse, PlanInfo}; @@ -116,7 +116,6 @@ pub struct UserStore { edit_prediction_usage: Option, plan_info: Option, current_user: watch::Receiver>>, - accepted_tos_at: Option>, contacts: Vec>, incoming_contact_requests: Vec>, outgoing_contact_requests: Vec>, @@ -194,7 +193,6 @@ impl UserStore { plan_info: None, model_request_usage: None, edit_prediction_usage: None, - accepted_tos_at: None, contacts: Default::default(), incoming_contact_requests: Default::default(), participant_indices: Default::default(), @@ -271,7 +269,6 @@ impl UserStore { Status::SignedOut => { current_user_tx.send(None).await.ok(); this.update(cx, |this, cx| { - this.accepted_tos_at = None; cx.emit(Event::PrivateUserInfoUpdated); cx.notify(); this.clear_contacts() @@ -791,19 +788,6 @@ impl UserStore { .set_authenticated_user_info(Some(response.user.metrics_id.clone()), staff); } - let accepted_tos_at = { - #[cfg(debug_assertions)] - if std::env::var("ZED_IGNORE_ACCEPTED_TOS").is_ok() { - None - } else { - response.user.accepted_tos_at - } - - #[cfg(not(debug_assertions))] - response.user.accepted_tos_at - }; - - self.accepted_tos_at = Some(accepted_tos_at); self.model_request_usage = Some(ModelRequestUsage(RequestUsage { limit: response.plan.usage.model_requests.limit, amount: response.plan.usage.model_requests.used as i32, @@ -846,32 +830,6 @@ impl UserStore { self.current_user.clone() } - pub fn has_accepted_terms_of_service(&self) -> bool { - self.accepted_tos_at - .is_some_and(|accepted_tos_at| accepted_tos_at.is_some()) - } - - pub fn accept_terms_of_service(&self, cx: &Context) -> Task> { - if self.current_user().is_none() { - return Task::ready(Err(anyhow!("no current user"))); - }; - - let client = self.client.clone(); - cx.spawn(async move |this, cx| -> anyhow::Result<()> { - let client = client.upgrade().context("client not found")?; - let response = client - .cloud_client() - .accept_terms_of_service() - .await - .context("error accepting tos")?; - this.update(cx, |this, cx| { - this.accepted_tos_at = Some(response.user.accepted_tos_at); - cx.emit(Event::PrivateUserInfoUpdated); - })?; - Ok(()) - }) - } - fn load_users( &self, request: impl RequestMessage, diff --git a/crates/cloud_api_client/src/cloud_api_client.rs b/crates/cloud_api_client/src/cloud_api_client.rs index 205f3e2432..7fd96fcef0 100644 --- a/crates/cloud_api_client/src/cloud_api_client.rs +++ b/crates/cloud_api_client/src/cloud_api_client.rs @@ -115,34 +115,6 @@ impl CloudApiClient { })) } - pub async fn accept_terms_of_service(&self) -> Result { - let request = self.build_request( - Request::builder().method(Method::POST).uri( - self.http_client - .build_zed_cloud_url("/client/terms_of_service/accept", &[])? - .as_ref(), - ), - AsyncBody::default(), - )?; - - let mut response = self.http_client.send(request).await?; - - if !response.status().is_success() { - let mut body = String::new(); - response.body_mut().read_to_string(&mut body).await?; - - anyhow::bail!( - "Failed to accept terms of service.\nStatus: {:?}\nBody: {body}", - response.status() - ) - } - - let mut body = String::new(); - response.body_mut().read_to_string(&mut body).await?; - - Ok(serde_json::from_str(&body)?) - } - pub async fn create_llm_token( &self, system_id: Option, diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 964f202934..6b695af1ae 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -89,9 +89,6 @@ pub trait EditPredictionProvider: 'static + Sized { debounce: bool, cx: &mut Context, ); - fn needs_terms_acceptance(&self, _cx: &App) -> bool { - false - } fn cycle( &mut self, buffer: Entity, @@ -124,7 +121,6 @@ pub trait EditPredictionProviderHandle { fn data_collection_state(&self, cx: &App) -> DataCollectionState; fn usage(&self, cx: &App) -> Option; fn toggle_data_collection(&self, cx: &mut App); - fn needs_terms_acceptance(&self, cx: &App) -> bool; fn is_refreshing(&self, cx: &App) -> bool; fn refresh( &self, @@ -196,10 +192,6 @@ where self.read(cx).is_enabled(buffer, cursor_position, cx) } - fn needs_terms_acceptance(&self, cx: &App) -> bool { - self.read(cx).needs_terms_acceptance(cx) - } - fn is_refreshing(&self, cx: &App) -> bool { self.read(cx).is_refreshing() } diff --git a/crates/edit_prediction_button/src/edit_prediction_button.rs b/crates/edit_prediction_button/src/edit_prediction_button.rs index 4f69af7ee4..0e3fe8cb1a 100644 --- a/crates/edit_prediction_button/src/edit_prediction_button.rs +++ b/crates/edit_prediction_button/src/edit_prediction_button.rs @@ -242,13 +242,9 @@ impl Render for EditPredictionButton { IconName::ZedPredictDisabled }; - if zeta::should_show_upsell_modal(&self.user_store, cx) { + if zeta::should_show_upsell_modal() { let tooltip_meta = if self.user_store.read(cx).current_user().is_some() { - if self.user_store.read(cx).has_accepted_terms_of_service() { - "Choose a Plan" - } else { - "Accept the Terms of Service" - } + "Choose a Plan" } else { "Sign In" }; diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 4051da190c..6b9a6b0f4a 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -253,7 +253,6 @@ pub type RenderDiffHunkControlsFn = Arc< enum ReportEditorEvent { Saved { auto_saved: bool }, EditorOpened, - ZetaTosClicked, Closed, } @@ -262,7 +261,6 @@ impl ReportEditorEvent { match self { Self::Saved { .. } => "Editor Saved", Self::EditorOpened => "Editor Opened", - Self::ZetaTosClicked => "Edit Prediction Provider ToS Clicked", Self::Closed => "Editor Closed", } } @@ -9115,45 +9113,6 @@ impl Editor { let provider = self.edit_prediction_provider.as_ref()?; let provider_icon = Self::get_prediction_provider_icon_name(&self.edit_prediction_provider); - if provider.provider.needs_terms_acceptance(cx) { - return Some( - h_flex() - .min_w(min_width) - .flex_1() - .px_2() - .py_1() - .gap_3() - .elevation_2(cx) - .hover(|style| style.bg(cx.theme().colors().element_hover)) - .id("accept-terms") - .cursor_pointer() - .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) - .on_click(cx.listener(|this, _event, window, cx| { - cx.stop_propagation(); - this.report_editor_event(ReportEditorEvent::ZetaTosClicked, None, cx); - window.dispatch_action( - zed_actions::OpenZedPredictOnboarding.boxed_clone(), - cx, - ); - })) - .child( - h_flex() - .flex_1() - .gap_2() - .child(Icon::new(provider_icon)) - .child(Label::new("Accept Terms of Service")) - .child(div().w_full()) - .child( - Icon::new(IconName::ArrowUpRight) - .color(Color::Muted) - .size(IconSize::Small), - ) - .into_any_element(), - ) - .into_any(), - ); - } - let is_refreshing = provider.provider.is_refreshing(cx); fn pending_completion_container(icon: IconName) -> Div { diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index 158bebcbbf..e0a3866443 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -14,7 +14,7 @@ use client::Client; use cloud_llm_client::{CompletionMode, CompletionRequestStatus}; use futures::FutureExt; use futures::{StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{AnyElement, AnyView, App, AsyncApp, SharedString, Task, Window}; +use gpui::{AnyView, App, AsyncApp, SharedString, Task, Window}; use http_client::{StatusCode, http}; use icons::IconName; use parking_lot::Mutex; @@ -640,16 +640,6 @@ pub trait LanguageModelProvider: 'static { window: &mut Window, cx: &mut App, ) -> AnyView; - fn must_accept_terms(&self, _cx: &App) -> bool { - false - } - fn render_accept_terms( - &self, - _view: LanguageModelProviderTosView, - _cx: &mut App, - ) -> Option { - None - } fn reset_credentials(&self, cx: &mut App) -> Task>; } diff --git a/crates/language_model/src/registry.rs b/crates/language_model/src/registry.rs index bcbb3404a8..c7693a64c7 100644 --- a/crates/language_model/src/registry.rs +++ b/crates/language_model/src/registry.rs @@ -24,9 +24,6 @@ pub enum ConfigurationError { ModelNotFound, #[error("{} LLM provider is not configured.", .0.name().0)] ProviderNotAuthenticated(Arc), - #[error("Using the {} LLM provider requires accepting the Terms of Service.", - .0.name().0)] - ProviderPendingTermsAcceptance(Arc), } impl std::fmt::Debug for ConfigurationError { @@ -37,9 +34,6 @@ impl std::fmt::Debug for ConfigurationError { Self::ProviderNotAuthenticated(provider) => { write!(f, "ProviderNotAuthenticated({})", provider.id()) } - Self::ProviderPendingTermsAcceptance(provider) => { - write!(f, "ProviderPendingTermsAcceptance({})", provider.id()) - } } } } @@ -198,12 +192,6 @@ impl LanguageModelRegistry { return Some(ConfigurationError::ProviderNotAuthenticated(model.provider)); } - if model.provider.must_accept_terms(cx) { - return Some(ConfigurationError::ProviderPendingTermsAcceptance( - model.provider, - )); - } - None } diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 8e4b786935..fb6e2fb1e4 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -23,9 +23,9 @@ use language_model::{ AuthenticateError, LanguageModel, LanguageModelCacheConfiguration, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, - LanguageModelProviderState, LanguageModelProviderTosView, LanguageModelRequest, - LanguageModelToolChoice, LanguageModelToolSchemaFormat, LlmApiToken, - ModelRequestLimitReachedError, PaymentRequiredError, RateLimiter, RefreshLlmTokenListener, + LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, + LanguageModelToolSchemaFormat, LlmApiToken, ModelRequestLimitReachedError, + PaymentRequiredError, RateLimiter, RefreshLlmTokenListener, }; use release_channel::AppVersion; use schemars::JsonSchema; @@ -118,7 +118,6 @@ pub struct State { llm_api_token: LlmApiToken, user_store: Entity, status: client::Status, - accept_terms_of_service_task: Option>>, models: Vec>, default_model: Option>, default_fast_model: Option>, @@ -142,7 +141,6 @@ impl State { llm_api_token: LlmApiToken::default(), user_store, status, - accept_terms_of_service_task: None, models: Vec::new(), default_model: None, default_fast_model: None, @@ -197,24 +195,6 @@ impl State { state.update(cx, |_, cx| cx.notify()) }) } - - fn has_accepted_terms_of_service(&self, cx: &App) -> bool { - self.user_store.read(cx).has_accepted_terms_of_service() - } - - fn accept_terms_of_service(&mut self, cx: &mut Context) { - let user_store = self.user_store.clone(); - self.accept_terms_of_service_task = Some(cx.spawn(async move |this, cx| { - let _ = user_store - .update(cx, |store, cx| store.accept_terms_of_service(cx))? - .await; - this.update(cx, |this, cx| { - this.accept_terms_of_service_task = None; - cx.notify() - }) - })); - } - fn update_models(&mut self, response: ListModelsResponse, cx: &mut Context) { let mut models = Vec::new(); @@ -384,7 +364,7 @@ impl LanguageModelProvider for CloudLanguageModelProvider { fn is_authenticated(&self, cx: &App) -> bool { let state = self.state.read(cx); - !state.is_signed_out(cx) && state.has_accepted_terms_of_service(cx) + !state.is_signed_out(cx) } fn authenticate(&self, _cx: &mut App) -> Task> { @@ -401,112 +381,11 @@ impl LanguageModelProvider for CloudLanguageModelProvider { .into() } - fn must_accept_terms(&self, cx: &App) -> bool { - !self.state.read(cx).has_accepted_terms_of_service(cx) - } - - fn render_accept_terms( - &self, - view: LanguageModelProviderTosView, - cx: &mut App, - ) -> Option { - let state = self.state.read(cx); - if state.has_accepted_terms_of_service(cx) { - return None; - } - Some( - render_accept_terms(view, state.accept_terms_of_service_task.is_some(), { - let state = self.state.clone(); - move |_window, cx| { - state.update(cx, |state, cx| state.accept_terms_of_service(cx)); - } - }) - .into_any_element(), - ) - } - fn reset_credentials(&self, _cx: &mut App) -> Task> { Task::ready(Ok(())) } } -fn render_accept_terms( - view_kind: LanguageModelProviderTosView, - accept_terms_of_service_in_progress: bool, - accept_terms_callback: impl Fn(&mut Window, &mut App) + 'static, -) -> impl IntoElement { - let thread_fresh_start = matches!(view_kind, LanguageModelProviderTosView::ThreadFreshStart); - let thread_empty_state = matches!(view_kind, LanguageModelProviderTosView::ThreadEmptyState); - - let terms_button = Button::new("terms_of_service", "Terms of Service") - .style(ButtonStyle::Subtle) - .icon(IconName::ArrowUpRight) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .when(thread_empty_state, |this| this.label_size(LabelSize::Small)) - .on_click(move |_, _window, cx| cx.open_url("https://zed.dev/terms-of-service")); - - let button_container = h_flex().child( - Button::new("accept_terms", "I accept the Terms of Service") - .when(!thread_empty_state, |this| { - this.full_width() - .style(ButtonStyle::Tinted(TintColor::Accent)) - .icon(IconName::Check) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - }) - .when(thread_empty_state, |this| { - this.style(ButtonStyle::Tinted(TintColor::Warning)) - .label_size(LabelSize::Small) - }) - .disabled(accept_terms_of_service_in_progress) - .on_click(move |_, window, cx| (accept_terms_callback)(window, cx)), - ); - - if thread_empty_state { - h_flex() - .w_full() - .flex_wrap() - .justify_between() - .child( - h_flex() - .child( - Label::new("To start using Zed AI, please read and accept the") - .size(LabelSize::Small), - ) - .child(terms_button), - ) - .child(button_container) - } else { - v_flex() - .w_full() - .gap_2() - .child( - h_flex() - .flex_wrap() - .when(thread_fresh_start, |this| this.justify_center()) - .child(Label::new( - "To start using Zed AI, please read and accept the", - )) - .child(terms_button), - ) - .child({ - match view_kind { - LanguageModelProviderTosView::TextThreadPopup => { - button_container.w_full().justify_end() - } - LanguageModelProviderTosView::Configuration => { - button_container.w_full().justify_start() - } - LanguageModelProviderTosView::ThreadFreshStart => { - button_container.w_full().justify_center() - } - LanguageModelProviderTosView::ThreadEmptyState => div().w_0(), - } - }) - } -} - pub struct CloudLanguageModel { id: LanguageModelId, model: Arc, @@ -1107,10 +986,7 @@ struct ZedAiConfiguration { plan: Option, subscription_period: Option<(DateTime, DateTime)>, eligible_for_trial: bool, - has_accepted_terms_of_service: bool, account_too_young: bool, - accept_terms_of_service_in_progress: bool, - accept_terms_of_service_callback: Arc, sign_in_callback: Arc, } @@ -1176,58 +1052,30 @@ impl RenderOnce for ZedAiConfiguration { ); } - v_flex() - .gap_2() - .w_full() - .when(!self.has_accepted_terms_of_service, |this| { - this.child(render_accept_terms( - LanguageModelProviderTosView::Configuration, - self.accept_terms_of_service_in_progress, - { - let callback = self.accept_terms_of_service_callback.clone(); - move |window, cx| (callback)(window, cx) - }, - )) - }) - .map(|this| { - if self.has_accepted_terms_of_service && self.account_too_young { - this.child(young_account_banner).child( - Button::new("upgrade", "Upgrade to Pro") - .style(ui::ButtonStyle::Tinted(ui::TintColor::Accent)) - .full_width() - .on_click(|_, _, cx| { - cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx)) - }), - ) - } else if self.has_accepted_terms_of_service { - this.text_sm() - .child(subscription_text) - .child(manage_subscription_buttons) - } else { - this - } - }) - .when(self.has_accepted_terms_of_service, |this| this) + v_flex().gap_2().w_full().map(|this| { + if self.account_too_young { + this.child(young_account_banner).child( + Button::new("upgrade", "Upgrade to Pro") + .style(ui::ButtonStyle::Tinted(ui::TintColor::Accent)) + .full_width() + .on_click(|_, _, cx| cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))), + ) + } else { + this.text_sm() + .child(subscription_text) + .child(manage_subscription_buttons) + } + }) } } struct ConfigurationView { state: Entity, - accept_terms_of_service_callback: Arc, sign_in_callback: Arc, } impl ConfigurationView { fn new(state: Entity) -> Self { - let accept_terms_of_service_callback = Arc::new({ - let state = state.clone(); - move |_window: &mut Window, cx: &mut App| { - state.update(cx, |state, cx| { - state.accept_terms_of_service(cx); - }); - } - }); - let sign_in_callback = Arc::new({ let state = state.clone(); move |_window: &mut Window, cx: &mut App| { @@ -1239,7 +1087,6 @@ impl ConfigurationView { Self { state, - accept_terms_of_service_callback, sign_in_callback, } } @@ -1255,10 +1102,7 @@ impl Render for ConfigurationView { plan: user_store.plan(), subscription_period: user_store.subscription_period(), eligible_for_trial: user_store.trial_started_at().is_none(), - has_accepted_terms_of_service: state.has_accepted_terms_of_service(cx), account_too_young: user_store.account_too_young(), - accept_terms_of_service_in_progress: state.accept_terms_of_service_task.is_some(), - accept_terms_of_service_callback: self.accept_terms_of_service_callback.clone(), sign_in_callback: self.sign_in_callback.clone(), } } @@ -1283,7 +1127,6 @@ impl Component for ZedAiConfiguration { plan: Option, eligible_for_trial: bool, account_too_young: bool, - has_accepted_terms_of_service: bool, ) -> AnyElement { ZedAiConfiguration { is_connected, @@ -1292,10 +1135,7 @@ impl Component for ZedAiConfiguration { .is_some() .then(|| (Utc::now(), Utc::now() + chrono::Duration::days(7))), eligible_for_trial, - has_accepted_terms_of_service, account_too_young, - accept_terms_of_service_in_progress: false, - accept_terms_of_service_callback: Arc::new(|_, _| {}), sign_in_callback: Arc::new(|_, _| {}), } .into_any_element() @@ -1306,33 +1146,30 @@ impl Component for ZedAiConfiguration { .p_4() .gap_4() .children(vec![ - single_example( - "Not connected", - configuration(false, None, false, false, true), - ), + single_example("Not connected", configuration(false, None, false, false)), single_example( "Accept Terms of Service", - configuration(true, None, true, false, false), + configuration(true, None, true, false), ), single_example( "No Plan - Not eligible for trial", - configuration(true, None, false, false, true), + configuration(true, None, false, false), ), single_example( "No Plan - Eligible for trial", - configuration(true, None, true, false, true), + configuration(true, None, true, false), ), single_example( "Free Plan", - configuration(true, Some(Plan::ZedFree), true, false, true), + configuration(true, Some(Plan::ZedFree), true, false), ), single_example( "Zed Pro Trial Plan", - configuration(true, Some(Plan::ZedProTrial), true, false, true), + configuration(true, Some(Plan::ZedProTrial), true, false), ), single_example( "Zed Pro Plan", - configuration(true, Some(Plan::ZedPro), true, false, true), + configuration(true, Some(Plan::ZedPro), true, false), ), ]) .into_any_element(), diff --git a/crates/zed/src/zed/edit_prediction_registry.rs b/crates/zed/src/zed/edit_prediction_registry.rs index a9abd9bc74..bc2d757fd1 100644 --- a/crates/zed/src/zed/edit_prediction_registry.rs +++ b/crates/zed/src/zed/edit_prediction_registry.rs @@ -75,13 +75,10 @@ pub fn init(client: Arc, user_store: Entity, cx: &mut App) { let new_provider = all_language_settings(None, cx).edit_predictions.provider; if new_provider != provider { - let tos_accepted = user_store.read(cx).has_accepted_terms_of_service(); - telemetry::event!( "Edit Prediction Provider Changed", from = provider, to = new_provider, - zed_ai_tos_accepted = tos_accepted, ); provider = new_provider; @@ -92,28 +89,6 @@ pub fn init(client: Arc, user_store: Entity, cx: &mut App) { user_store.clone(), cx, ); - - if !tos_accepted { - match provider { - EditPredictionProvider::Zed => { - let Some(window) = cx.active_window() else { - return; - }; - - window - .update(cx, |_, window, cx| { - window.dispatch_action( - Box::new(zed_actions::OpenZedPredictOnboarding), - cx, - ); - }) - .ok(); - } - EditPredictionProvider::None - | EditPredictionProvider::Copilot - | EditPredictionProvider::Supermaven => {} - } - } } } }) diff --git a/crates/zeta/src/zeta.rs b/crates/zeta/src/zeta.rs index 916699d29b..7b14d12796 100644 --- a/crates/zeta/src/zeta.rs +++ b/crates/zeta/src/zeta.rs @@ -118,12 +118,8 @@ impl Dismissable for ZedPredictUpsell { } } -pub fn should_show_upsell_modal(user_store: &Entity, cx: &App) -> bool { - if user_store.read(cx).has_accepted_terms_of_service() { - !ZedPredictUpsell::dismissed() - } else { - true - } +pub fn should_show_upsell_modal() -> bool { + !ZedPredictUpsell::dismissed() } #[derive(Clone)] @@ -1547,16 +1543,6 @@ impl edit_prediction::EditPredictionProvider for ZetaEditPredictionProvider { ) -> bool { true } - - fn needs_terms_acceptance(&self, cx: &App) -> bool { - !self - .zeta - .read(cx) - .user_store - .read(cx) - .has_accepted_terms_of_service() - } - fn is_refreshing(&self) -> bool { !self.pending_completions.is_empty() } @@ -1569,10 +1555,6 @@ impl edit_prediction::EditPredictionProvider for ZetaEditPredictionProvider { _debounce: bool, cx: &mut Context, ) { - if self.needs_terms_acceptance(cx) { - return; - } - if self.zeta.read(cx).update_required { return; } From f8a8f4f65d319ce4f93b89bf1beefbef12086edf Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Fri, 22 Aug 2025 13:18:25 -0400 Subject: [PATCH 48/89] zed 0.201.3 --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index da24807695..1981028c8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20393,7 +20393,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.201.2" +version = "0.201.3" dependencies = [ "activity_indicator", "agent", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index fa8358444e..5368146d52 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "0.201.2" +version = "0.201.3" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] From 14c599ed5e3a124321f707f6a66c511dd8c9f4a9 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Wed, 20 Aug 2025 20:38:30 -0600 Subject: [PATCH 49/89] Remove style lints for now (#36651) Closes #36577 Release Notes: - N/A --- Cargo.toml | 151 +++++------------------------------------------------ 1 file changed, 13 insertions(+), 138 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d458a4752c..b6104303b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -802,147 +802,26 @@ unexpected_cfgs = { level = "allow" } dbg_macro = "deny" todo = "deny" -# Motivation: We use `vec![a..b]` a lot when dealing with ranges in text, so -# warning on this rule produces a lot of noise. -single_range_in_vec_init = "allow" - -redundant_clone = "warn" -declare_interior_mutable_const = "deny" - -# These are all of the rules that currently have violations in the Zed -# codebase. +# We currently do not restrict any style rules +# as it slows down shipping code to Zed. # -# We'll want to drive this list down by either: -# 1. fixing violations of the rule and begin enforcing it -# 2. deciding we want to allow the rule permanently, at which point -# we should codify that separately above. +# Running ./script/clippy can take several minutes, and so it's +# common to skip that step and let CI do it. Any unexpected failures +# (which also take minutes to discover) thus require switching back +# to an old branch, manual fixing, and re-pushing. # -# This list shouldn't be added to; it should only get shorter. -# ============================================================================= - -# There are a bunch of rules currently failing in the `style` group, so -# allow all of those, for now. +# In the future we could improve this by either making sure +# Zed can surface clippy errors in diagnostics (in addition to the +# rust-analyzer errors), or by having CI fix style nits automatically. style = { level = "allow", priority = -1 } -# Temporary list of style lints that we've fixed so far. -# Progress is being tracked in #36577 -blocks_in_conditions = "warn" -bool_assert_comparison = "warn" -borrow_interior_mutable_const = "warn" -box_default = "warn" -builtin_type_shadow = "warn" -bytes_nth = "warn" -chars_next_cmp = "warn" -cmp_null = "warn" -collapsible_else_if = "warn" -collapsible_if = "warn" -comparison_to_empty = "warn" -default_instead_of_iter_empty = "warn" -disallowed_macros = "warn" -disallowed_methods = "warn" -disallowed_names = "warn" -disallowed_types = "warn" -doc_lazy_continuation = "warn" -doc_overindented_list_items = "warn" -duplicate_underscore_argument = "warn" -err_expect = "warn" -fn_to_numeric_cast = "warn" -fn_to_numeric_cast_with_truncation = "warn" -for_kv_map = "warn" -implicit_saturating_add = "warn" -implicit_saturating_sub = "warn" -inconsistent_digit_grouping = "warn" -infallible_destructuring_match = "warn" -inherent_to_string = "warn" -init_numbered_fields = "warn" -into_iter_on_ref = "warn" -io_other_error = "warn" -items_after_test_module = "warn" -iter_cloned_collect = "warn" -iter_next_slice = "warn" -iter_nth = "warn" -iter_nth_zero = "warn" -iter_skip_next = "warn" -just_underscores_and_digits = "warn" -len_zero = "warn" -let_and_return = "warn" -main_recursion = "warn" -manual_bits = "warn" -manual_dangling_ptr = "warn" -manual_is_ascii_check = "warn" -manual_is_finite = "warn" -manual_is_infinite = "warn" -manual_map = "warn" -manual_next_back = "warn" -manual_non_exhaustive = "warn" -manual_ok_or = "warn" -manual_pattern_char_comparison = "warn" -manual_rotate = "warn" -manual_slice_fill = "warn" -manual_while_let_some = "warn" -map_clone = "warn" -map_collect_result_unit = "warn" -match_like_matches_macro = "warn" -match_overlapping_arm = "warn" -mem_replace_option_with_none = "warn" -mem_replace_option_with_some = "warn" -missing_enforced_import_renames = "warn" -missing_safety_doc = "warn" -mixed_attributes_style = "warn" -mixed_case_hex_literals = "warn" -module_inception = "warn" -must_use_unit = "warn" -mut_mutex_lock = "warn" -needless_borrow = "warn" -needless_doctest_main = "warn" -needless_else = "warn" -needless_parens_on_range_literals = "warn" -needless_pub_self = "warn" -needless_return = "warn" -needless_return_with_question_mark = "warn" -non_minimal_cfg = "warn" -ok_expect = "warn" -owned_cow = "warn" -print_literal = "warn" -print_with_newline = "warn" -println_empty_string = "warn" -ptr_eq = "warn" -question_mark = "warn" -redundant_closure = "warn" -redundant_field_names = "warn" -redundant_pattern_matching = "warn" -redundant_static_lifetimes = "warn" -result_map_or_into_option = "warn" -self_named_constructors = "warn" -single_match = "warn" -tabs_in_doc_comments = "warn" -to_digit_is_some = "warn" -toplevel_ref_arg = "warn" -unnecessary_fold = "warn" -unnecessary_map_or = "warn" -unnecessary_mut_passed = "warn" -unnecessary_owned_empty_strings = "warn" -unneeded_struct_pattern = "warn" -unsafe_removed_from_name = "warn" -unused_unit = "warn" -unusual_byte_groupings = "warn" -while_let_on_iterator = "warn" -write_literal = "warn" -write_with_newline = "warn" -writeln_empty_string = "warn" -wrong_self_convention = "warn" -zero_ptr = "warn" - # Individual rules that have violations in the codebase: type_complexity = "allow" -# We often return trait objects from `new` functions. -new_ret_no_self = { level = "allow" } -# We have a few `next` functions that differ in lifetimes -# compared to Iterator::next. Yet, clippy complains about those. -should_implement_trait = { level = "allow" } let_underscore_future = "allow" -# It doesn't make sense to implement `Default` unilaterally. -new_without_default = "allow" + +# Motivation: We use `vec![a..b]` a lot when dealing with ranges in text, so +# warning on this rule produces a lot of noise. +single_range_in_vec_init = "allow" # in Rust it can be very tedious to reduce argument count without # running afoul of the borrow checker. @@ -951,10 +830,6 @@ too_many_arguments = "allow" # We often have large enum variants yet we rarely actually bother with splitting them up. large_enum_variant = "allow" -# `enum_variant_names` fires for all enums, even when they derive serde traits. -# Adhering to this lint would be a breaking change. -enum_variant_names = "allow" - [workspace.metadata.cargo-machete] ignored = [ "bindgen", From 321f9556679f2d8fbba9756ea04ec793cac19b60 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Fri, 22 Aug 2025 16:32:49 -0300 Subject: [PATCH 50/89] ACP debug tools pane (#36768) Adds a new "acp: open debug tools" action that opens a new workspace item with a log of ACP messages for the active connection. Release Notes: - N/A --- Cargo.lock | 27 +- Cargo.toml | 4 +- crates/acp_tools/Cargo.toml | 30 ++ crates/acp_tools/LICENSE-GPL | 1 + crates/acp_tools/src/acp_tools.rs | 494 +++++++++++++++++++++++++++++ crates/agent_servers/Cargo.toml | 1 + crates/agent_servers/src/acp/v1.rs | 11 +- crates/zed/Cargo.toml | 1 + crates/zed/src/main.rs | 1 + crates/zed/src/zed.rs | 1 + script/squawk | 12 +- tooling/workspace-hack/Cargo.toml | 8 +- 12 files changed, 574 insertions(+), 17 deletions(-) create mode 100644 crates/acp_tools/Cargo.toml create mode 120000 crates/acp_tools/LICENSE-GPL create mode 100644 crates/acp_tools/src/acp_tools.rs diff --git a/Cargo.lock b/Cargo.lock index 1981028c8d..37e58416c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,6 +39,26 @@ dependencies = [ "workspace-hack", ] +[[package]] +name = "acp_tools" +version = "0.1.0" +dependencies = [ + "agent-client-protocol", + "collections", + "gpui", + "language", + "markdown", + "project", + "serde", + "serde_json", + "settings", + "theme", + "ui", + "util", + "workspace", + "workspace-hack", +] + [[package]] name = "action_log" version = "0.1.0" @@ -171,11 +191,12 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "0.0.30" +version = "0.0.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f792e009ba59b137ee1db560bc37e567887ad4b5af6f32181d381fff690e2d4" +checksum = "289eb34ee17213dadcca47eedadd386a5e7678094095414e475965d1bcca2860" dependencies = [ "anyhow", + "async-broadcast", "futures 0.3.31", "log", "parking_lot", @@ -264,6 +285,7 @@ name = "agent_servers" version = "0.1.0" dependencies = [ "acp_thread", + "acp_tools", "action_log", "agent-client-protocol", "agent_settings", @@ -20395,6 +20417,7 @@ dependencies = [ name = "zed" version = "0.201.3" dependencies = [ + "acp_tools", "activity_indicator", "agent", "agent_servers", diff --git a/Cargo.toml b/Cargo.toml index b6104303b7..a645029b43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] resolver = "2" members = [ + "crates/acp_tools", "crates/acp_thread", "crates/action_log", "crates/activity_indicator", @@ -226,6 +227,7 @@ edition = "2024" # Workspace member crates # +acp_tools = { path = "crates/acp_tools" } acp_thread = { path = "crates/acp_thread" } action_log = { path = "crates/action_log" } agent = { path = "crates/agent" } @@ -423,7 +425,7 @@ zlog_settings = { path = "crates/zlog_settings" } # agentic-coding-protocol = "0.0.10" -agent-client-protocol = "0.0.30" +agent-client-protocol = "0.0.31" aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" } any_vec = "0.14" diff --git a/crates/acp_tools/Cargo.toml b/crates/acp_tools/Cargo.toml new file mode 100644 index 0000000000..7a6d8c21a0 --- /dev/null +++ b/crates/acp_tools/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "acp_tools" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + + +[lints] +workspace = true + +[lib] +path = "src/acp_tools.rs" +doctest = false + +[dependencies] +agent-client-protocol.workspace = true +collections.workspace = true +gpui.workspace = true +language.workspace= true +markdown.workspace = true +project.workspace = true +serde.workspace = true +serde_json.workspace = true +settings.workspace = true +theme.workspace = true +ui.workspace = true +util.workspace = true +workspace-hack.workspace = true +workspace.workspace = true diff --git a/crates/acp_tools/LICENSE-GPL b/crates/acp_tools/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/acp_tools/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/acp_tools/src/acp_tools.rs b/crates/acp_tools/src/acp_tools.rs new file mode 100644 index 0000000000..ca5e57e85a --- /dev/null +++ b/crates/acp_tools/src/acp_tools.rs @@ -0,0 +1,494 @@ +use std::{ + cell::RefCell, + collections::HashSet, + fmt::Display, + rc::{Rc, Weak}, + sync::Arc, +}; + +use agent_client_protocol as acp; +use collections::HashMap; +use gpui::{ + App, Empty, Entity, EventEmitter, FocusHandle, Focusable, Global, ListAlignment, ListState, + StyleRefinement, Subscription, Task, TextStyleRefinement, Window, actions, list, prelude::*, +}; +use language::LanguageRegistry; +use markdown::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownStyle}; +use project::Project; +use settings::Settings; +use theme::ThemeSettings; +use ui::prelude::*; +use util::ResultExt as _; +use workspace::{Item, Workspace}; + +actions!(acp, [OpenDebugTools]); + +pub fn init(cx: &mut App) { + cx.observe_new( + |workspace: &mut Workspace, _window, _cx: &mut Context| { + workspace.register_action(|workspace, _: &OpenDebugTools, window, cx| { + let acp_tools = + Box::new(cx.new(|cx| AcpTools::new(workspace.project().clone(), cx))); + workspace.add_item_to_active_pane(acp_tools, None, true, window, cx); + }); + }, + ) + .detach(); +} + +struct GlobalAcpConnectionRegistry(Entity); + +impl Global for GlobalAcpConnectionRegistry {} + +#[derive(Default)] +pub struct AcpConnectionRegistry { + active_connection: RefCell>, +} + +struct ActiveConnection { + server_name: &'static str, + connection: Weak, +} + +impl AcpConnectionRegistry { + pub fn default_global(cx: &mut App) -> Entity { + if cx.has_global::() { + cx.global::().0.clone() + } else { + let registry = cx.new(|_cx| AcpConnectionRegistry::default()); + cx.set_global(GlobalAcpConnectionRegistry(registry.clone())); + registry + } + } + + pub fn set_active_connection( + &self, + server_name: &'static str, + connection: &Rc, + cx: &mut Context, + ) { + self.active_connection.replace(Some(ActiveConnection { + server_name, + connection: Rc::downgrade(connection), + })); + cx.notify(); + } +} + +struct AcpTools { + project: Entity, + focus_handle: FocusHandle, + expanded: HashSet, + watched_connection: Option, + connection_registry: Entity, + _subscription: Subscription, +} + +struct WatchedConnection { + server_name: &'static str, + messages: Vec, + list_state: ListState, + connection: Weak, + incoming_request_methods: HashMap>, + outgoing_request_methods: HashMap>, + _task: Task<()>, +} + +impl AcpTools { + fn new(project: Entity, cx: &mut Context) -> Self { + let connection_registry = AcpConnectionRegistry::default_global(cx); + + let subscription = cx.observe(&connection_registry, |this, _, cx| { + this.update_connection(cx); + cx.notify(); + }); + + let mut this = Self { + project, + focus_handle: cx.focus_handle(), + expanded: HashSet::default(), + watched_connection: None, + connection_registry, + _subscription: subscription, + }; + this.update_connection(cx); + this + } + + fn update_connection(&mut self, cx: &mut Context) { + let active_connection = self.connection_registry.read(cx).active_connection.borrow(); + let Some(active_connection) = active_connection.as_ref() else { + return; + }; + + if let Some(watched_connection) = self.watched_connection.as_ref() { + if Weak::ptr_eq( + &watched_connection.connection, + &active_connection.connection, + ) { + return; + } + } + + if let Some(connection) = active_connection.connection.upgrade() { + let mut receiver = connection.subscribe(); + let task = cx.spawn(async move |this, cx| { + while let Ok(message) = receiver.recv().await { + this.update(cx, |this, cx| { + this.push_stream_message(message, cx); + }) + .ok(); + } + }); + + self.watched_connection = Some(WatchedConnection { + server_name: active_connection.server_name, + messages: vec![], + list_state: ListState::new(0, ListAlignment::Bottom, px(2048.)), + connection: active_connection.connection.clone(), + incoming_request_methods: HashMap::default(), + outgoing_request_methods: HashMap::default(), + _task: task, + }); + } + } + + fn push_stream_message(&mut self, stream_message: acp::StreamMessage, cx: &mut Context) { + let Some(connection) = self.watched_connection.as_mut() else { + return; + }; + let language_registry = self.project.read(cx).languages().clone(); + let index = connection.messages.len(); + + let (request_id, method, message_type, params) = match stream_message.message { + acp::StreamMessageContent::Request { id, method, params } => { + let method_map = match stream_message.direction { + acp::StreamMessageDirection::Incoming => { + &mut connection.incoming_request_methods + } + acp::StreamMessageDirection::Outgoing => { + &mut connection.outgoing_request_methods + } + }; + + method_map.insert(id, method.clone()); + (Some(id), method.into(), MessageType::Request, Ok(params)) + } + acp::StreamMessageContent::Response { id, result } => { + let method_map = match stream_message.direction { + acp::StreamMessageDirection::Incoming => { + &mut connection.outgoing_request_methods + } + acp::StreamMessageDirection::Outgoing => { + &mut connection.incoming_request_methods + } + }; + + if let Some(method) = method_map.remove(&id) { + (Some(id), method.into(), MessageType::Response, result) + } else { + ( + Some(id), + "[unrecognized response]".into(), + MessageType::Response, + result, + ) + } + } + acp::StreamMessageContent::Notification { method, params } => { + (None, method.into(), MessageType::Notification, Ok(params)) + } + }; + + let message = WatchedConnectionMessage { + name: method, + message_type, + request_id, + direction: stream_message.direction, + collapsed_params_md: match params.as_ref() { + Ok(params) => params + .as_ref() + .map(|params| collapsed_params_md(params, &language_registry, cx)), + Err(err) => { + if let Ok(err) = &serde_json::to_value(err) { + Some(collapsed_params_md(&err, &language_registry, cx)) + } else { + None + } + } + }, + + expanded_params_md: None, + params, + }; + + connection.messages.push(message); + connection.list_state.splice(index..index, 1); + cx.notify(); + } + + fn render_message( + &mut self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let Some(connection) = self.watched_connection.as_ref() else { + return Empty.into_any(); + }; + + let Some(message) = connection.messages.get(index) else { + return Empty.into_any(); + }; + + let base_size = TextSize::Editor.rems(cx); + + let theme_settings = ThemeSettings::get_global(cx); + let text_style = window.text_style(); + + let colors = cx.theme().colors(); + let expanded = self.expanded.contains(&index); + + v_flex() + .w_full() + .px_4() + .py_3() + .border_color(colors.border) + .border_b_1() + .gap_2() + .items_start() + .font_buffer(cx) + .text_size(base_size) + .id(index) + .group("message") + .hover(|this| this.bg(colors.element_background.opacity(0.5))) + .on_click(cx.listener(move |this, _, _, cx| { + if this.expanded.contains(&index) { + this.expanded.remove(&index); + } else { + this.expanded.insert(index); + let Some(connection) = &mut this.watched_connection else { + return; + }; + let Some(message) = connection.messages.get_mut(index) else { + return; + }; + message.expanded(this.project.read(cx).languages().clone(), cx); + connection.list_state.scroll_to_reveal_item(index); + } + cx.notify() + })) + .child( + h_flex() + .w_full() + .gap_2() + .items_center() + .flex_shrink_0() + .child(match message.direction { + acp::StreamMessageDirection::Incoming => { + ui::Icon::new(ui::IconName::ArrowDown).color(Color::Error) + } + acp::StreamMessageDirection::Outgoing => { + ui::Icon::new(ui::IconName::ArrowUp).color(Color::Success) + } + }) + .child( + Label::new(message.name.clone()) + .buffer_font(cx) + .color(Color::Muted), + ) + .child(div().flex_1()) + .child( + div() + .child(ui::Chip::new(message.message_type.to_string())) + .visible_on_hover("message"), + ) + .children( + message + .request_id + .map(|req_id| div().child(ui::Chip::new(req_id.to_string()))), + ), + ) + // I'm aware using markdown is a hack. Trying to get something working for the demo. + // Will clean up soon! + .when_some( + if expanded { + message.expanded_params_md.clone() + } else { + message.collapsed_params_md.clone() + }, + |this, params| { + this.child( + div().pl_6().w_full().child( + MarkdownElement::new( + params, + MarkdownStyle { + base_text_style: text_style, + selection_background_color: colors.element_selection_background, + syntax: cx.theme().syntax().clone(), + code_block_overflow_x_scroll: true, + code_block: StyleRefinement { + text: Some(TextStyleRefinement { + font_family: Some( + theme_settings.buffer_font.family.clone(), + ), + font_size: Some((base_size * 0.8).into()), + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + }, + ) + .code_block_renderer( + CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: expanded, + border: false, + }, + ), + ), + ) + }, + ) + .into_any() + } +} + +struct WatchedConnectionMessage { + name: SharedString, + request_id: Option, + direction: acp::StreamMessageDirection, + message_type: MessageType, + params: Result, acp::Error>, + collapsed_params_md: Option>, + expanded_params_md: Option>, +} + +impl WatchedConnectionMessage { + fn expanded(&mut self, language_registry: Arc, cx: &mut App) { + let params_md = match &self.params { + Ok(Some(params)) => Some(expanded_params_md(params, &language_registry, cx)), + Err(err) => { + if let Some(err) = &serde_json::to_value(err).log_err() { + Some(expanded_params_md(&err, &language_registry, cx)) + } else { + None + } + } + _ => None, + }; + self.expanded_params_md = params_md; + } +} + +fn collapsed_params_md( + params: &serde_json::Value, + language_registry: &Arc, + cx: &mut App, +) -> Entity { + let params_json = serde_json::to_string(params).unwrap_or_default(); + let mut spaced_out_json = String::with_capacity(params_json.len() + params_json.len() / 4); + + for ch in params_json.chars() { + match ch { + '{' => spaced_out_json.push_str("{ "), + '}' => spaced_out_json.push_str(" }"), + ':' => spaced_out_json.push_str(": "), + ',' => spaced_out_json.push_str(", "), + c => spaced_out_json.push(c), + } + } + + let params_md = format!("```json\n{}\n```", spaced_out_json); + cx.new(|cx| Markdown::new(params_md.into(), Some(language_registry.clone()), None, cx)) +} + +fn expanded_params_md( + params: &serde_json::Value, + language_registry: &Arc, + cx: &mut App, +) -> Entity { + let params_json = serde_json::to_string_pretty(params).unwrap_or_default(); + let params_md = format!("```json\n{}\n```", params_json); + cx.new(|cx| Markdown::new(params_md.into(), Some(language_registry.clone()), None, cx)) +} + +enum MessageType { + Request, + Response, + Notification, +} + +impl Display for MessageType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MessageType::Request => write!(f, "Request"), + MessageType::Response => write!(f, "Response"), + MessageType::Notification => write!(f, "Notification"), + } + } +} + +enum AcpToolsEvent {} + +impl EventEmitter for AcpTools {} + +impl Item for AcpTools { + type Event = AcpToolsEvent; + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> ui::SharedString { + format!( + "ACP: {}", + self.watched_connection + .as_ref() + .map_or("Disconnected", |connection| connection.server_name) + ) + .into() + } + + fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { + Some(ui::Icon::new(IconName::Thread)) + } +} + +impl Focusable for AcpTools { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for AcpTools { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .track_focus(&self.focus_handle) + .size_full() + .bg(cx.theme().colors().editor_background) + .child(match self.watched_connection.as_ref() { + Some(connection) => { + if connection.messages.is_empty() { + h_flex() + .size_full() + .justify_center() + .items_center() + .child("No messages recorded yet") + .into_any() + } else { + list( + connection.list_state.clone(), + cx.processor(Self::render_message), + ) + .with_sizing_behavior(gpui::ListSizingBehavior::Auto) + .flex_grow() + .into_any() + } + } + None => h_flex() + .size_full() + .justify_center() + .items_center() + .child("No active connection") + .into_any(), + }) + } +} diff --git a/crates/agent_servers/Cargo.toml b/crates/agent_servers/Cargo.toml index 60dd796463..8ea4a27f4c 100644 --- a/crates/agent_servers/Cargo.toml +++ b/crates/agent_servers/Cargo.toml @@ -17,6 +17,7 @@ path = "src/agent_servers.rs" doctest = false [dependencies] +acp_tools.workspace = true acp_thread.workspace = true action_log.workspace = true agent-client-protocol.workspace = true diff --git a/crates/agent_servers/src/acp/v1.rs b/crates/agent_servers/src/acp/v1.rs index 29f389547d..1945ad2483 100644 --- a/crates/agent_servers/src/acp/v1.rs +++ b/crates/agent_servers/src/acp/v1.rs @@ -1,3 +1,4 @@ +use acp_tools::AcpConnectionRegistry; use action_log::ActionLog; use agent_client_protocol::{self as acp, Agent as _, ErrorCode}; use anyhow::anyhow; @@ -101,6 +102,14 @@ impl AcpConnection { }) .detach(); + let connection = Rc::new(connection); + + cx.update(|cx| { + AcpConnectionRegistry::default_global(cx).update(cx, |registry, cx| { + registry.set_active_connection(server_name, &connection, cx) + }); + })?; + let response = connection .initialize(acp::InitializeRequest { protocol_version: acp::VERSION, @@ -119,7 +128,7 @@ impl AcpConnection { Ok(Self { auth_methods: response.auth_methods, - connection: connection.into(), + connection, server_name, sessions, prompt_capabilities: response.agent_capabilities.prompt_capabilities, diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 5368146d52..fa903b70b8 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -20,6 +20,7 @@ path = "src/main.rs" [dependencies] activity_indicator.workspace = true +acp_tools.workspace = true agent.workspace = true agent_ui.workspace = true agent_settings.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 45c67153eb..7c81d99a7c 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -566,6 +566,7 @@ pub fn main() { language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx); agent_settings::init(cx); agent_servers::init(cx); + acp_tools::init(cx); web_search::init(cx); web_search_providers::init(app_state.client.clone(), cx); snippet_provider::init(cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 958149825a..6be4ccda97 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -4424,6 +4424,7 @@ mod tests { assert_eq!(actions_without_namespace, Vec::<&str>::new()); let expected_namespaces = vec![ + "acp", "activity_indicator", "agent", #[cfg(not(target_os = "macos"))] diff --git a/script/squawk b/script/squawk index 8489206f14..497fcff089 100755 --- a/script/squawk +++ b/script/squawk @@ -15,13 +15,11 @@ SQUAWK_VERSION=0.26.0 SQUAWK_BIN="./target/squawk-$SQUAWK_VERSION" SQUAWK_ARGS="--assume-in-transaction --config script/lib/squawk.toml" -if [ ! -f "$SQUAWK_BIN" ]; then - pkgutil --pkg-info com.apple.pkg.RosettaUpdateAuto || /usr/sbin/softwareupdate --install-rosetta --agree-to-license - # When bootstrapping a brand new CI machine, the `target` directory may not exist yet. - mkdir -p "./target" - curl -L -o "$SQUAWK_BIN" "https://github.com/sbdchd/squawk/releases/download/v$SQUAWK_VERSION/squawk-darwin-x86_64" - chmod +x "$SQUAWK_BIN" -fi +pkgutil --pkg-info com.apple.pkg.RosettaUpdateAuto || /usr/sbin/softwareupdate --install-rosetta --agree-to-license +# When bootstrapping a brand new CI machine, the `target` directory may not exist yet. +mkdir -p "./target" +curl -L -o "$SQUAWK_BIN" "https://github.com/sbdchd/squawk/releases/download/v$SQUAWK_VERSION/squawk-darwin-x86_64" +chmod +x "$SQUAWK_BIN" if [ -n "$SQUAWK_GITHUB_TOKEN" ]; then export SQUAWK_GITHUB_REPO_OWNER=$(echo $GITHUB_REPOSITORY | awk -F/ '{print $1}') diff --git a/tooling/workspace-hack/Cargo.toml b/tooling/workspace-hack/Cargo.toml index 054e757056..bf44fc195e 100644 --- a/tooling/workspace-hack/Cargo.toml +++ b/tooling/workspace-hack/Cargo.toml @@ -54,6 +54,7 @@ digest = { version = "0.10", features = ["mac", "oid", "std"] } either = { version = "1", features = ["serde", "use_std"] } euclid = { version = "0.22" } event-listener = { version = "5" } +event-listener-strategy = { version = "0.5" } flate2 = { version = "1", features = ["zlib-rs"] } form_urlencoded = { version = "1" } futures = { version = "0.3", features = ["io-compat"] } @@ -183,6 +184,7 @@ digest = { version = "0.10", features = ["mac", "oid", "std"] } either = { version = "1", features = ["serde", "use_std"] } euclid = { version = "0.22" } event-listener = { version = "5" } +event-listener-strategy = { version = "0.5" } flate2 = { version = "1", features = ["zlib-rs"] } form_urlencoded = { version = "1" } futures = { version = "0.3", features = ["io-compat"] } @@ -403,7 +405,6 @@ bytemuck = { version = "1", default-features = false, features = ["min_const_gen cipher = { version = "0.4", default-features = false, features = ["block-padding", "rand_core", "zeroize"] } codespan-reporting = { version = "0.12" } crypto-common = { version = "0.1", default-features = false, features = ["rand_core", "std"] } -event-listener-strategy = { version = "0.5" } flume = { version = "0.11" } foldhash = { version = "0.1", default-features = false, features = ["std"] } getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } @@ -444,7 +445,6 @@ bytemuck = { version = "1", default-features = false, features = ["min_const_gen cipher = { version = "0.4", default-features = false, features = ["block-padding", "rand_core", "zeroize"] } codespan-reporting = { version = "0.12" } crypto-common = { version = "0.1", default-features = false, features = ["rand_core", "std"] } -event-listener-strategy = { version = "0.5" } flume = { version = "0.11" } foldhash = { version = "0.1", default-features = false, features = ["std"] } getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } @@ -483,7 +483,6 @@ bytemuck = { version = "1", default-features = false, features = ["min_const_gen cipher = { version = "0.4", default-features = false, features = ["block-padding", "rand_core", "zeroize"] } codespan-reporting = { version = "0.12" } crypto-common = { version = "0.1", default-features = false, features = ["rand_core", "std"] } -event-listener-strategy = { version = "0.5" } flume = { version = "0.11" } foldhash = { version = "0.1", default-features = false, features = ["std"] } getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } @@ -524,7 +523,6 @@ bytemuck = { version = "1", default-features = false, features = ["min_const_gen cipher = { version = "0.4", default-features = false, features = ["block-padding", "rand_core", "zeroize"] } codespan-reporting = { version = "0.12" } crypto-common = { version = "0.1", default-features = false, features = ["rand_core", "std"] } -event-listener-strategy = { version = "0.5" } flume = { version = "0.11" } foldhash = { version = "0.1", default-features = false, features = ["std"] } getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } @@ -610,7 +608,6 @@ bytemuck = { version = "1", default-features = false, features = ["min_const_gen cipher = { version = "0.4", default-features = false, features = ["block-padding", "rand_core", "zeroize"] } codespan-reporting = { version = "0.12" } crypto-common = { version = "0.1", default-features = false, features = ["rand_core", "std"] } -event-listener-strategy = { version = "0.5" } flume = { version = "0.11" } foldhash = { version = "0.1", default-features = false, features = ["std"] } getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } @@ -651,7 +648,6 @@ bytemuck = { version = "1", default-features = false, features = ["min_const_gen cipher = { version = "0.4", default-features = false, features = ["block-padding", "rand_core", "zeroize"] } codespan-reporting = { version = "0.12" } crypto-common = { version = "0.1", default-features = false, features = ["rand_core", "std"] } -event-listener-strategy = { version = "0.5" } flume = { version = "0.11" } foldhash = { version = "0.1", default-features = false, features = ["std"] } getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } From 7e2a20878b0bbc20cbf4c900750d8d57c8f028c7 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 22 Aug 2025 17:40:52 -0300 Subject: [PATCH 51/89] thread_view: Adjust empty state and error displays (#36774) Also changes the message editor placeholder depending on the agent. Release Notes: - N/A --------- Co-authored-by: Conrad Irwin --- crates/agent2/src/native_agent_server.rs | 4 +- crates/agent_ui/src/acp/thread_view.rs | 514 +++++++++++------------ crates/agent_ui/src/agent_panel.rs | 2 +- crates/ui/src/components/callout.rs | 1 + 4 files changed, 254 insertions(+), 267 deletions(-) diff --git a/crates/agent2/src/native_agent_server.rs b/crates/agent2/src/native_agent_server.rs index ac5aa95c04..4ce467d6fd 100644 --- a/crates/agent2/src/native_agent_server.rs +++ b/crates/agent2/src/native_agent_server.rs @@ -23,11 +23,11 @@ impl NativeAgentServer { impl AgentServer for NativeAgentServer { fn name(&self) -> &'static str { - "Native Agent" + "Zed Agent" } fn empty_state_headline(&self) -> &'static str { - "Welcome to the Agent Panel" + self.name() } fn empty_state_message(&self) -> &'static str { diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index d27dee1fe6..2a83a4ab5b 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -258,6 +258,7 @@ pub struct AcpThreadView { hovered_recent_history_item: Option, entry_view_state: Entity, message_editor: Entity, + focus_handle: FocusHandle, model_selector: Option>, profile_selector: Option>, notifications: Vec>, @@ -312,6 +313,13 @@ impl AcpThreadView { ) -> Self { let prompt_capabilities = Rc::new(Cell::new(acp::PromptCapabilities::default())); let prevent_slash_commands = agent.clone().downcast::().is_some(); + + let placeholder = if agent.name() == "Zed Agent" { + format!("Message the {} — @ to include context", agent.name()) + } else { + format!("Message {} — @ to include context", agent.name()) + }; + let message_editor = cx.new(|cx| { let mut editor = MessageEditor::new( workspace.clone(), @@ -319,7 +327,7 @@ impl AcpThreadView { history_store.clone(), prompt_store.clone(), prompt_capabilities.clone(), - "Message the agent — @ to include context", + placeholder, prevent_slash_commands, editor::EditorMode::AutoHeight { min_lines: MIN_EDITOR_LINES, @@ -381,6 +389,7 @@ impl AcpThreadView { prompt_capabilities, _subscriptions: subscriptions, _cancel_task: None, + focus_handle: cx.focus_handle(), } } @@ -404,8 +413,12 @@ impl AcpThreadView { let connection = match connect_task.await { Ok(connection) => connection, Err(err) => { - this.update(cx, |this, cx| { - this.handle_load_error(err, cx); + this.update_in(cx, |this, window, cx| { + if err.downcast_ref::().is_some() { + this.handle_load_error(err, window, cx); + } else { + this.handle_thread_error(err, cx); + } cx.notify(); }) .log_err(); @@ -522,6 +535,7 @@ impl AcpThreadView { title_editor, _subscriptions: subscriptions, }; + this.message_editor.focus_handle(cx).focus(window); this.profile_selector = this.as_native_thread(cx).map(|thread| { cx.new(|cx| { @@ -537,7 +551,7 @@ impl AcpThreadView { cx.notify(); } Err(err) => { - this.handle_load_error(err, cx); + this.handle_load_error(err, window, cx); } }; }) @@ -606,17 +620,28 @@ impl AcpThreadView { .map(|desc| cx.new(|cx| Markdown::new(desc.into(), None, None, cx))), _subscription: subscription, }; + if this.message_editor.focus_handle(cx).is_focused(window) { + this.focus_handle.focus(window) + } cx.notify(); }) .ok(); } - fn handle_load_error(&mut self, err: anyhow::Error, cx: &mut Context) { + fn handle_load_error( + &mut self, + err: anyhow::Error, + window: &mut Window, + cx: &mut Context, + ) { if let Some(load_err) = err.downcast_ref::() { self.thread_state = ThreadState::LoadError(load_err.clone()); } else { self.thread_state = ThreadState::LoadError(LoadError::Other(err.to_string().into())) } + if self.message_editor.focus_handle(cx).is_focused(window) { + self.focus_handle.focus(window) + } cx.notify(); } @@ -633,12 +658,11 @@ impl AcpThreadView { } } - pub fn title(&self, cx: &App) -> SharedString { + pub fn title(&self) -> SharedString { match &self.thread_state { - ThreadState::Ready { thread, .. } => thread.read(cx).title(), + ThreadState::Ready { .. } | ThreadState::Unauthenticated { .. } => "New Thread".into(), ThreadState::Loading { .. } => "Loading…".into(), ThreadState::LoadError(_) => "Failed to load".into(), - ThreadState::Unauthenticated { .. } => "Authentication Required".into(), } } @@ -1069,6 +1093,9 @@ impl AcpThreadView { AcpThreadEvent::LoadError(error) => { self.thread_retry_status.take(); self.thread_state = ThreadState::LoadError(error.clone()); + if self.message_editor.focus_handle(cx).is_focused(window) { + self.focus_handle.focus(window) + } } AcpThreadEvent::TitleUpdated => { let title = thread.read(cx).title(); @@ -2338,33 +2365,6 @@ impl AcpThreadView { .into_any() } - fn render_agent_logo(&self) -> AnyElement { - Icon::new(self.agent.logo()) - .color(Color::Muted) - .size(IconSize::XLarge) - .into_any_element() - } - - fn render_error_agent_logo(&self) -> AnyElement { - let logo = Icon::new(self.agent.logo()) - .color(Color::Muted) - .size(IconSize::XLarge) - .into_any_element(); - - h_flex() - .relative() - .justify_center() - .child(div().opacity(0.3).child(logo)) - .child( - h_flex() - .absolute() - .right_1() - .bottom_0() - .child(Icon::new(IconName::XCircleFilled).color(Color::Error)), - ) - .into_any_element() - } - fn render_rules_item(&self, cx: &Context) -> Option { let project_context = self .as_native_thread(cx)? @@ -2493,8 +2493,7 @@ impl AcpThreadView { ) } - fn render_empty_state(&self, window: &mut Window, cx: &mut Context) -> AnyElement { - let loading = matches!(&self.thread_state, ThreadState::Loading { .. }); + fn render_recent_history(&self, window: &mut Window, cx: &mut Context) -> AnyElement { let render_history = self .agent .clone() @@ -2506,38 +2505,6 @@ impl AcpThreadView { v_flex() .size_full() - .when(!render_history, |this| { - this.child( - v_flex() - .size_full() - .items_center() - .justify_center() - .child(if loading { - h_flex() - .justify_center() - .child(self.render_agent_logo()) - .with_animation( - "pulsating_icon", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.4, 1.0)), - |icon, delta| icon.opacity(delta), - ) - .into_any() - } else { - self.render_agent_logo().into_any_element() - }) - .child(h_flex().mt_4().mb_2().justify_center().child(if loading { - div() - .child(LoadingLabel::new("").size(LabelSize::Large)) - .into_any_element() - } else { - Headline::new(self.agent.empty_state_headline()) - .size(HeadlineSize::Medium) - .into_any_element() - })), - ) - }) .when(render_history, |this| { let recent_history: Vec<_> = self.history_store.update(cx, |history_store, _| { history_store.entries().take(3).collect() @@ -2612,196 +2579,118 @@ impl AcpThreadView { window: &mut Window, cx: &Context, ) -> Div { - v_flex() - .p_2() - .gap_2() - .flex_1() - .items_center() - .justify_center() - .child( - v_flex() - .items_center() - .justify_center() - .child(self.render_error_agent_logo()) - .child( - h_flex().mt_4().mb_1().justify_center().child( - Headline::new("Authentication Required").size(HeadlineSize::Medium), + v_flex().flex_1().size_full().justify_end().child( + v_flex() + .p_2() + .pr_3() + .w_full() + .border_t_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().status().warning.opacity(0.04)) + .child( + h_flex() + .gap_1p5() + .child( + Icon::new(IconName::Warning) + .color(Color::Warning) + .size(IconSize::Small), + ) + .child(Label::new("Authentication Required")), + ) + .children(description.map(|desc| { + div().text_ui(cx).child(self.render_markdown( + desc.clone(), + default_markdown_style(false, false, window, cx), + )) + })) + .children( + configuration_view + .cloned() + .map(|view| div().w_full().child(view)), + ) + .when( + configuration_view.is_none() + && description.is_none() + && pending_auth_method.is_none(), + |el| { + el.child( + Label::new(format!( + "You are not currently authenticated with {}. Please choose one of the following options:", + self.agent.name() + )) + .color(Color::Muted) + .mb_1() + .ml_5(), + ) + }, + ) + .when(!connection.auth_methods().is_empty(), |this| { + this.child( + h_flex().justify_end().flex_wrap().gap_1().children( + connection.auth_methods().iter().enumerate().rev().map( + |(ix, method)| { + Button::new( + SharedString::from(method.id.0.clone()), + method.name.clone(), + ) + .when(ix == 0, |el| { + el.style(ButtonStyle::Tinted(ui::TintColor::Warning)) + }) + .on_click({ + let method_id = method.id.clone(); + cx.listener(move |this, _, window, cx| { + this.authenticate(method_id.clone(), window, cx) + }) + }) + }, + ), ), ) - .into_any(), - ) - .children(description.map(|desc| { - div().text_ui(cx).text_center().child(self.render_markdown( - desc.clone(), - default_markdown_style(false, false, window, cx), - )) - })) - .children( - configuration_view - .cloned() - .map(|view| div().px_4().w_full().max_w_128().child(view)), - ) - .when( - configuration_view.is_none() - && description.is_none() - && pending_auth_method.is_none(), - |el| { + }) + .when_some(pending_auth_method, |el, _| { el.child( - div() - .text_ui(cx) - .text_center() - .px_4() + h_flex() + .py_4() .w_full() - .max_w_128() - .child(Label::new("Authentication required")), - ) - }, - ) - .when_some(pending_auth_method, |el, _| { - let spinner_icon = div() - .px_0p5() - .id("generating") - .tooltip(Tooltip::text("Generating Changes…")) - .child( - Icon::new(IconName::ArrowCircle) - .size(IconSize::Small) - .with_animation( - "arrow-circle", - Animation::new(Duration::from_secs(2)).repeat(), - |icon, delta| { - icon.transform(Transformation::rotate(percentage(delta))) - }, + .justify_center() + .gap_1() + .child( + Icon::new(IconName::ArrowCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_animation( + "arrow-circle", + Animation::new(Duration::from_secs(2)).repeat(), + |icon, delta| { + icon.transform(Transformation::rotate(percentage( + delta, + ))) + }, + ) + .into_any_element(), ) - .into_any_element(), + .child(Label::new("Authenticating…")), ) - .into_any(); - el.child( - h_flex() - .text_ui(cx) - .text_center() - .justify_center() - .gap_2() - .px_4() - .w_full() - .max_w_128() - .child(Label::new("Authenticating...")) - .child(spinner_icon), - ) - }) - .child( - h_flex() - .mt_1p5() - .gap_1() - .flex_wrap() - .justify_center() - .children(connection.auth_methods().iter().enumerate().rev().map( - |(ix, method)| { - Button::new( - SharedString::from(method.id.0.clone()), - method.name.clone(), - ) - .style(ButtonStyle::Outlined) - .when(ix == 0, |el| { - el.style(ButtonStyle::Tinted(ui::TintColor::Accent)) - }) - .size(ButtonSize::Medium) - .label_size(LabelSize::Small) - .on_click({ - let method_id = method.id.clone(); - cx.listener(move |this, _, window, cx| { - this.authenticate(method_id.clone(), window, cx) - }) - }) - }, - )), - ) + }), + ) } fn render_load_error(&self, e: &LoadError, cx: &Context) -> AnyElement { - let mut container = v_flex() - .items_center() - .justify_center() - .child(self.render_error_agent_logo()) - .child( - v_flex() - .mt_4() - .mb_2() - .gap_0p5() - .text_center() - .items_center() - .child(Headline::new("Failed to launch").size(HeadlineSize::Medium)) - .child( - Label::new(e.to_string()) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ); - - if let LoadError::Unsupported { - upgrade_message, - upgrade_command, - .. - } = &e - { - let upgrade_message = upgrade_message.clone(); - let upgrade_command = upgrade_command.clone(); - container = container.child( - Button::new("upgrade", upgrade_message) - .tooltip(Tooltip::text(upgrade_command.clone())) - .on_click(cx.listener(move |this, _, window, cx| { - let task = this - .workspace - .update(cx, |workspace, cx| { - let project = workspace.project().read(cx); - let cwd = project.first_project_directory(cx); - let shell = project.terminal_settings(&cwd, cx).shell.clone(); - let spawn_in_terminal = task::SpawnInTerminal { - id: task::TaskId("upgrade".to_string()), - full_label: upgrade_command.clone(), - label: upgrade_command.clone(), - command: Some(upgrade_command.clone()), - args: Vec::new(), - command_label: upgrade_command.clone(), - cwd, - env: Default::default(), - use_new_terminal: true, - allow_concurrent_runs: true, - reveal: Default::default(), - reveal_target: Default::default(), - hide: Default::default(), - shell, - show_summary: true, - show_command: true, - show_rerun: false, - }; - workspace.spawn_in_terminal(spawn_in_terminal, window, cx) - }) - .ok(); - let Some(task) = task else { return }; - cx.spawn_in(window, async move |this, cx| { - if let Some(Ok(_)) = task.await { - this.update_in(cx, |this, window, cx| { - this.reset(window, cx); - }) - .ok(); - } - }) - .detach() - })), - ); - } else if let LoadError::NotInstalled { - install_message, - install_command, - .. - } = e - { - let install_message = install_message.clone(); - let install_command = install_command.clone(); - container = container.child( - Button::new("install", install_message) - .style(ButtonStyle::Tinted(ui::TintColor::Accent)) - .size(ButtonSize::Medium) + let (message, action_slot) = match e { + LoadError::NotInstalled { + error_message, + install_message, + install_command, + } => { + let install_command = install_command.clone(); + let button = Button::new("install", install_message) .tooltip(Tooltip::text(install_command.clone())) + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .icon(IconName::Download) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) .on_click(cx.listener(move |this, _, window, cx| { let task = this .workspace @@ -2841,11 +2730,81 @@ impl AcpThreadView { } }) .detach() - })), - ); - } + })); - container.into_any() + (error_message.clone(), Some(button.into_any_element())) + } + LoadError::Unsupported { + error_message, + upgrade_message, + upgrade_command, + } => { + let upgrade_command = upgrade_command.clone(); + let button = Button::new("upgrade", upgrade_message) + .tooltip(Tooltip::text(upgrade_command.clone())) + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .icon(IconName::Download) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .on_click(cx.listener(move |this, _, window, cx| { + let task = this + .workspace + .update(cx, |workspace, cx| { + let project = workspace.project().read(cx); + let cwd = project.first_project_directory(cx); + let shell = project.terminal_settings(&cwd, cx).shell.clone(); + let spawn_in_terminal = task::SpawnInTerminal { + id: task::TaskId("upgrade".to_string()), + full_label: upgrade_command.clone(), + label: upgrade_command.clone(), + command: Some(upgrade_command.clone()), + args: Vec::new(), + command_label: upgrade_command.clone(), + cwd, + env: Default::default(), + use_new_terminal: true, + allow_concurrent_runs: true, + reveal: Default::default(), + reveal_target: Default::default(), + hide: Default::default(), + shell, + show_summary: true, + show_command: true, + show_rerun: false, + }; + workspace.spawn_in_terminal(spawn_in_terminal, window, cx) + }) + .ok(); + let Some(task) = task else { return }; + cx.spawn_in(window, async move |this, cx| { + if let Some(Ok(_)) = task.await { + this.update_in(cx, |this, window, cx| { + this.reset(window, cx); + }) + .ok(); + } + }) + .detach() + })); + + (error_message.clone(), Some(button.into_any_element())) + } + LoadError::Exited { .. } => ("Server exited with status {status}".into(), None), + LoadError::Other(msg) => ( + msg.into(), + Some(self.create_copy_button(msg.to_string()).into_any_element()), + ), + }; + + Callout::new() + .severity(Severity::Error) + .icon(IconName::XCircleFilled) + .title("Failed to Launch") + .description(message) + .actions_slot(div().children(action_slot)) + .into_any_element() } fn render_activity_bar( @@ -3336,6 +3295,19 @@ impl AcpThreadView { (IconName::Maximize, "Expand Message Editor") }; + let backdrop = div() + .size_full() + .absolute() + .inset_0() + .bg(cx.theme().colors().panel_background) + .opacity(0.8) + .block_mouse_except_scroll(); + + let enable_editor = match self.thread_state { + ThreadState::Loading { .. } | ThreadState::Ready { .. } => true, + ThreadState::Unauthenticated { .. } | ThreadState::LoadError(..) => false, + }; + v_flex() .on_action(cx.listener(Self::expand_message_editor)) .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| { @@ -3411,6 +3383,7 @@ impl AcpThreadView { .child(self.render_send_button(cx)), ), ) + .when(!enable_editor, |this| this.child(backdrop)) .into_any() } @@ -3913,18 +3886,19 @@ impl AcpThreadView { return; } - let title = self.title(cx); + // TODO: Change this once we have title summarization for external agents. + let title = self.agent.name(); match AgentSettings::get_global(cx).notify_when_agent_waiting { NotifyWhenAgentWaiting::PrimaryScreen => { if let Some(primary) = cx.primary_display() { - self.pop_up(icon, caption.into(), title, window, primary, cx); + self.pop_up(icon, caption.into(), title.into(), window, primary, cx); } } NotifyWhenAgentWaiting::AllScreens => { let caption = caption.into(); for screen in cx.displays() { - self.pop_up(icon, caption.clone(), title.clone(), window, screen, cx); + self.pop_up(icon, caption.clone(), title.into(), window, screen, cx); } } NotifyWhenAgentWaiting::Never => { @@ -4423,6 +4397,7 @@ impl AcpThreadView { Callout::new() .severity(Severity::Error) .title("Error") + .icon(IconName::XCircle) .description(error.clone()) .actions_slot(self.create_copy_button(error.to_string())) .dismiss_action(self.dismiss_error_button(cx)) @@ -4434,6 +4409,7 @@ impl AcpThreadView { Callout::new() .severity(Severity::Error) + .icon(IconName::XCircle) .title("Free Usage Exceeded") .description(ERROR_MESSAGE) .actions_slot( @@ -4453,6 +4429,7 @@ impl AcpThreadView { Callout::new() .severity(Severity::Error) .title("Authentication Required") + .icon(IconName::XCircle) .description(error.clone()) .actions_slot( h_flex() @@ -4478,6 +4455,7 @@ impl AcpThreadView { Callout::new() .severity(Severity::Error) .title("Model Prompt Limit Reached") + .icon(IconName::XCircle) .description(error_message) .actions_slot( h_flex() @@ -4648,7 +4626,14 @@ impl AcpThreadView { impl Focusable for AcpThreadView { fn focus_handle(&self, cx: &App) -> FocusHandle { - self.message_editor.focus_handle(cx) + match self.thread_state { + ThreadState::Loading { .. } | ThreadState::Ready { .. } => { + self.message_editor.focus_handle(cx) + } + ThreadState::LoadError(_) | ThreadState::Unauthenticated { .. } => { + self.focus_handle.clone() + } + } } } @@ -4664,6 +4649,7 @@ impl Render for AcpThreadView { .on_action(cx.listener(Self::toggle_burn_mode)) .on_action(cx.listener(Self::keep_all)) .on_action(cx.listener(Self::reject_all)) + .track_focus(&self.focus_handle) .bg(cx.theme().colors().panel_background) .child(match &self.thread_state { ThreadState::Unauthenticated { @@ -4680,14 +4666,14 @@ impl Render for AcpThreadView { window, cx, ), - ThreadState::Loading { .. } => { - v_flex().flex_1().child(self.render_empty_state(window, cx)) - } - ThreadState::LoadError(e) => v_flex() - .p_2() + ThreadState::Loading { .. } => v_flex() .flex_1() + .child(self.render_recent_history(window, cx)), + ThreadState::LoadError(e) => v_flex() + .flex_1() + .size_full() .items_center() - .justify_center() + .justify_end() .child(self.render_load_error(e, cx)), ThreadState::Ready { thread, .. } => { let thread_clone = thread.clone(); @@ -4724,7 +4710,7 @@ impl Render for AcpThreadView { }, ) } else { - this.child(self.render_empty_state(window, cx)) + this.child(self.render_recent_history(window, cx)) } }) } diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index d0fb676fd2..0e611d0db9 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -2097,7 +2097,7 @@ impl AgentPanel { .child(title_editor) .into_any_element() } else { - Label::new(thread_view.read(cx).title(cx)) + Label::new(thread_view.read(cx).title()) .color(Color::Muted) .truncate() .into_any_element() diff --git a/crates/ui/src/components/callout.rs b/crates/ui/src/components/callout.rs index 7ffeda881c..b1ead18ee7 100644 --- a/crates/ui/src/components/callout.rs +++ b/crates/ui/src/components/callout.rs @@ -132,6 +132,7 @@ impl RenderOnce for Callout { h_flex() .min_w_0() + .w_full() .p_2() .gap_2() .items_start() From a422082b54c7d5ab3e2d89fdb8045744e03d30a8 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Sat, 23 Aug 2025 00:35:26 +0200 Subject: [PATCH 52/89] agent2: Tweak usage callout border (#36777) Release Notes: - N/A --- .../agent_ui/src/ui/preview/usage_callouts.rs | 29 ++++++++----------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/crates/agent_ui/src/ui/preview/usage_callouts.rs b/crates/agent_ui/src/ui/preview/usage_callouts.rs index 29b12ea627..d4d037b976 100644 --- a/crates/agent_ui/src/ui/preview/usage_callouts.rs +++ b/crates/agent_ui/src/ui/preview/usage_callouts.rs @@ -86,23 +86,18 @@ impl RenderOnce for UsageCallout { (IconName::Warning, Severity::Warning) }; - div() - .border_t_1() - .border_color(cx.theme().colors().border) - .child( - Callout::new() - .icon(icon) - .severity(severity) - .icon(icon) - .title(title) - .description(message) - .actions_slot( - Button::new("upgrade", button_text) - .label_size(LabelSize::Small) - .on_click(move |_, _, cx| { - cx.open_url(&url); - }), - ), + Callout::new() + .icon(icon) + .severity(severity) + .icon(icon) + .title(title) + .description(message) + .actions_slot( + Button::new("upgrade", button_text) + .label_size(LabelSize::Small) + .on_click(move |_, _, cx| { + cx.open_url(&url); + }), ) .into_any_element() } From abe442c7cca76d9844d6ac2702fe0caad24dcee3 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 22 Aug 2025 20:10:26 -0300 Subject: [PATCH 53/89] thread view: Simplify tool call & improve required auth state UIs (#36783) Release Notes: - N/A --- crates/agent_ui/src/acp/thread_view.rs | 145 ++++++++++++++----------- 1 file changed, 82 insertions(+), 63 deletions(-) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 2a83a4ab5b..0e1d4123b9 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -1668,39 +1668,14 @@ impl AcpThreadView { let header_id = SharedString::from(format!("outer-tool-call-header-{}", entry_ix)); let card_header_id = SharedString::from("inner-tool-call-header"); - let status_icon = match &tool_call.status { - ToolCallStatus::Pending - | ToolCallStatus::WaitingForConfirmation { .. } - | ToolCallStatus::Completed => None, - ToolCallStatus::InProgress => Some( - div() - .absolute() - .right_2() - .child( - Icon::new(IconName::ArrowCircle) - .color(Color::Muted) - .size(IconSize::Small) - .with_animation( - "running", - Animation::new(Duration::from_secs(3)).repeat(), - |icon, delta| { - icon.transform(Transformation::rotate(percentage(delta))) - }, - ), - ) - .into_any(), - ), - ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed => Some( - div() - .absolute() - .right_2() - .child( - Icon::new(IconName::Close) - .color(Color::Error) - .size(IconSize::Small), - ) - .into_any_element(), - ), + let in_progress = match &tool_call.status { + ToolCallStatus::InProgress => true, + _ => false, + }; + + let failed_or_canceled = match &tool_call.status { + ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed => true, + _ => false, }; let failed_tool_call = matches!( @@ -1884,7 +1859,33 @@ impl AcpThreadView { .into_any() }), ) - .children(status_icon), + .when(in_progress && use_card_layout, |this| { + this.child( + div().absolute().right_2().child( + Icon::new(IconName::ArrowCircle) + .color(Color::Muted) + .size(IconSize::Small) + .with_animation( + "running", + Animation::new(Duration::from_secs(3)).repeat(), + |icon, delta| { + icon.transform(Transformation::rotate(percentage( + delta, + ))) + }, + ), + ), + ) + }) + .when(failed_or_canceled, |this| { + this.child( + div().absolute().right_2().child( + Icon::new(IconName::Close) + .color(Color::Error) + .size(IconSize::Small), + ), + ) + }), ) .children(tool_output_display) } @@ -2579,11 +2580,15 @@ impl AcpThreadView { window: &mut Window, cx: &Context, ) -> Div { + let show_description = + configuration_view.is_none() && description.is_none() && pending_auth_method.is_none(); + v_flex().flex_1().size_full().justify_end().child( v_flex() .p_2() .pr_3() .w_full() + .gap_1() .border_t_1() .border_color(cx.theme().colors().border) .bg(cx.theme().status().warning.opacity(0.04)) @@ -2595,7 +2600,7 @@ impl AcpThreadView { .color(Color::Warning) .size(IconSize::Small), ) - .child(Label::new("Authentication Required")), + .child(Label::new("Authentication Required").size(LabelSize::Small)), ) .children(description.map(|desc| { div().text_ui(cx).child(self.render_markdown( @@ -2609,44 +2614,20 @@ impl AcpThreadView { .map(|view| div().w_full().child(view)), ) .when( - configuration_view.is_none() - && description.is_none() - && pending_auth_method.is_none(), + show_description, |el| { el.child( Label::new(format!( "You are not currently authenticated with {}. Please choose one of the following options:", self.agent.name() )) + .size(LabelSize::Small) .color(Color::Muted) .mb_1() .ml_5(), ) }, ) - .when(!connection.auth_methods().is_empty(), |this| { - this.child( - h_flex().justify_end().flex_wrap().gap_1().children( - connection.auth_methods().iter().enumerate().rev().map( - |(ix, method)| { - Button::new( - SharedString::from(method.id.0.clone()), - method.name.clone(), - ) - .when(ix == 0, |el| { - el.style(ButtonStyle::Tinted(ui::TintColor::Warning)) - }) - .on_click({ - let method_id = method.id.clone(); - cx.listener(move |this, _, window, cx| { - this.authenticate(method_id.clone(), window, cx) - }) - }) - }, - ), - ), - ) - }) .when_some(pending_auth_method, |el, _| { el.child( h_flex() @@ -2669,9 +2650,47 @@ impl AcpThreadView { ) .into_any_element(), ) - .child(Label::new("Authenticating…")), + .child(Label::new("Authenticating…").size(LabelSize::Small)), ) - }), + }) + .when(!connection.auth_methods().is_empty(), |this| { + this.child( + h_flex() + .justify_end() + .flex_wrap() + .gap_1() + .when(!show_description, |this| { + this.border_t_1() + .mt_1() + .pt_2() + .border_color(cx.theme().colors().border.opacity(0.8)) + }) + .children( + connection + .auth_methods() + .iter() + .enumerate() + .rev() + .map(|(ix, method)| { + Button::new( + SharedString::from(method.id.0.clone()), + method.name.clone(), + ) + .when(ix == 0, |el| { + el.style(ButtonStyle::Tinted(ui::TintColor::Warning)) + }) + .label_size(LabelSize::Small) + .on_click({ + let method_id = method.id.clone(); + cx.listener(move |this, _, window, cx| { + this.authenticate(method_id.clone(), window, cx) + }) + }) + }), + ), + ) + }) + ) } From e926e0bde4c323a2fd327cc090de8792a8a9a058 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 22 Aug 2025 22:09:08 -0600 Subject: [PATCH 54/89] acp: Remove ACP v0 (#36785) We had a few people confused about why some features weren't working due to the fallback logic. It's gone. Release Notes: - N/A --- Cargo.lock | 61 +---- Cargo.toml | 1 - crates/agent_servers/Cargo.toml | 1 - crates/agent_servers/src/acp.rs | 387 ++++++++++++++++++++++++++++-- tooling/workspace-hack/Cargo.toml | 2 - 5 files changed, 381 insertions(+), 71 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 37e58416c4..c9f2d952f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -289,7 +289,6 @@ dependencies = [ "action_log", "agent-client-protocol", "agent_settings", - "agentic-coding-protocol", "anyhow", "client", "collections", @@ -443,24 +442,6 @@ dependencies = [ "zed_actions", ] -[[package]] -name = "agentic-coding-protocol" -version = "0.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e6ae951b36fa2f8d9dd6e1af6da2fcaba13d7c866cf6a9e65deda9dc6c5fe4" -dependencies = [ - "anyhow", - "chrono", - "derive_more 2.0.1", - "futures 0.3.31", - "log", - "parking_lot", - "schemars", - "semver", - "serde", - "serde_json", -] - [[package]] name = "ahash" version = "0.7.8" @@ -876,7 +857,7 @@ dependencies = [ "anyhow", "async-trait", "collections", - "derive_more 0.99.19", + "derive_more", "extension", "futures 0.3.31", "gpui", @@ -939,7 +920,7 @@ dependencies = [ "clock", "collections", "ctor", - "derive_more 0.99.19", + "derive_more", "gpui", "icons", "indoc", @@ -976,7 +957,7 @@ dependencies = [ "cloud_llm_client", "collections", "component", - "derive_more 0.99.19", + "derive_more", "diffy", "editor", "feature_flags", @@ -3088,7 +3069,7 @@ dependencies = [ "cocoa 0.26.0", "collections", "credentials_provider", - "derive_more 0.99.19", + "derive_more", "feature_flags", "fs", "futures 0.3.31", @@ -3520,7 +3501,7 @@ name = "command_palette_hooks" version = "0.1.0" dependencies = [ "collections", - "derive_more 0.99.19", + "derive_more", "gpui", "workspace-hack", ] @@ -4681,27 +4662,6 @@ dependencies = [ "syn 2.0.101", ] -[[package]] -name = "derive_more" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", - "unicode-xid", -] - [[package]] name = "derive_refineable" version = "0.1.0" @@ -6442,7 +6402,7 @@ dependencies = [ "askpass", "async-trait", "collections", - "derive_more 0.99.19", + "derive_more", "futures 0.3.31", "git2", "gpui", @@ -7472,7 +7432,7 @@ dependencies = [ "core-video", "cosmic-text", "ctor", - "derive_more 0.99.19", + "derive_more", "embed-resource", "env_logger 0.11.8", "etagere", @@ -7997,7 +7957,7 @@ version = "0.1.0" dependencies = [ "anyhow", "bytes 1.10.1", - "derive_more 0.99.19", + "derive_more", "futures 0.3.31", "http 1.3.1", "http-body 1.0.1", @@ -14392,12 +14352,10 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe8c9d1c68d67dd9f97ecbc6f932b60eb289c5dbddd8aa1405484a8fd2fcd984" dependencies = [ - "chrono", "dyn-clone", "indexmap", "ref-cast", "schemars_derive", - "semver", "serde", "serde_json", ] @@ -16466,7 +16424,7 @@ version = "0.1.0" dependencies = [ "anyhow", "collections", - "derive_more 0.99.19", + "derive_more", "fs", "futures 0.3.31", "gpui", @@ -19981,7 +19939,6 @@ dependencies = [ "rustix 1.0.7", "rustls 0.23.26", "rustls-webpki 0.103.1", - "schemars", "scopeguard", "sea-orm", "sea-query-binder", diff --git a/Cargo.toml b/Cargo.toml index a645029b43..6cd6dec791 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -424,7 +424,6 @@ zlog_settings = { path = "crates/zlog_settings" } # External crates # -agentic-coding-protocol = "0.0.10" agent-client-protocol = "0.0.31" aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" } diff --git a/crates/agent_servers/Cargo.toml b/crates/agent_servers/Cargo.toml index 8ea4a27f4c..9f90f3a78a 100644 --- a/crates/agent_servers/Cargo.toml +++ b/crates/agent_servers/Cargo.toml @@ -22,7 +22,6 @@ acp_thread.workspace = true action_log.workspace = true agent-client-protocol.workspace = true agent_settings.workspace = true -agentic-coding-protocol.workspace = true anyhow.workspace = true client = { workspace = true, optional = true } collections.workspace = true diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 1cfb1fcabf..a99a401431 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -1,34 +1,391 @@ -use std::{path::Path, rc::Rc}; - use crate::AgentServerCommand; use acp_thread::AgentConnection; -use anyhow::Result; -use gpui::AsyncApp; +use acp_tools::AcpConnectionRegistry; +use action_log::ActionLog; +use agent_client_protocol::{self as acp, Agent as _, ErrorCode}; +use anyhow::anyhow; +use collections::HashMap; +use futures::AsyncBufReadExt as _; +use futures::channel::oneshot; +use futures::io::BufReader; +use project::Project; +use serde::Deserialize; +use std::{any::Any, cell::RefCell}; +use std::{path::Path, rc::Rc}; use thiserror::Error; -mod v0; -mod v1; +use anyhow::{Context as _, Result}; +use gpui::{App, AppContext as _, AsyncApp, Entity, Task, WeakEntity}; + +use acp_thread::{AcpThread, AuthRequired, LoadError}; #[derive(Debug, Error)] #[error("Unsupported version")] pub struct UnsupportedVersion; +pub struct AcpConnection { + server_name: &'static str, + connection: Rc, + sessions: Rc>>, + auth_methods: Vec, + prompt_capabilities: acp::PromptCapabilities, + _io_task: Task>, +} + +pub struct AcpSession { + thread: WeakEntity, + suppress_abort_err: bool, +} + pub async fn connect( server_name: &'static str, command: AgentServerCommand, root_dir: &Path, cx: &mut AsyncApp, ) -> Result> { - let conn = v1::AcpConnection::stdio(server_name, command.clone(), root_dir, cx).await; + let conn = AcpConnection::stdio(server_name, command.clone(), root_dir, cx).await?; + Ok(Rc::new(conn) as _) +} - match conn { - Ok(conn) => Ok(Rc::new(conn) as _), - Err(err) if err.is::() => { - // Consider re-using initialize response and subprocess when adding another version here - let conn: Rc = - Rc::new(v0::AcpConnection::stdio(server_name, command, root_dir, cx).await?); - Ok(conn) +const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::V1; + +impl AcpConnection { + pub async fn stdio( + server_name: &'static str, + command: AgentServerCommand, + root_dir: &Path, + cx: &mut AsyncApp, + ) -> Result { + let mut child = util::command::new_smol_command(&command.path) + .args(command.args.iter().map(|arg| arg.as_str())) + .envs(command.env.iter().flatten()) + .current_dir(root_dir) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn()?; + + let stdout = child.stdout.take().context("Failed to take stdout")?; + let stdin = child.stdin.take().context("Failed to take stdin")?; + let stderr = child.stderr.take().context("Failed to take stderr")?; + log::trace!("Spawned (pid: {})", child.id()); + + let sessions = Rc::new(RefCell::new(HashMap::default())); + + let client = ClientDelegate { + sessions: sessions.clone(), + cx: cx.clone(), + }; + let (connection, io_task) = acp::ClientSideConnection::new(client, stdin, stdout, { + let foreground_executor = cx.foreground_executor().clone(); + move |fut| { + foreground_executor.spawn(fut).detach(); + } + }); + + let io_task = cx.background_spawn(io_task); + + cx.background_spawn(async move { + let mut stderr = BufReader::new(stderr); + let mut line = String::new(); + while let Ok(n) = stderr.read_line(&mut line).await + && n > 0 + { + log::warn!("agent stderr: {}", &line); + line.clear(); + } + }) + .detach(); + + cx.spawn({ + let sessions = sessions.clone(); + async move |cx| { + let status = child.status().await?; + + for session in sessions.borrow().values() { + session + .thread + .update(cx, |thread, cx| { + thread.emit_load_error(LoadError::Exited { status }, cx) + }) + .ok(); + } + + anyhow::Ok(()) + } + }) + .detach(); + + let connection = Rc::new(connection); + + cx.update(|cx| { + AcpConnectionRegistry::default_global(cx).update(cx, |registry, cx| { + registry.set_active_connection(server_name, &connection, cx) + }); + })?; + + let response = connection + .initialize(acp::InitializeRequest { + protocol_version: acp::VERSION, + client_capabilities: acp::ClientCapabilities { + fs: acp::FileSystemCapability { + read_text_file: true, + write_text_file: true, + }, + }, + }) + .await?; + + if response.protocol_version < MINIMUM_SUPPORTED_VERSION { + return Err(UnsupportedVersion.into()); } - Err(err) => Err(err), + + Ok(Self { + auth_methods: response.auth_methods, + connection, + server_name, + sessions, + prompt_capabilities: response.agent_capabilities.prompt_capabilities, + _io_task: io_task, + }) + } +} + +impl AgentConnection for AcpConnection { + fn new_thread( + self: Rc, + project: Entity, + cwd: &Path, + cx: &mut App, + ) -> Task>> { + let conn = self.connection.clone(); + let sessions = self.sessions.clone(); + let cwd = cwd.to_path_buf(); + cx.spawn(async move |cx| { + let response = conn + .new_session(acp::NewSessionRequest { + mcp_servers: vec![], + cwd, + }) + .await + .map_err(|err| { + if err.code == acp::ErrorCode::AUTH_REQUIRED.code { + let mut error = AuthRequired::new(); + + if err.message != acp::ErrorCode::AUTH_REQUIRED.message { + error = error.with_description(err.message); + } + + anyhow!(error) + } else { + anyhow!(err) + } + })?; + + let session_id = response.session_id; + let action_log = cx.new(|_| ActionLog::new(project.clone()))?; + let thread = cx.new(|_cx| { + AcpThread::new( + self.server_name, + self.clone(), + project, + action_log, + session_id.clone(), + ) + })?; + + let session = AcpSession { + thread: thread.downgrade(), + suppress_abort_err: false, + }; + sessions.borrow_mut().insert(session_id, session); + + Ok(thread) + }) + } + + fn auth_methods(&self) -> &[acp::AuthMethod] { + &self.auth_methods + } + + fn authenticate(&self, method_id: acp::AuthMethodId, cx: &mut App) -> Task> { + let conn = self.connection.clone(); + cx.foreground_executor().spawn(async move { + let result = conn + .authenticate(acp::AuthenticateRequest { + method_id: method_id.clone(), + }) + .await?; + + Ok(result) + }) + } + + fn prompt( + &self, + _id: Option, + params: acp::PromptRequest, + cx: &mut App, + ) -> Task> { + let conn = self.connection.clone(); + let sessions = self.sessions.clone(); + let session_id = params.session_id.clone(); + cx.foreground_executor().spawn(async move { + let result = conn.prompt(params).await; + + let mut suppress_abort_err = false; + + if let Some(session) = sessions.borrow_mut().get_mut(&session_id) { + suppress_abort_err = session.suppress_abort_err; + session.suppress_abort_err = false; + } + + match result { + Ok(response) => Ok(response), + Err(err) => { + if err.code != ErrorCode::INTERNAL_ERROR.code { + anyhow::bail!(err) + } + + let Some(data) = &err.data else { + anyhow::bail!(err) + }; + + // Temporary workaround until the following PR is generally available: + // https://github.com/google-gemini/gemini-cli/pull/6656 + + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct ErrorDetails { + details: Box, + } + + match serde_json::from_value(data.clone()) { + Ok(ErrorDetails { details }) => { + if suppress_abort_err && details.contains("This operation was aborted") + { + Ok(acp::PromptResponse { + stop_reason: acp::StopReason::Cancelled, + }) + } else { + Err(anyhow!(details)) + } + } + Err(_) => Err(anyhow!(err)), + } + } + } + }) + } + + fn prompt_capabilities(&self) -> acp::PromptCapabilities { + self.prompt_capabilities + } + + fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) { + if let Some(session) = self.sessions.borrow_mut().get_mut(session_id) { + session.suppress_abort_err = true; + } + let conn = self.connection.clone(); + let params = acp::CancelNotification { + session_id: session_id.clone(), + }; + cx.foreground_executor() + .spawn(async move { conn.cancel(params).await }) + .detach(); + } + + fn into_any(self: Rc) -> Rc { + self + } +} + +struct ClientDelegate { + sessions: Rc>>, + cx: AsyncApp, +} + +impl acp::Client for ClientDelegate { + async fn request_permission( + &self, + arguments: acp::RequestPermissionRequest, + ) -> Result { + let cx = &mut self.cx.clone(); + let rx = self + .sessions + .borrow() + .get(&arguments.session_id) + .context("Failed to get session")? + .thread + .update(cx, |thread, cx| { + thread.request_tool_call_authorization(arguments.tool_call, arguments.options, cx) + })?; + + let result = rx?.await; + + let outcome = match result { + Ok(option) => acp::RequestPermissionOutcome::Selected { option_id: option }, + Err(oneshot::Canceled) => acp::RequestPermissionOutcome::Cancelled, + }; + + Ok(acp::RequestPermissionResponse { outcome }) + } + + async fn write_text_file( + &self, + arguments: acp::WriteTextFileRequest, + ) -> Result<(), acp::Error> { + let cx = &mut self.cx.clone(); + let task = self + .sessions + .borrow() + .get(&arguments.session_id) + .context("Failed to get session")? + .thread + .update(cx, |thread, cx| { + thread.write_text_file(arguments.path, arguments.content, cx) + })?; + + task.await?; + + Ok(()) + } + + async fn read_text_file( + &self, + arguments: acp::ReadTextFileRequest, + ) -> Result { + let cx = &mut self.cx.clone(); + let task = self + .sessions + .borrow() + .get(&arguments.session_id) + .context("Failed to get session")? + .thread + .update(cx, |thread, cx| { + thread.read_text_file(arguments.path, arguments.line, arguments.limit, false, cx) + })?; + + let content = task.await?; + + Ok(acp::ReadTextFileResponse { content }) + } + + async fn session_notification( + &self, + notification: acp::SessionNotification, + ) -> Result<(), acp::Error> { + let cx = &mut self.cx.clone(); + let sessions = self.sessions.borrow(); + let session = sessions + .get(¬ification.session_id) + .context("Failed to get session")?; + + session.thread.update(cx, |thread, cx| { + thread.handle_session_update(notification.update, cx) + })??; + + Ok(()) } } diff --git a/tooling/workspace-hack/Cargo.toml b/tooling/workspace-hack/Cargo.toml index bf44fc195e..2f9a963abc 100644 --- a/tooling/workspace-hack/Cargo.toml +++ b/tooling/workspace-hack/Cargo.toml @@ -109,7 +109,6 @@ rustc-hash = { version = "1" } rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", default-features = false, features = ["fs", "net", "std"] } rustls = { version = "0.23", features = ["ring"] } rustls-webpki = { version = "0.103", default-features = false, features = ["aws-lc-rs", "ring", "std"] } -schemars = { version = "1", features = ["chrono04", "indexmap2", "semver1"] } sea-orm = { version = "1", features = ["runtime-tokio-rustls", "sqlx-postgres", "sqlx-sqlite"] } sea-query-binder = { version = "0.7", default-features = false, features = ["postgres-array", "sqlx-postgres", "sqlx-sqlite", "with-bigdecimal", "with-chrono", "with-json", "with-rust_decimal", "with-time", "with-uuid"] } semver = { version = "1", features = ["serde"] } @@ -244,7 +243,6 @@ rustc-hash = { version = "1" } rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", default-features = false, features = ["fs", "net", "std"] } rustls = { version = "0.23", features = ["ring"] } rustls-webpki = { version = "0.103", default-features = false, features = ["aws-lc-rs", "ring", "std"] } -schemars = { version = "1", features = ["chrono04", "indexmap2", "semver1"] } sea-orm = { version = "1", features = ["runtime-tokio-rustls", "sqlx-postgres", "sqlx-sqlite"] } sea-query-binder = { version = "0.7", default-features = false, features = ["postgres-array", "sqlx-postgres", "sqlx-sqlite", "with-bigdecimal", "with-chrono", "with-json", "with-rust_decimal", "with-time", "with-uuid"] } semver = { version = "1", features = ["serde"] } From 7bf6cc058ca778e528c1c1986b4c78728d615bfb Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Sat, 23 Aug 2025 01:21:20 -0400 Subject: [PATCH 55/89] acp: Eagerly load all kinds of mentions (#36741) This PR makes it so that all kinds of @-mentions start loading their context as soon as they are confirmed. Previously, we were waiting to load the context for file, symbol, selection, and rule mentions until the user's message was sent. By kicking off loading immediately for these kinds of context, we can support adding selections from unsaved buffers, and we make the semantics of @-mentions more consistent. Loading all kinds of context eagerly also makes it possible to simplify the structure of the MentionSet and the code around it. Now MentionSet is just a single hash map, all the management of creases happens in a uniform way in `MessageEditor::confirm_completion`, and the helper methods for loading different kinds of context are much more focused and orthogonal. Release Notes: - N/A --------- Co-authored-by: Conrad --- crates/acp_thread/src/mention.rs | 154 +- crates/agent/src/thread_store.rs | 13 +- crates/agent2/src/db.rs | 13 +- crates/agent2/src/thread.rs | 54 +- .../agent_ui/src/acp/completion_provider.rs | 4 +- crates/agent_ui/src/acp/message_editor.rs | 1252 +++++++---------- crates/agent_ui/src/acp/thread_view.rs | 46 +- 7 files changed, 699 insertions(+), 837 deletions(-) diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index a1e713cffa..6fa0887e22 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -5,7 +5,7 @@ use prompt_store::{PromptId, UserPromptId}; use serde::{Deserialize, Serialize}; use std::{ fmt, - ops::Range, + ops::RangeInclusive, path::{Path, PathBuf}, str::FromStr, }; @@ -17,13 +17,14 @@ pub enum MentionUri { File { abs_path: PathBuf, }, + PastedImage, Directory { abs_path: PathBuf, }, Symbol { - path: PathBuf, + abs_path: PathBuf, name: String, - line_range: Range, + line_range: RangeInclusive, }, Thread { id: acp::SessionId, @@ -38,8 +39,9 @@ pub enum MentionUri { name: String, }, Selection { - path: PathBuf, - line_range: Range, + #[serde(default, skip_serializing_if = "Option::is_none")] + abs_path: Option, + line_range: RangeInclusive, }, Fetch { url: Url, @@ -48,36 +50,44 @@ pub enum MentionUri { impl MentionUri { pub fn parse(input: &str) -> Result { + fn parse_line_range(fragment: &str) -> Result> { + let range = fragment + .strip_prefix("L") + .context("Line range must start with \"L\"")?; + let (start, end) = range + .split_once(":") + .context("Line range must use colon as separator")?; + let range = start + .parse::() + .context("Parsing line range start")? + .checked_sub(1) + .context("Line numbers should be 1-based")? + ..=end + .parse::() + .context("Parsing line range end")? + .checked_sub(1) + .context("Line numbers should be 1-based")?; + Ok(range) + } + let url = url::Url::parse(input)?; let path = url.path(); match url.scheme() { "file" => { let path = url.to_file_path().ok().context("Extracting file path")?; if let Some(fragment) = url.fragment() { - let range = fragment - .strip_prefix("L") - .context("Line range must start with \"L\"")?; - let (start, end) = range - .split_once(":") - .context("Line range must use colon as separator")?; - let line_range = start - .parse::() - .context("Parsing line range start")? - .checked_sub(1) - .context("Line numbers should be 1-based")? - ..end - .parse::() - .context("Parsing line range end")? - .checked_sub(1) - .context("Line numbers should be 1-based")?; + let line_range = parse_line_range(fragment)?; if let Some(name) = single_query_param(&url, "symbol")? { Ok(Self::Symbol { name, - path, + abs_path: path, line_range, }) } else { - Ok(Self::Selection { path, line_range }) + Ok(Self::Selection { + abs_path: Some(path), + line_range, + }) } } else if input.ends_with("/") { Ok(Self::Directory { abs_path: path }) @@ -105,6 +115,17 @@ impl MentionUri { id: rule_id.into(), name, }) + } else if path.starts_with("/agent/pasted-image") { + Ok(Self::PastedImage) + } else if path.starts_with("/agent/untitled-buffer") { + let fragment = url + .fragment() + .context("Missing fragment for untitled buffer selection")?; + let line_range = parse_line_range(fragment)?; + Ok(Self::Selection { + abs_path: None, + line_range, + }) } else { bail!("invalid zed url: {:?}", input); } @@ -121,13 +142,16 @@ impl MentionUri { .unwrap_or_default() .to_string_lossy() .into_owned(), + MentionUri::PastedImage => "Image".to_string(), MentionUri::Symbol { name, .. } => name.clone(), MentionUri::Thread { name, .. } => name.clone(), MentionUri::TextThread { name, .. } => name.clone(), MentionUri::Rule { name, .. } => name.clone(), MentionUri::Selection { - path, line_range, .. - } => selection_name(path, line_range), + abs_path: path, + line_range, + .. + } => selection_name(path.as_deref(), line_range), MentionUri::Fetch { url } => url.to_string(), } } @@ -137,6 +161,7 @@ impl MentionUri { MentionUri::File { abs_path } => { FileIcons::get_icon(abs_path, cx).unwrap_or_else(|| IconName::File.path().into()) } + MentionUri::PastedImage => IconName::Image.path().into(), MentionUri::Directory { .. } => FileIcons::get_folder_icon(false, cx) .unwrap_or_else(|| IconName::Folder.path().into()), MentionUri::Symbol { .. } => IconName::Code.path().into(), @@ -157,29 +182,40 @@ impl MentionUri { MentionUri::File { abs_path } => { Url::from_file_path(abs_path).expect("mention path should be absolute") } + MentionUri::PastedImage => Url::parse("zed:///agent/pasted-image").unwrap(), MentionUri::Directory { abs_path } => { Url::from_directory_path(abs_path).expect("mention path should be absolute") } MentionUri::Symbol { - path, + abs_path, name, line_range, } => { - let mut url = Url::from_file_path(path).expect("mention path should be absolute"); + let mut url = + Url::from_file_path(abs_path).expect("mention path should be absolute"); url.query_pairs_mut().append_pair("symbol", name); url.set_fragment(Some(&format!( "L{}:{}", - line_range.start + 1, - line_range.end + 1 + line_range.start() + 1, + line_range.end() + 1 ))); url } - MentionUri::Selection { path, line_range } => { - let mut url = Url::from_file_path(path).expect("mention path should be absolute"); + MentionUri::Selection { + abs_path: path, + line_range, + } => { + let mut url = if let Some(path) = path { + Url::from_file_path(path).expect("mention path should be absolute") + } else { + let mut url = Url::parse("zed:///").unwrap(); + url.set_path("/agent/untitled-buffer"); + url + }; url.set_fragment(Some(&format!( "L{}:{}", - line_range.start + 1, - line_range.end + 1 + line_range.start() + 1, + line_range.end() + 1 ))); url } @@ -191,7 +227,10 @@ impl MentionUri { } MentionUri::TextThread { path, name } => { let mut url = Url::parse("zed:///").unwrap(); - url.set_path(&format!("/agent/text-thread/{}", path.to_string_lossy())); + url.set_path(&format!( + "/agent/text-thread/{}", + path.to_string_lossy().trim_start_matches('/') + )); url.query_pairs_mut().append_pair("name", name); url } @@ -237,12 +276,14 @@ fn single_query_param(url: &Url, name: &'static str) -> Result> { } } -pub fn selection_name(path: &Path, line_range: &Range) -> String { +pub fn selection_name(path: Option<&Path>, line_range: &RangeInclusive) -> String { format!( "{} ({}:{})", - path.file_name().unwrap_or_default().display(), - line_range.start + 1, - line_range.end + 1 + path.and_then(|path| path.file_name()) + .unwrap_or("Untitled".as_ref()) + .display(), + *line_range.start() + 1, + *line_range.end() + 1 ) } @@ -302,14 +343,14 @@ mod tests { let parsed = MentionUri::parse(symbol_uri).unwrap(); match &parsed { MentionUri::Symbol { - path, + abs_path: path, name, line_range, } => { assert_eq!(path.to_str().unwrap(), path!("/path/to/file.rs")); assert_eq!(name, "MySymbol"); - assert_eq!(line_range.start, 9); - assert_eq!(line_range.end, 19); + assert_eq!(line_range.start(), &9); + assert_eq!(line_range.end(), &19); } _ => panic!("Expected Symbol variant"), } @@ -321,16 +362,39 @@ mod tests { let selection_uri = uri!("file:///path/to/file.rs#L5:15"); let parsed = MentionUri::parse(selection_uri).unwrap(); match &parsed { - MentionUri::Selection { path, line_range } => { - assert_eq!(path.to_str().unwrap(), path!("/path/to/file.rs")); - assert_eq!(line_range.start, 4); - assert_eq!(line_range.end, 14); + MentionUri::Selection { + abs_path: path, + line_range, + } => { + assert_eq!( + path.as_ref().unwrap().to_str().unwrap(), + path!("/path/to/file.rs") + ); + assert_eq!(line_range.start(), &4); + assert_eq!(line_range.end(), &14); } _ => panic!("Expected Selection variant"), } assert_eq!(parsed.to_uri().to_string(), selection_uri); } + #[test] + fn test_parse_untitled_selection_uri() { + let selection_uri = uri!("zed:///agent/untitled-buffer#L1:10"); + let parsed = MentionUri::parse(selection_uri).unwrap(); + match &parsed { + MentionUri::Selection { + abs_path: None, + line_range, + } => { + assert_eq!(line_range.start(), &0); + assert_eq!(line_range.end(), &9); + } + _ => panic!("Expected Selection variant without path"), + } + assert_eq!(parsed.to_uri().to_string(), selection_uri); + } + #[test] fn test_parse_thread_uri() { let thread_uri = "zed:///agent/thread/session123?name=Thread+name"; diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs index 45e551dbdf..cba2457566 100644 --- a/crates/agent/src/thread_store.rs +++ b/crates/agent/src/thread_store.rs @@ -893,8 +893,19 @@ impl ThreadsDatabase { let needs_migration_from_heed = mdb_path.exists(); - let connection = if *ZED_STATELESS || cfg!(any(feature = "test-support", test)) { + let connection = if *ZED_STATELESS { Connection::open_memory(Some("THREAD_FALLBACK_DB")) + } else if cfg!(any(feature = "test-support", test)) { + // rust stores the name of the test on the current thread. + // We use this to automatically create a database that will + // be shared within the test (for the test_retrieve_old_thread) + // but not with concurrent tests. + let thread = std::thread::current(); + let test_name = thread.name(); + Connection::open_memory(Some(&format!( + "THREAD_FALLBACK_{}", + test_name.unwrap_or_default() + ))) } else { Connection::open_file(&sqlite_path.to_string_lossy()) }; diff --git a/crates/agent2/src/db.rs b/crates/agent2/src/db.rs index 1b88955a24..e7d31c0c7a 100644 --- a/crates/agent2/src/db.rs +++ b/crates/agent2/src/db.rs @@ -266,8 +266,19 @@ impl ThreadsDatabase { } pub fn new(executor: BackgroundExecutor) -> Result { - let connection = if *ZED_STATELESS || cfg!(any(feature = "test-support", test)) { + let connection = if *ZED_STATELESS { Connection::open_memory(Some("THREAD_FALLBACK_DB")) + } else if cfg!(any(feature = "test-support", test)) { + // rust stores the name of the test on the current thread. + // We use this to automatically create a database that will + // be shared within the test (for the test_retrieve_old_thread) + // but not with concurrent tests. + let thread = std::thread::current(); + let test_name = thread.name(); + Connection::open_memory(Some(&format!( + "THREAD_FALLBACK_{}", + test_name.unwrap_or_default() + ))) } else { let threads_dir = paths::data_dir().join("threads"); std::fs::create_dir_all(&threads_dir)?; diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index c89e5875f9..6d616f73fc 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -45,14 +45,15 @@ use schemars::{JsonSchema, Schema}; use serde::{Deserialize, Serialize}; use settings::{Settings, update_settings_file}; use smol::stream::StreamExt; +use std::fmt::Write; use std::{ collections::BTreeMap, + ops::RangeInclusive, path::Path, sync::Arc, time::{Duration, Instant}, }; -use std::{fmt::Write, ops::Range}; -use util::{ResultExt, markdown::MarkdownCodeBlock}; +use util::{ResultExt, debug_panic, markdown::MarkdownCodeBlock}; use uuid::Uuid; const TOOL_CANCELED_MESSAGE: &str = "Tool canceled by user"; @@ -187,6 +188,7 @@ impl UserMessage { const OPEN_FILES_TAG: &str = ""; const OPEN_DIRECTORIES_TAG: &str = ""; const OPEN_SYMBOLS_TAG: &str = ""; + const OPEN_SELECTIONS_TAG: &str = ""; const OPEN_THREADS_TAG: &str = ""; const OPEN_FETCH_TAG: &str = ""; const OPEN_RULES_TAG: &str = @@ -195,6 +197,7 @@ impl UserMessage { let mut file_context = OPEN_FILES_TAG.to_string(); let mut directory_context = OPEN_DIRECTORIES_TAG.to_string(); let mut symbol_context = OPEN_SYMBOLS_TAG.to_string(); + let mut selection_context = OPEN_SELECTIONS_TAG.to_string(); let mut thread_context = OPEN_THREADS_TAG.to_string(); let mut fetch_context = OPEN_FETCH_TAG.to_string(); let mut rules_context = OPEN_RULES_TAG.to_string(); @@ -211,7 +214,7 @@ impl UserMessage { match uri { MentionUri::File { abs_path } => { write!( - &mut symbol_context, + &mut file_context, "\n{}", MarkdownCodeBlock { tag: &codeblock_tag(abs_path, None), @@ -220,17 +223,19 @@ impl UserMessage { ) .ok(); } + MentionUri::PastedImage => { + debug_panic!("pasted image URI should not be used in mention content") + } MentionUri::Directory { .. } => { write!(&mut directory_context, "\n{}\n", content).ok(); } MentionUri::Symbol { - path, line_range, .. - } - | MentionUri::Selection { - path, line_range, .. + abs_path: path, + line_range, + .. } => { write!( - &mut rules_context, + &mut symbol_context, "\n{}", MarkdownCodeBlock { tag: &codeblock_tag(path, Some(line_range)), @@ -239,6 +244,24 @@ impl UserMessage { ) .ok(); } + MentionUri::Selection { + abs_path: path, + line_range, + .. + } => { + write!( + &mut selection_context, + "\n{}", + MarkdownCodeBlock { + tag: &codeblock_tag( + path.as_deref().unwrap_or("Untitled".as_ref()), + Some(line_range) + ), + text: content + } + ) + .ok(); + } MentionUri::Thread { .. } => { write!(&mut thread_context, "\n{}\n", content).ok(); } @@ -291,6 +314,13 @@ impl UserMessage { .push(language_model::MessageContent::Text(symbol_context)); } + if selection_context.len() > OPEN_SELECTIONS_TAG.len() { + selection_context.push_str("\n"); + message + .content + .push(language_model::MessageContent::Text(selection_context)); + } + if thread_context.len() > OPEN_THREADS_TAG.len() { thread_context.push_str("\n"); message @@ -326,7 +356,7 @@ impl UserMessage { } } -fn codeblock_tag(full_path: &Path, line_range: Option<&Range>) -> String { +fn codeblock_tag(full_path: &Path, line_range: Option<&RangeInclusive>) -> String { let mut result = String::new(); if let Some(extension) = full_path.extension().and_then(|ext| ext.to_str()) { @@ -336,10 +366,10 @@ fn codeblock_tag(full_path: &Path, line_range: Option<&Range>) -> String { let _ = write!(result, "{}", full_path.display()); if let Some(range) = line_range { - if range.start == range.end { - let _ = write!(result, ":{}", range.start + 1); + if range.start() == range.end() { + let _ = write!(result, ":{}", range.start() + 1); } else { - let _ = write!(result, ":{}-{}", range.start + 1, range.end + 1); + let _ = write!(result, ":{}-{}", range.start() + 1, range.end() + 1); } } diff --git a/crates/agent_ui/src/acp/completion_provider.rs b/crates/agent_ui/src/acp/completion_provider.rs index 22a9ea6773..5b40967069 100644 --- a/crates/agent_ui/src/acp/completion_provider.rs +++ b/crates/agent_ui/src/acp/completion_provider.rs @@ -247,9 +247,9 @@ impl ContextPickerCompletionProvider { let abs_path = project.read(cx).absolute_path(&symbol.path, cx)?; let uri = MentionUri::Symbol { - path: abs_path, + abs_path, name: symbol.name.clone(), - line_range: symbol.range.start.0.row..symbol.range.end.0.row, + line_range: symbol.range.start.0.row..=symbol.range.end.0.row, }; let new_text = format!("{} ", uri.as_link()); let new_text_len = new_text.len(); diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index b82f1968a7..5a8abeb07a 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -6,7 +6,7 @@ use acp_thread::{MentionUri, selection_name}; use agent_client_protocol as acp; use agent_servers::AgentServer; use agent2::HistoryStore; -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Result, anyhow}; use assistant_slash_commands::codeblock_fence_for_path; use collections::{HashMap, HashSet}; use editor::{ @@ -17,8 +17,8 @@ use editor::{ display_map::{Crease, CreaseId, FoldId}, }; use futures::{ - FutureExt as _, TryFutureExt as _, - future::{Shared, join_all, try_join_all}, + FutureExt as _, + future::{Shared, join_all}, }; use gpui::{ AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable, @@ -28,14 +28,14 @@ use gpui::{ use language::{Buffer, Language}; use language_model::LanguageModelImage; use project::{CompletionIntent, Project, ProjectItem, ProjectPath, Worktree}; -use prompt_store::PromptStore; +use prompt_store::{PromptId, PromptStore}; use rope::Point; use settings::Settings; use std::{ cell::Cell, ffi::OsStr, fmt::Write, - ops::Range, + ops::{Range, RangeInclusive}, path::{Path, PathBuf}, rc::Rc, sync::Arc, @@ -49,12 +49,8 @@ use ui::{ Render, SelectableButton, SharedString, Styled, TextSize, TintColor, Toggleable, Window, div, h_flex, px, }; -use url::Url; -use util::ResultExt; -use workspace::{ - Toast, Workspace, - notifications::{NotificationId, NotifyResultExt as _}, -}; +use util::{ResultExt, debug_panic}; +use workspace::{Workspace, notifications::NotifyResultExt as _}; use zed_actions::agent::Chat; const PARSE_SLASH_COMMAND_DEBOUNCE: Duration = Duration::from_millis(50); @@ -219,9 +215,9 @@ impl MessageEditor { pub fn mentions(&self) -> HashSet { self.mention_set - .uri_by_crease_id + .mentions .values() - .cloned() + .map(|(uri, _)| uri.clone()) .collect() } @@ -246,132 +242,168 @@ impl MessageEditor { else { return Task::ready(()); }; + let end_anchor = snapshot + .buffer_snapshot + .anchor_before(start_anchor.to_offset(&snapshot.buffer_snapshot) + content_len + 1); - if let MentionUri::File { abs_path, .. } = &mention_uri { - let extension = abs_path - .extension() - .and_then(OsStr::to_str) - .unwrap_or_default(); - - if Img::extensions().contains(&extension) && !extension.contains("svg") { - if !self.prompt_capabilities.get().image { - struct ImagesNotAllowed; - - let end_anchor = snapshot.buffer_snapshot.anchor_before( - start_anchor.to_offset(&snapshot.buffer_snapshot) + content_len + 1, - ); - - self.editor.update(cx, |editor, cx| { - // Remove mention - editor.edit([((start_anchor..end_anchor), "")], cx); - }); - - self.workspace - .update(cx, |workspace, cx| { - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - "This agent does not support images yet", - ) - .autohide(), - cx, - ); - }) - .ok(); - return Task::ready(()); - } - - let project = self.project.clone(); - let Some(project_path) = project - .read(cx) - .project_path_for_absolute_path(abs_path, cx) - else { - return Task::ready(()); - }; - let image = cx - .spawn(async move |_, cx| { - let image = project - .update(cx, |project, cx| project.open_image(project_path, cx)) - .map_err(|e| e.to_string())? - .await - .map_err(|e| e.to_string())?; - image - .read_with(cx, |image, _cx| image.image.clone()) - .map_err(|e| e.to_string()) - }) - .shared(); - let Some(crease_id) = insert_crease_for_image( - *excerpt_id, - start, - content_len, - Some(abs_path.as_path().into()), - image.clone(), - self.editor.clone(), - window, - cx, - ) else { - return Task::ready(()); - }; - return self.confirm_mention_for_image( - crease_id, - start_anchor, - Some(abs_path.clone()), - image, - window, - cx, - ); - } - } - - let Some(crease_id) = crate::context_picker::insert_crease_for_mention( - *excerpt_id, - start, - content_len, - crease_text, - mention_uri.icon_path(cx), - self.editor.clone(), - window, - cx, - ) else { + let crease_id = if let MentionUri::File { abs_path } = &mention_uri + && let Some(extension) = abs_path.extension() + && let Some(extension) = extension.to_str() + && Img::extensions().contains(&extension) + && !extension.contains("svg") + { + let Some(project_path) = self + .project + .read(cx) + .project_path_for_absolute_path(&abs_path, cx) + else { + log::error!("project path not found"); + return Task::ready(()); + }; + let image = self + .project + .update(cx, |project, cx| project.open_image(project_path, cx)); + let image = cx + .spawn(async move |_, cx| { + let image = image.await.map_err(|e| e.to_string())?; + let image = image + .update(cx, |image, _| image.image.clone()) + .map_err(|e| e.to_string())?; + Ok(image) + }) + .shared(); + insert_crease_for_image( + *excerpt_id, + start, + content_len, + Some(abs_path.as_path().into()), + image, + self.editor.clone(), + window, + cx, + ) + } else { + crate::context_picker::insert_crease_for_mention( + *excerpt_id, + start, + content_len, + crease_text, + mention_uri.icon_path(cx), + self.editor.clone(), + window, + cx, + ) + }; + let Some(crease_id) = crease_id else { return Task::ready(()); }; - match mention_uri { - MentionUri::Fetch { url } => { - self.confirm_mention_for_fetch(crease_id, start_anchor, url, window, cx) + let task = match mention_uri.clone() { + MentionUri::Fetch { url } => self.confirm_mention_for_fetch(url, cx), + MentionUri::Directory { abs_path } => self.confirm_mention_for_directory(abs_path, cx), + MentionUri::Thread { id, .. } => self.confirm_mention_for_thread(id, cx), + MentionUri::TextThread { path, .. } => self.confirm_mention_for_text_thread(path, cx), + MentionUri::File { abs_path } => self.confirm_mention_for_file(abs_path, cx), + MentionUri::Symbol { + abs_path, + line_range, + .. + } => self.confirm_mention_for_symbol(abs_path, line_range, cx), + MentionUri::Rule { id, .. } => self.confirm_mention_for_rule(id, cx), + MentionUri::PastedImage => { + debug_panic!("pasted image URI should not be included in completions"); + Task::ready(Err(anyhow!( + "pasted imaged URI should not be included in completions" + ))) } - MentionUri::Directory { abs_path } => { - self.confirm_mention_for_directory(crease_id, start_anchor, abs_path, window, cx) + MentionUri::Selection { .. } => { + // Handled elsewhere + debug_panic!("unexpected selection URI"); + Task::ready(Err(anyhow!("unexpected selection URI"))) } - MentionUri::Thread { id, name } => { - self.confirm_mention_for_thread(crease_id, start_anchor, id, name, window, cx) + }; + let task = cx + .spawn(async move |_, _| task.await.map_err(|e| e.to_string())) + .shared(); + self.mention_set + .mentions + .insert(crease_id, (mention_uri, task.clone())); + + // Notify the user if we failed to load the mentioned context + cx.spawn_in(window, async move |this, cx| { + if task.await.notify_async_err(cx).is_none() { + this.update(cx, |this, cx| { + this.editor.update(cx, |editor, cx| { + // Remove mention + editor.edit([(start_anchor..end_anchor, "")], cx); + }); + this.mention_set.mentions.remove(&crease_id); + }) + .ok(); } - MentionUri::TextThread { path, name } => self.confirm_mention_for_text_thread( - crease_id, - start_anchor, - path, - name, - window, - cx, - ), - MentionUri::File { .. } - | MentionUri::Symbol { .. } - | MentionUri::Rule { .. } - | MentionUri::Selection { .. } => { - self.mention_set.insert_uri(crease_id, mention_uri.clone()); - Task::ready(()) + }) + } + + fn confirm_mention_for_file( + &mut self, + abs_path: PathBuf, + cx: &mut Context, + ) -> Task> { + let Some(project_path) = self + .project + .read(cx) + .project_path_for_absolute_path(&abs_path, cx) + else { + return Task::ready(Err(anyhow!("project path not found"))); + }; + let extension = abs_path + .extension() + .and_then(OsStr::to_str) + .unwrap_or_default(); + + if Img::extensions().contains(&extension) && !extension.contains("svg") { + if !self.prompt_capabilities.get().image { + return Task::ready(Err(anyhow!("This agent does not support images yet"))); } + let task = self + .project + .update(cx, |project, cx| project.open_image(project_path, cx)); + return cx.spawn(async move |_, cx| { + let image = task.await?; + let image = image.update(cx, |image, _| image.image.clone())?; + let format = image.format; + let image = cx + .update(|cx| LanguageModelImage::from_image(image, cx))? + .await; + if let Some(image) = image { + Ok(Mention::Image(MentionImage { + data: image.source, + format, + })) + } else { + Err(anyhow!("Failed to convert image")) + } + }); } + + let buffer = self + .project + .update(cx, |project, cx| project.open_buffer(project_path, cx)); + cx.spawn(async move |_, cx| { + let buffer = buffer.await?; + let mention = buffer.update(cx, |buffer, cx| Mention::Text { + content: buffer.text(), + tracked_buffers: vec![cx.entity()], + })?; + anyhow::Ok(mention) + }) } fn confirm_mention_for_directory( &mut self, - crease_id: CreaseId, - anchor: Anchor, abs_path: PathBuf, - window: &mut Window, cx: &mut Context, - ) -> Task<()> { + ) -> Task> { fn collect_files_in_path(worktree: &Worktree, path: &Path) -> Vec<(Arc, PathBuf)> { let mut files = Vec::new(); @@ -386,24 +418,21 @@ impl MessageEditor { files } - let uri = MentionUri::Directory { - abs_path: abs_path.clone(), - }; let Some(project_path) = self .project .read(cx) .project_path_for_absolute_path(&abs_path, cx) else { - return Task::ready(()); + return Task::ready(Err(anyhow!("project path not found"))); }; let Some(entry) = self.project.read(cx).entry_for_path(&project_path, cx) else { - return Task::ready(()); + return Task::ready(Err(anyhow!("project entry not found"))); }; let Some(worktree) = self.project.read(cx).worktree_for_entry(entry.id, cx) else { - return Task::ready(()); + return Task::ready(Err(anyhow!("worktree not found"))); }; let project = self.project.clone(); - let task = cx.spawn(async move |_, cx| { + cx.spawn(async move |_, cx| { let directory_path = entry.path.clone(); let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id())?; @@ -453,89 +482,83 @@ impl MessageEditor { ((rel_path, full_path, rope), buffer) }) .unzip(); - (render_directory_contents(contents), tracked_buffers) + Mention::Text { + content: render_directory_contents(contents), + tracked_buffers, + } }) .await; anyhow::Ok(contents) - }); - let task = cx - .spawn(async move |_, _| task.await.map_err(|e| e.to_string())) - .shared(); - - self.mention_set - .directories - .insert(abs_path.clone(), task.clone()); - - let editor = self.editor.clone(); - cx.spawn_in(window, async move |this, cx| { - if task.await.notify_async_err(cx).is_some() { - this.update(cx, |this, _| { - this.mention_set.insert_uri(crease_id, uri); - }) - .ok(); - } else { - editor - .update(cx, |editor, cx| { - editor.display_map.update(cx, |display_map, cx| { - display_map.unfold_intersecting(vec![anchor..anchor], true, cx); - }); - editor.remove_creases([crease_id], cx); - }) - .ok(); - this.update(cx, |this, _cx| { - this.mention_set.directories.remove(&abs_path); - }) - .ok(); - } }) } fn confirm_mention_for_fetch( &mut self, - crease_id: CreaseId, - anchor: Anchor, url: url::Url, - window: &mut Window, cx: &mut Context, - ) -> Task<()> { - let Some(http_client) = self + ) -> Task> { + let http_client = match self .workspace - .update(cx, |workspace, _cx| workspace.client().http_client()) - .ok() - else { - return Task::ready(()); + .update(cx, |workspace, _| workspace.client().http_client()) + { + Ok(http_client) => http_client, + Err(e) => return Task::ready(Err(e)), }; - - let url_string = url.to_string(); - let fetch = cx - .background_executor() - .spawn(async move { - fetch_url_content(http_client, url_string) - .map_err(|e| e.to_string()) - .await + cx.background_executor().spawn(async move { + let content = fetch_url_content(http_client, url.to_string()).await?; + Ok(Mention::Text { + content, + tracked_buffers: Vec::new(), }) - .shared(); - self.mention_set - .add_fetch_result(url.clone(), fetch.clone()); + }) + } - cx.spawn_in(window, async move |this, cx| { - let fetch = fetch.await.notify_async_err(cx); - this.update(cx, |this, cx| { - if fetch.is_some() { - this.mention_set - .insert_uri(crease_id, MentionUri::Fetch { url }); - } else { - // Remove crease if we failed to fetch - this.editor.update(cx, |editor, cx| { - editor.display_map.update(cx, |display_map, cx| { - display_map.unfold_intersecting(vec![anchor..anchor], true, cx); - }); - editor.remove_creases([crease_id], cx); - }); - this.mention_set.fetch_results.remove(&url); + fn confirm_mention_for_symbol( + &mut self, + abs_path: PathBuf, + line_range: RangeInclusive, + cx: &mut Context, + ) -> Task> { + let Some(project_path) = self + .project + .read(cx) + .project_path_for_absolute_path(&abs_path, cx) + else { + return Task::ready(Err(anyhow!("project path not found"))); + }; + let buffer = self + .project + .update(cx, |project, cx| project.open_buffer(project_path, cx)); + cx.spawn(async move |_, cx| { + let buffer = buffer.await?; + let mention = buffer.update(cx, |buffer, cx| { + let start = Point::new(*line_range.start(), 0).min(buffer.max_point()); + let end = Point::new(*line_range.end() + 1, 0).min(buffer.max_point()); + let content = buffer.text_for_range(start..end).collect(); + Mention::Text { + content, + tracked_buffers: vec![cx.entity()], } + })?; + anyhow::Ok(mention) + }) + } + + fn confirm_mention_for_rule( + &mut self, + id: PromptId, + cx: &mut Context, + ) -> Task> { + let Some(prompt_store) = self.prompt_store.clone() else { + return Task::ready(Err(anyhow!("missing prompt store"))); + }; + let prompt = prompt_store.read(cx).load(id, cx); + cx.spawn(async move |_, _| { + let prompt = prompt.await?; + Ok(Mention::Text { + content: prompt, + tracked_buffers: Vec::new(), }) - .ok(); }) } @@ -560,24 +583,24 @@ impl MessageEditor { let range = snapshot.anchor_after(offset + range_to_fold.start) ..snapshot.anchor_after(offset + range_to_fold.end); - // TODO support selections from buffers with no path - let Some(project_path) = buffer.read(cx).project_path(cx) else { - continue; - }; - let Some(abs_path) = self.project.read(cx).absolute_path(&project_path, cx) else { - continue; - }; + let abs_path = buffer + .read(cx) + .project_path(cx) + .and_then(|project_path| self.project.read(cx).absolute_path(&project_path, cx)); let snapshot = buffer.read(cx).snapshot(); + let text = snapshot + .text_for_range(selection_range.clone()) + .collect::(); let point_range = selection_range.to_point(&snapshot); - let line_range = point_range.start.row..point_range.end.row; + let line_range = point_range.start.row..=point_range.end.row; let uri = MentionUri::Selection { - path: abs_path.clone(), + abs_path: abs_path.clone(), line_range: line_range.clone(), }; let crease = crate::context_picker::crease_for_mention( - selection_name(&abs_path, &line_range).into(), + selection_name(abs_path.as_deref(), &line_range).into(), uri.icon_path(cx), range, self.editor.downgrade(), @@ -589,132 +612,69 @@ impl MessageEditor { crease_ids.first().copied().unwrap() }); - self.mention_set.insert_uri(crease_id, uri); + self.mention_set.mentions.insert( + crease_id, + ( + uri, + Task::ready(Ok(Mention::Text { + content: text, + tracked_buffers: vec![buffer], + })) + .shared(), + ), + ); } } fn confirm_mention_for_thread( &mut self, - crease_id: CreaseId, - anchor: Anchor, id: acp::SessionId, - name: String, - window: &mut Window, cx: &mut Context, - ) -> Task<()> { - let uri = MentionUri::Thread { - id: id.clone(), - name, - }; + ) -> Task> { let server = Rc::new(agent2::NativeAgentServer::new( self.project.read(cx).fs().clone(), self.history_store.clone(), )); let connection = server.connect(Path::new(""), &self.project, cx); - let load_summary = cx.spawn({ - let id = id.clone(); - async move |_, cx| { - let agent = connection.await?; - let agent = agent.downcast::().unwrap(); - let summary = agent - .0 - .update(cx, |agent, cx| agent.thread_summary(id, cx))? - .await?; - anyhow::Ok(summary) - } - }); - let task = cx - .spawn(async move |_, _| load_summary.await.map_err(|e| format!("{e}"))) - .shared(); - - self.mention_set.insert_thread(id.clone(), task.clone()); - self.mention_set.insert_uri(crease_id, uri); - - let editor = self.editor.clone(); - cx.spawn_in(window, async move |this, cx| { - if task.await.notify_async_err(cx).is_none() { - editor - .update(cx, |editor, cx| { - editor.display_map.update(cx, |display_map, cx| { - display_map.unfold_intersecting(vec![anchor..anchor], true, cx); - }); - editor.remove_creases([crease_id], cx); - }) - .ok(); - this.update(cx, |this, _| { - this.mention_set.thread_summaries.remove(&id); - this.mention_set.uri_by_crease_id.remove(&crease_id); - }) - .ok(); - } + cx.spawn(async move |_, cx| { + let agent = connection.await?; + let agent = agent.downcast::().unwrap(); + let summary = agent + .0 + .update(cx, |agent, cx| agent.thread_summary(id, cx))? + .await?; + anyhow::Ok(Mention::Text { + content: summary.to_string(), + tracked_buffers: Vec::new(), + }) }) } fn confirm_mention_for_text_thread( &mut self, - crease_id: CreaseId, - anchor: Anchor, path: PathBuf, - name: String, - window: &mut Window, cx: &mut Context, - ) -> Task<()> { - let uri = MentionUri::TextThread { - path: path.clone(), - name, - }; + ) -> Task> { let context = self.history_store.update(cx, |text_thread_store, cx| { text_thread_store.load_text_thread(path.as_path().into(), cx) }); - let task = cx - .spawn(async move |_, cx| { - let context = context.await.map_err(|e| e.to_string())?; - let xml = context - .update(cx, |context, cx| context.to_xml(cx)) - .map_err(|e| e.to_string())?; - Ok(xml) + cx.spawn(async move |_, cx| { + let context = context.await?; + let xml = context.update(cx, |context, cx| context.to_xml(cx))?; + Ok(Mention::Text { + content: xml, + tracked_buffers: Vec::new(), }) - .shared(); - - self.mention_set - .insert_text_thread(path.clone(), task.clone()); - - let editor = self.editor.clone(); - cx.spawn_in(window, async move |this, cx| { - if task.await.notify_async_err(cx).is_some() { - this.update(cx, |this, _| { - this.mention_set.insert_uri(crease_id, uri); - }) - .ok(); - } else { - editor - .update(cx, |editor, cx| { - editor.display_map.update(cx, |display_map, cx| { - display_map.unfold_intersecting(vec![anchor..anchor], true, cx); - }); - editor.remove_creases([crease_id], cx); - }) - .ok(); - this.update(cx, |this, _| { - this.mention_set.text_thread_summaries.remove(&path); - }) - .ok(); - } }) } pub fn contents( &self, - window: &mut Window, cx: &mut Context, ) -> Task, Vec>)>> { - let contents = self.mention_set.contents( - &self.project, - self.prompt_store.as_ref(), - &self.prompt_capabilities.get(), - window, - cx, - ); + let contents = self + .mention_set + .contents(&self.prompt_capabilities.get(), cx); let editor = self.editor.clone(); let prevent_slash_commands = self.prevent_slash_commands; @@ -729,7 +689,7 @@ impl MessageEditor { editor.display_map.update(cx, |map, cx| { let snapshot = map.snapshot(cx); for (crease_id, crease) in snapshot.crease_snapshot.creases() { - let Some(mention) = contents.get(&crease_id) else { + let Some((uri, mention)) = contents.get(&crease_id) else { continue; }; @@ -747,7 +707,6 @@ impl MessageEditor { } let chunk = match mention { Mention::Text { - uri, content, tracked_buffers, } => { @@ -764,17 +723,25 @@ impl MessageEditor { }) } Mention::Image(mention_image) => { + let uri = match uri { + MentionUri::File { .. } => Some(uri.to_uri().to_string()), + MentionUri::PastedImage => None, + other => { + debug_panic!( + "unexpected mention uri for image: {:?}", + other + ); + None + } + }; acp::ContentBlock::Image(acp::ImageContent { annotations: None, data: mention_image.data.to_string(), mime_type: mention_image.format.mime_type().into(), - uri: mention_image - .abs_path - .as_ref() - .map(|path| format!("file://{}", path.display())), + uri, }) } - Mention::UriOnly(uri) => { + Mention::UriOnly => { acp::ContentBlock::ResourceLink(acp::ResourceLink { name: uri.name(), uri: uri.to_uri().to_string(), @@ -813,7 +780,13 @@ impl MessageEditor { pub fn clear(&mut self, window: &mut Window, cx: &mut Context) { self.editor.update(cx, |editor, cx| { editor.clear(window, cx); - editor.remove_creases(self.mention_set.drain(), cx) + editor.remove_creases( + self.mention_set + .mentions + .drain() + .map(|(crease_id, _)| crease_id), + cx, + ) }); } @@ -853,7 +826,7 @@ impl MessageEditor { } cx.stop_propagation(); - let replacement_text = "image"; + let replacement_text = MentionUri::PastedImage.as_link().to_string(); for image in images { let (excerpt_id, text_anchor, multibuffer_anchor) = self.editor.update(cx, |message_editor, cx| { @@ -876,24 +849,62 @@ impl MessageEditor { }); let content_len = replacement_text.len(); - let Some(anchor) = multibuffer_anchor else { - return; + let Some(start_anchor) = multibuffer_anchor else { + continue; }; - let task = Task::ready(Ok(Arc::new(image))).shared(); + let end_anchor = self.editor.update(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len) + }); + let image = Arc::new(image); let Some(crease_id) = insert_crease_for_image( excerpt_id, text_anchor, content_len, None.clone(), - task.clone(), + Task::ready(Ok(image.clone())).shared(), self.editor.clone(), window, cx, ) else { - return; + continue; }; - self.confirm_mention_for_image(crease_id, anchor, None, task, window, cx) - .detach(); + let task = cx + .spawn_in(window, { + async move |_, cx| { + let format = image.format; + let image = cx + .update(|_, cx| LanguageModelImage::from_image(image, cx)) + .map_err(|e| e.to_string())? + .await; + if let Some(image) = image { + Ok(Mention::Image(MentionImage { + data: image.source, + format, + })) + } else { + Err("Failed to convert image".into()) + } + } + }) + .shared(); + + self.mention_set + .mentions + .insert(crease_id, (MentionUri::PastedImage, task.clone())); + + cx.spawn_in(window, async move |this, cx| { + if task.await.notify_async_err(cx).is_none() { + this.update(cx, |this, cx| { + this.editor.update(cx, |editor, cx| { + editor.edit([(start_anchor..end_anchor, "")], cx); + }); + this.mention_set.mentions.remove(&crease_id); + }) + .ok(); + } + }) + .detach(); } } @@ -995,67 +1006,6 @@ impl MessageEditor { }) } - fn confirm_mention_for_image( - &mut self, - crease_id: CreaseId, - anchor: Anchor, - abs_path: Option, - image: Shared, String>>>, - window: &mut Window, - cx: &mut Context, - ) -> Task<()> { - let editor = self.editor.clone(); - let task = cx - .spawn_in(window, { - let abs_path = abs_path.clone(); - async move |_, cx| { - let image = image.await?; - let format = image.format; - let image = cx - .update(|_, cx| LanguageModelImage::from_image(image, cx)) - .map_err(|e| e.to_string())? - .await; - if let Some(image) = image { - Ok(MentionImage { - abs_path, - data: image.source, - format, - }) - } else { - Err("Failed to convert image".into()) - } - } - }) - .shared(); - - self.mention_set.insert_image(crease_id, task.clone()); - - cx.spawn_in(window, async move |this, cx| { - if task.await.notify_async_err(cx).is_some() { - if let Some(abs_path) = abs_path.clone() { - this.update(cx, |this, _cx| { - this.mention_set - .insert_uri(crease_id, MentionUri::File { abs_path }); - }) - .ok(); - } - } else { - editor - .update(cx, |editor, cx| { - editor.display_map.update(cx, |display_map, cx| { - display_map.unfold_intersecting(vec![anchor..anchor], true, cx); - }); - editor.remove_creases([crease_id], cx); - }) - .ok(); - this.update(cx, |this, _cx| { - this.mention_set.images.remove(&crease_id); - }) - .ok(); - } - }) - } - pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context) { self.editor.update(cx, |editor, cx| { editor.set_mode(mode); @@ -1073,7 +1023,6 @@ impl MessageEditor { let mut text = String::new(); let mut mentions = Vec::new(); - let mut images = Vec::new(); for chunk in message { match chunk { @@ -1084,26 +1033,58 @@ impl MessageEditor { resource: acp::EmbeddedResourceResource::TextResourceContents(resource), .. }) => { - if let Some(mention_uri) = MentionUri::parse(&resource.uri).log_err() { - let start = text.len(); - write!(&mut text, "{}", mention_uri.as_link()).ok(); - let end = text.len(); - mentions.push((start..end, mention_uri, resource.text)); - } + let Some(mention_uri) = MentionUri::parse(&resource.uri).log_err() else { + continue; + }; + let start = text.len(); + write!(&mut text, "{}", mention_uri.as_link()).ok(); + let end = text.len(); + mentions.push(( + start..end, + mention_uri, + Mention::Text { + content: resource.text, + tracked_buffers: Vec::new(), + }, + )); } acp::ContentBlock::ResourceLink(resource) => { if let Some(mention_uri) = MentionUri::parse(&resource.uri).log_err() { let start = text.len(); write!(&mut text, "{}", mention_uri.as_link()).ok(); let end = text.len(); - mentions.push((start..end, mention_uri, resource.uri)); + mentions.push((start..end, mention_uri, Mention::UriOnly)); } } - acp::ContentBlock::Image(content) => { + acp::ContentBlock::Image(acp::ImageContent { + uri, + data, + mime_type, + annotations: _, + }) => { + let mention_uri = if let Some(uri) = uri { + MentionUri::parse(&uri) + } else { + Ok(MentionUri::PastedImage) + }; + let Some(mention_uri) = mention_uri.log_err() else { + continue; + }; + let Some(format) = ImageFormat::from_mime_type(&mime_type) else { + log::error!("failed to parse MIME type for image: {mime_type:?}"); + continue; + }; let start = text.len(); - text.push_str("image"); + write!(&mut text, "{}", mention_uri.as_link()).ok(); let end = text.len(); - images.push((start..end, content)); + mentions.push(( + start..end, + mention_uri, + Mention::Image(MentionImage { + data: data.into(), + format, + }), + )); } acp::ContentBlock::Audio(_) | acp::ContentBlock::Resource(_) => {} } @@ -1114,9 +1095,9 @@ impl MessageEditor { editor.buffer().read(cx).snapshot(cx) }); - for (range, mention_uri, text) in mentions { + for (range, mention_uri, mention) in mentions { let anchor = snapshot.anchor_before(range.start); - let crease_id = crate::context_picker::insert_crease_for_mention( + let Some(crease_id) = crate::context_picker::insert_crease_for_mention( anchor.excerpt_id, anchor.text_anchor, range.end - range.start, @@ -1125,77 +1106,14 @@ impl MessageEditor { self.editor.clone(), window, cx, - ); - - if let Some(crease_id) = crease_id { - self.mention_set.insert_uri(crease_id, mention_uri.clone()); - } - - match mention_uri { - MentionUri::Thread { id, .. } => { - self.mention_set - .insert_thread(id, Task::ready(Ok(text.into())).shared()); - } - MentionUri::TextThread { path, .. } => { - self.mention_set - .insert_text_thread(path, Task::ready(Ok(text)).shared()); - } - MentionUri::Fetch { url } => { - self.mention_set - .add_fetch_result(url, Task::ready(Ok(text)).shared()); - } - MentionUri::Directory { abs_path } => { - let task = Task::ready(Ok((text, Vec::new()))).shared(); - self.mention_set.directories.insert(abs_path, task); - } - MentionUri::File { .. } - | MentionUri::Symbol { .. } - | MentionUri::Rule { .. } - | MentionUri::Selection { .. } => {} - } - } - for (range, content) in images { - let Some(format) = ImageFormat::from_mime_type(&content.mime_type) else { + ) else { continue; }; - let anchor = snapshot.anchor_before(range.start); - let abs_path = content - .uri - .as_ref() - .and_then(|uri| uri.strip_prefix("file://").map(|s| Path::new(s).into())); - let name = content - .uri - .as_ref() - .and_then(|uri| { - uri.strip_prefix("file://") - .and_then(|path| Path::new(path).file_name()) - }) - .map(|name| name.to_string_lossy().to_string()) - .unwrap_or("Image".to_owned()); - let crease_id = crate::context_picker::insert_crease_for_mention( - anchor.excerpt_id, - anchor.text_anchor, - range.end - range.start, - name.into(), - IconName::Image.path().into(), - self.editor.clone(), - window, - cx, + self.mention_set.mentions.insert( + crease_id, + (mention_uri.clone(), Task::ready(Ok(mention)).shared()), ); - let data: SharedString = content.data.to_string().into(); - - if let Some(crease_id) = crease_id { - self.mention_set.insert_image( - crease_id, - Task::ready(Ok(MentionImage { - abs_path, - data, - format, - })) - .shared(), - ); - } } cx.notify(); } @@ -1425,289 +1343,60 @@ impl Render for ImageHover { } } -#[derive(Debug, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq)] pub enum Mention { Text { - uri: MentionUri, content: String, tracked_buffers: Vec>, }, Image(MentionImage), - UriOnly(MentionUri), + UriOnly, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct MentionImage { - pub abs_path: Option, pub data: SharedString, pub format: ImageFormat, } #[derive(Default)] pub struct MentionSet { - uri_by_crease_id: HashMap, - fetch_results: HashMap>>>, - images: HashMap>>>, - thread_summaries: HashMap>>>, - text_thread_summaries: HashMap>>>, - directories: HashMap>), String>>>>, + mentions: HashMap>>)>, } impl MentionSet { - pub fn insert_uri(&mut self, crease_id: CreaseId, uri: MentionUri) { - self.uri_by_crease_id.insert(crease_id, uri); - } - - pub fn add_fetch_result(&mut self, url: Url, content: Shared>>) { - self.fetch_results.insert(url, content); - } - - pub fn insert_image( - &mut self, - crease_id: CreaseId, - task: Shared>>, - ) { - self.images.insert(crease_id, task); - } - - fn insert_thread( - &mut self, - id: acp::SessionId, - task: Shared>>, - ) { - self.thread_summaries.insert(id, task); - } - - fn insert_text_thread(&mut self, path: PathBuf, task: Shared>>) { - self.text_thread_summaries.insert(path, task); - } - - pub fn contents( + fn contents( &self, - project: &Entity, - prompt_store: Option<&Entity>, prompt_capabilities: &acp::PromptCapabilities, - _window: &mut Window, cx: &mut App, - ) -> Task>> { + ) -> Task>> { if !prompt_capabilities.embedded_context { let mentions = self - .uri_by_crease_id + .mentions .iter() - .map(|(crease_id, uri)| (*crease_id, Mention::UriOnly(uri.clone()))) + .map(|(crease_id, (uri, _))| (*crease_id, (uri.clone(), Mention::UriOnly))) .collect(); return Task::ready(Ok(mentions)); } - let mut processed_image_creases = HashSet::default(); - - let mut contents = self - .uri_by_crease_id - .iter() - .map(|(&crease_id, uri)| { - match uri { - MentionUri::File { abs_path, .. } => { - let uri = uri.clone(); - let abs_path = abs_path.to_path_buf(); - - if let Some(task) = self.images.get(&crease_id).cloned() { - processed_image_creases.insert(crease_id); - return cx.spawn(async move |_| { - let image = task.await.map_err(|e| anyhow!("{e}"))?; - anyhow::Ok((crease_id, Mention::Image(image))) - }); - } - - let buffer_task = project.update(cx, |project, cx| { - let path = project - .find_project_path(abs_path, cx) - .context("Failed to find project path")?; - anyhow::Ok(project.open_buffer(path, cx)) - }); - cx.spawn(async move |cx| { - let buffer = buffer_task?.await?; - let content = buffer.read_with(cx, |buffer, _cx| buffer.text())?; - - anyhow::Ok(( - crease_id, - Mention::Text { - uri, - content, - tracked_buffers: vec![buffer], - }, - )) - }) - } - MentionUri::Directory { abs_path } => { - let Some(content) = self.directories.get(abs_path).cloned() else { - return Task::ready(Err(anyhow!("missing directory load task"))); - }; - let uri = uri.clone(); - cx.spawn(async move |_| { - let (content, tracked_buffers) = - content.await.map_err(|e| anyhow::anyhow!("{e}"))?; - Ok(( - crease_id, - Mention::Text { - uri, - content, - tracked_buffers, - }, - )) - }) - } - MentionUri::Symbol { - path, line_range, .. - } - | MentionUri::Selection { - path, line_range, .. - } => { - let uri = uri.clone(); - let path_buf = path.clone(); - let line_range = line_range.clone(); - - let buffer_task = project.update(cx, |project, cx| { - let path = project - .find_project_path(&path_buf, cx) - .context("Failed to find project path")?; - anyhow::Ok(project.open_buffer(path, cx)) - }); - - cx.spawn(async move |cx| { - let buffer = buffer_task?.await?; - let content = buffer.read_with(cx, |buffer, _cx| { - buffer - .text_for_range( - Point::new(line_range.start, 0) - ..Point::new( - line_range.end, - buffer.line_len(line_range.end), - ), - ) - .collect() - })?; - - anyhow::Ok(( - crease_id, - Mention::Text { - uri, - content, - tracked_buffers: vec![buffer], - }, - )) - }) - } - MentionUri::Thread { id, .. } => { - let Some(content) = self.thread_summaries.get(id).cloned() else { - return Task::ready(Err(anyhow!("missing thread summary"))); - }; - let uri = uri.clone(); - cx.spawn(async move |_| { - Ok(( - crease_id, - Mention::Text { - uri, - content: content - .await - .map_err(|e| anyhow::anyhow!("{e}"))? - .to_string(), - tracked_buffers: Vec::new(), - }, - )) - }) - } - MentionUri::TextThread { path, .. } => { - let Some(content) = self.text_thread_summaries.get(path).cloned() else { - return Task::ready(Err(anyhow!("missing text thread summary"))); - }; - let uri = uri.clone(); - cx.spawn(async move |_| { - Ok(( - crease_id, - Mention::Text { - uri, - content: content.await.map_err(|e| anyhow::anyhow!("{e}"))?, - tracked_buffers: Vec::new(), - }, - )) - }) - } - MentionUri::Rule { id: prompt_id, .. } => { - let Some(prompt_store) = prompt_store else { - return Task::ready(Err(anyhow!("missing prompt store"))); - }; - let text_task = prompt_store.read(cx).load(*prompt_id, cx); - let uri = uri.clone(); - cx.spawn(async move |_| { - // TODO: report load errors instead of just logging - let text = text_task.await?; - anyhow::Ok(( - crease_id, - Mention::Text { - uri, - content: text, - tracked_buffers: Vec::new(), - }, - )) - }) - } - MentionUri::Fetch { url } => { - let Some(content) = self.fetch_results.get(url).cloned() else { - return Task::ready(Err(anyhow!("missing fetch result"))); - }; - let uri = uri.clone(); - cx.spawn(async move |_| { - Ok(( - crease_id, - Mention::Text { - uri, - content: content.await.map_err(|e| anyhow::anyhow!("{e}"))?, - tracked_buffers: Vec::new(), - }, - )) - }) - } - } - }) - .collect::>(); - - // Handle images that didn't have a mention URI (because they were added by the paste handler). - contents.extend(self.images.iter().filter_map(|(crease_id, image)| { - if processed_image_creases.contains(crease_id) { - return None; - } - let crease_id = *crease_id; - let image = image.clone(); - Some(cx.spawn(async move |_| { - Ok(( - crease_id, - Mention::Image(image.await.map_err(|e| anyhow::anyhow!("{e}"))?), - )) - })) - })); - + let mentions = self.mentions.clone(); cx.spawn(async move |_cx| { - let contents = try_join_all(contents).await?.into_iter().collect(); - anyhow::Ok(contents) + let mut contents = HashMap::default(); + for (crease_id, (mention_uri, task)) in mentions { + contents.insert( + crease_id, + (mention_uri, task.await.map_err(|e| anyhow!("{e}"))?), + ); + } + Ok(contents) }) } - pub fn drain(&mut self) -> impl Iterator { - self.fetch_results.clear(); - self.thread_summaries.clear(); - self.text_thread_summaries.clear(); - self.directories.clear(); - self.uri_by_crease_id - .drain() - .map(|(id, _)| id) - .chain(self.images.drain().map(|(id, _)| id)) - } - - pub fn remove_invalid(&mut self, snapshot: EditorSnapshot) { + fn remove_invalid(&mut self, snapshot: EditorSnapshot) { for (crease_id, crease) in snapshot.crease_snapshot.creases() { if !crease.range().start.is_valid(&snapshot.buffer_snapshot) { - self.uri_by_crease_id.remove(&crease_id); + self.mentions.remove(&crease_id); } } } @@ -1969,9 +1658,7 @@ mod tests { }); let (content, _) = message_editor - .update_in(cx, |message_editor, window, cx| { - message_editor.contents(window, cx) - }) + .update(cx, |message_editor, cx| message_editor.contents(cx)) .await .unwrap(); @@ -2038,7 +1725,8 @@ mod tests { "six.txt": "6", "seven.txt": "7", "eight.txt": "8", - } + }, + "x.png": "", }), ) .await; @@ -2222,14 +1910,10 @@ mod tests { }; let contents = message_editor - .update_in(&mut cx, |message_editor, window, cx| { - message_editor.mention_set().contents( - &project, - None, - &all_prompt_capabilities, - window, - cx, - ) + .update(&mut cx, |message_editor, cx| { + message_editor + .mention_set() + .contents(&all_prompt_capabilities, cx) }) .await .unwrap() @@ -2237,7 +1921,7 @@ mod tests { .collect::>(); { - let [Mention::Text { content, uri, .. }] = contents.as_slice() else { + let [(uri, Mention::Text { content, .. })] = contents.as_slice() else { panic!("Unexpected mentions"); }; pretty_assertions::assert_eq!(content, "1"); @@ -2245,14 +1929,10 @@ mod tests { } let contents = message_editor - .update_in(&mut cx, |message_editor, window, cx| { - message_editor.mention_set().contents( - &project, - None, - &acp::PromptCapabilities::default(), - window, - cx, - ) + .update(&mut cx, |message_editor, cx| { + message_editor + .mention_set() + .contents(&acp::PromptCapabilities::default(), cx) }) .await .unwrap() @@ -2260,7 +1940,7 @@ mod tests { .collect::>(); { - let [Mention::UriOnly(uri)] = contents.as_slice() else { + let [(uri, Mention::UriOnly)] = contents.as_slice() else { panic!("Unexpected mentions"); }; pretty_assertions::assert_eq!(uri, &url_one.parse::().unwrap()); @@ -2300,14 +1980,10 @@ mod tests { cx.run_until_parked(); let contents = message_editor - .update_in(&mut cx, |message_editor, window, cx| { - message_editor.mention_set().contents( - &project, - None, - &all_prompt_capabilities, - window, - cx, - ) + .update(&mut cx, |message_editor, cx| { + message_editor + .mention_set() + .contents(&all_prompt_capabilities, cx) }) .await .unwrap() @@ -2317,7 +1993,7 @@ mod tests { let url_eight = uri!("file:///dir/b/eight.txt"); { - let [_, Mention::Text { content, uri, .. }] = contents.as_slice() else { + let [_, (uri, Mention::Text { content, .. })] = contents.as_slice() else { panic!("Unexpected mentions"); }; pretty_assertions::assert_eq!(content, "8"); @@ -2414,14 +2090,10 @@ mod tests { }); let contents = message_editor - .update_in(&mut cx, |message_editor, window, cx| { - message_editor.mention_set().contents( - &project, - None, - &all_prompt_capabilities, - window, - cx, - ) + .update(&mut cx, |message_editor, cx| { + message_editor + .mention_set() + .contents(&all_prompt_capabilities, cx) }) .await .unwrap() @@ -2429,7 +2101,7 @@ mod tests { .collect::>(); { - let [_, _, Mention::Text { content, uri, .. }] = contents.as_slice() else { + let [_, _, (uri, Mention::Text { content, .. })] = contents.as_slice() else { panic!("Unexpected mentions"); }; pretty_assertions::assert_eq!(content, "1"); @@ -2444,11 +2116,85 @@ mod tests { cx.run_until_parked(); editor.read_with(&cx, |editor, cx| { - assert_eq!( - editor.text(cx), - format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({url_one}?symbol=MySymbol#L1:1) ") - ); - }); + assert_eq!( + editor.text(cx), + format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({url_one}?symbol=MySymbol#L1:1) ") + ); + }); + + // Try to mention an "image" file that will fail to load + cx.simulate_input("@file x.png"); + + editor.update(&mut cx, |editor, cx| { + assert_eq!( + editor.text(cx), + format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({url_one}?symbol=MySymbol#L1:1) @file x.png") + ); + assert!(editor.has_visible_completions_menu()); + assert_eq!(current_completion_labels(editor), &["x.png dir/"]); + }); + + editor.update_in(&mut cx, |editor, window, cx| { + editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); + }); + + // Getting the message contents fails + message_editor + .update(&mut cx, |message_editor, cx| { + message_editor + .mention_set() + .contents(&all_prompt_capabilities, cx) + }) + .await + .expect_err("Should fail to load x.png"); + + cx.run_until_parked(); + + // Mention was removed + editor.read_with(&cx, |editor, cx| { + assert_eq!( + editor.text(cx), + format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({url_one}?symbol=MySymbol#L1:1) ") + ); + }); + + // Once more + cx.simulate_input("@file x.png"); + + editor.update(&mut cx, |editor, cx| { + assert_eq!( + editor.text(cx), + format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({url_one}?symbol=MySymbol#L1:1) @file x.png") + ); + assert!(editor.has_visible_completions_menu()); + assert_eq!(current_completion_labels(editor), &["x.png dir/"]); + }); + + editor.update_in(&mut cx, |editor, window, cx| { + editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); + }); + + // This time don't immediately get the contents, just let the confirmed completion settle + cx.run_until_parked(); + + // Mention was removed + editor.read_with(&cx, |editor, cx| { + assert_eq!( + editor.text(cx), + format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({url_one}?symbol=MySymbol#L1:1) ") + ); + }); + + // Now getting the contents succeeds, because the invalid mention was removed + let contents = message_editor + .update(&mut cx, |message_editor, cx| { + message_editor + .mention_set() + .contents(&all_prompt_capabilities, cx) + }) + .await + .unwrap(); + assert_eq!(contents.len(), 3); } fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec> { diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 0e1d4123b9..3ad1234e22 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -274,6 +274,7 @@ pub struct AcpThreadView { edits_expanded: bool, plan_expanded: bool, editor_expanded: bool, + terminal_expanded: bool, editing_message: Option, prompt_capabilities: Rc>, _cancel_task: Option>, @@ -384,6 +385,7 @@ impl AcpThreadView { edits_expanded: false, plan_expanded: false, editor_expanded: false, + terminal_expanded: true, history_store, hovered_recent_history_item: None, prompt_capabilities, @@ -835,7 +837,7 @@ impl AcpThreadView { let contents = self .message_editor - .update(cx, |message_editor, cx| message_editor.contents(window, cx)); + .update(cx, |message_editor, cx| message_editor.contents(cx)); self.send_impl(contents, window, cx) } @@ -848,7 +850,7 @@ impl AcpThreadView { let contents = self .message_editor - .update(cx, |message_editor, cx| message_editor.contents(window, cx)); + .update(cx, |message_editor, cx| message_editor.contents(cx)); cx.spawn_in(window, async move |this, cx| { cancelled.await; @@ -956,8 +958,7 @@ impl AcpThreadView { return; }; - let contents = - message_editor.update(cx, |message_editor, cx| message_editor.contents(window, cx)); + let contents = message_editor.update(cx, |message_editor, cx| message_editor.contents(cx)); let task = cx.foreground_executor().spawn(async move { rewind.await?; @@ -1690,9 +1691,10 @@ impl AcpThreadView { matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some(); let use_card_layout = needs_confirmation || is_edit; - let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation; + let is_collapsible = !tool_call.content.is_empty() && !use_card_layout; - let is_open = needs_confirmation || self.expanded_tool_calls.contains(&tool_call.id); + let is_open = + needs_confirmation || is_edit || self.expanded_tool_calls.contains(&tool_call.id); let gradient_overlay = |color: Hsla| { div() @@ -2162,8 +2164,6 @@ impl AcpThreadView { .map(|path| format!("{}", path.display())) .unwrap_or_else(|| "current directory".to_string()); - let is_expanded = self.expanded_tool_calls.contains(&tool_call.id); - let header = h_flex() .id(SharedString::from(format!( "terminal-tool-header-{}", @@ -2297,19 +2297,12 @@ impl AcpThreadView { "terminal-tool-disclosure-{}", terminal.entity_id() )), - is_expanded, + self.terminal_expanded, ) .opened_icon(IconName::ChevronUp) .closed_icon(IconName::ChevronDown) - .on_click(cx.listener({ - let id = tool_call.id.clone(); - move |this, _event, _window, _cx| { - if is_expanded { - this.expanded_tool_calls.remove(&id); - } else { - this.expanded_tool_calls.insert(id.clone()); - } - } + .on_click(cx.listener(move |this, _event, _window, _cx| { + this.terminal_expanded = !this.terminal_expanded; })), ); @@ -2318,7 +2311,7 @@ impl AcpThreadView { .read(cx) .entry(entry_ix) .and_then(|entry| entry.terminal(terminal)); - let show_output = is_expanded && terminal_view.is_some(); + let show_output = self.terminal_expanded && terminal_view.is_some(); v_flex() .mb_2() @@ -3655,6 +3648,7 @@ impl AcpThreadView { .open_path(path, None, true, window, cx) .detach_and_log_err(cx); } + MentionUri::PastedImage => {} MentionUri::Directory { abs_path } => { let project = workspace.project(); let Some(entry) = project.update(cx, |project, cx| { @@ -3669,9 +3663,14 @@ impl AcpThreadView { }); } MentionUri::Symbol { - path, line_range, .. + abs_path: path, + line_range, + .. } - | MentionUri::Selection { path, line_range } => { + | MentionUri::Selection { + abs_path: Some(path), + line_range, + } => { let project = workspace.project(); let Some((path, _)) = project.update(cx, |project, cx| { let path = project.find_project_path(path, cx)?; @@ -3687,8 +3686,8 @@ impl AcpThreadView { let Some(editor) = item.await?.downcast::() else { return Ok(()); }; - let range = - Point::new(line_range.start, 0)..Point::new(line_range.start, 0); + let range = Point::new(*line_range.start(), 0) + ..Point::new(*line_range.start(), 0); editor .update_in(cx, |editor, window, cx| { editor.change_selections( @@ -3703,6 +3702,7 @@ impl AcpThreadView { }) .detach_and_log_err(cx); } + MentionUri::Selection { abs_path: None, .. } => {} MentionUri::Thread { id, name } => { if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { From ad6bc4586a7a2a9178428d4e7558917256b39fae Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Sat, 23 Aug 2025 16:30:54 +0200 Subject: [PATCH 56/89] acp: Support launching custom agent servers (#36805) It's enough to add this to your settings: ```json { "agent_servers": { "Name Of Your Agent": { "command": "/path/to/custom/agent", "args": ["arguments", "that", "you", "want"], } } } ``` Release Notes: - N/A --- crates/acp_tools/src/acp_tools.rs | 12 +- crates/agent2/src/native_agent_server.rs | 12 +- crates/agent_servers/src/acp.rs | 12 +- crates/agent_servers/src/agent_servers.rs | 8 +- crates/agent_servers/src/claude.rs | 12 +- crates/agent_servers/src/custom.rs | 59 ++++++++++ crates/agent_servers/src/e2e_tests.rs | 17 ++- crates/agent_servers/src/gemini.rs | 13 +-- crates/agent_servers/src/settings.rs | 24 +++- crates/agent_ui/src/acp/thread_view.rs | 20 ++-- crates/agent_ui/src/agent_panel.rs | 105 ++++++++++++++---- crates/agent_ui/src/agent_ui.rs | 19 +++- crates/language_model/src/language_model.rs | 4 +- .../language_models/src/provider/anthropic.rs | 6 +- crates/language_models/src/provider/google.rs | 6 +- 15 files changed, 238 insertions(+), 91 deletions(-) create mode 100644 crates/agent_servers/src/custom.rs diff --git a/crates/acp_tools/src/acp_tools.rs b/crates/acp_tools/src/acp_tools.rs index ca5e57e85a..ee12b04cde 100644 --- a/crates/acp_tools/src/acp_tools.rs +++ b/crates/acp_tools/src/acp_tools.rs @@ -46,7 +46,7 @@ pub struct AcpConnectionRegistry { } struct ActiveConnection { - server_name: &'static str, + server_name: SharedString, connection: Weak, } @@ -63,12 +63,12 @@ impl AcpConnectionRegistry { pub fn set_active_connection( &self, - server_name: &'static str, + server_name: impl Into, connection: &Rc, cx: &mut Context, ) { self.active_connection.replace(Some(ActiveConnection { - server_name, + server_name: server_name.into(), connection: Rc::downgrade(connection), })); cx.notify(); @@ -85,7 +85,7 @@ struct AcpTools { } struct WatchedConnection { - server_name: &'static str, + server_name: SharedString, messages: Vec, list_state: ListState, connection: Weak, @@ -142,7 +142,7 @@ impl AcpTools { }); self.watched_connection = Some(WatchedConnection { - server_name: active_connection.server_name, + server_name: active_connection.server_name.clone(), messages: vec![], list_state: ListState::new(0, ListAlignment::Bottom, px(2048.)), connection: active_connection.connection.clone(), @@ -442,7 +442,7 @@ impl Item for AcpTools { "ACP: {}", self.watched_connection .as_ref() - .map_or("Disconnected", |connection| connection.server_name) + .map_or("Disconnected", |connection| &connection.server_name) ) .into() } diff --git a/crates/agent2/src/native_agent_server.rs b/crates/agent2/src/native_agent_server.rs index 4ce467d6fd..12d3c79d1b 100644 --- a/crates/agent2/src/native_agent_server.rs +++ b/crates/agent2/src/native_agent_server.rs @@ -3,7 +3,7 @@ use std::{any::Any, path::Path, rc::Rc, sync::Arc}; use agent_servers::AgentServer; use anyhow::Result; use fs::Fs; -use gpui::{App, Entity, Task}; +use gpui::{App, Entity, SharedString, Task}; use project::Project; use prompt_store::PromptStore; @@ -22,16 +22,16 @@ impl NativeAgentServer { } impl AgentServer for NativeAgentServer { - fn name(&self) -> &'static str { - "Zed Agent" + fn name(&self) -> SharedString { + "Zed Agent".into() } - fn empty_state_headline(&self) -> &'static str { + fn empty_state_headline(&self) -> SharedString { self.name() } - fn empty_state_message(&self) -> &'static str { - "" + fn empty_state_message(&self) -> SharedString { + "".into() } fn logo(&self) -> ui::IconName { diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index a99a401431..c9c938c6c0 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -15,7 +15,7 @@ use std::{path::Path, rc::Rc}; use thiserror::Error; use anyhow::{Context as _, Result}; -use gpui::{App, AppContext as _, AsyncApp, Entity, Task, WeakEntity}; +use gpui::{App, AppContext as _, AsyncApp, Entity, SharedString, Task, WeakEntity}; use acp_thread::{AcpThread, AuthRequired, LoadError}; @@ -24,7 +24,7 @@ use acp_thread::{AcpThread, AuthRequired, LoadError}; pub struct UnsupportedVersion; pub struct AcpConnection { - server_name: &'static str, + server_name: SharedString, connection: Rc, sessions: Rc>>, auth_methods: Vec, @@ -38,7 +38,7 @@ pub struct AcpSession { } pub async fn connect( - server_name: &'static str, + server_name: SharedString, command: AgentServerCommand, root_dir: &Path, cx: &mut AsyncApp, @@ -51,7 +51,7 @@ const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::V1; impl AcpConnection { pub async fn stdio( - server_name: &'static str, + server_name: SharedString, command: AgentServerCommand, root_dir: &Path, cx: &mut AsyncApp, @@ -121,7 +121,7 @@ impl AcpConnection { cx.update(|cx| { AcpConnectionRegistry::default_global(cx).update(cx, |registry, cx| { - registry.set_active_connection(server_name, &connection, cx) + registry.set_active_connection(server_name.clone(), &connection, cx) }); })?; @@ -187,7 +187,7 @@ impl AgentConnection for AcpConnection { let action_log = cx.new(|_| ActionLog::new(project.clone()))?; let thread = cx.new(|_cx| { AcpThread::new( - self.server_name, + self.server_name.clone(), self.clone(), project, action_log, diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index 2f5ec478ae..fa59201338 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -1,5 +1,6 @@ mod acp; mod claude; +mod custom; mod gemini; mod settings; @@ -7,6 +8,7 @@ mod settings; pub mod e2e_tests; pub use claude::*; +pub use custom::*; pub use gemini::*; pub use settings::*; @@ -31,9 +33,9 @@ pub fn init(cx: &mut App) { pub trait AgentServer: Send { fn logo(&self) -> ui::IconName; - fn name(&self) -> &'static str; - fn empty_state_headline(&self) -> &'static str; - fn empty_state_message(&self) -> &'static str; + fn name(&self) -> SharedString; + fn empty_state_headline(&self) -> SharedString; + fn empty_state_message(&self) -> SharedString; fn connect( &self, diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index ef666974f1..048563103f 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -30,7 +30,7 @@ use futures::{ io::BufReader, select_biased, }; -use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity}; +use gpui::{App, AppContext, AsyncApp, Entity, SharedString, Task, WeakEntity}; use serde::{Deserialize, Serialize}; use util::{ResultExt, debug_panic}; @@ -43,16 +43,16 @@ use acp_thread::{AcpThread, AgentConnection, AuthRequired, LoadError, MentionUri pub struct ClaudeCode; impl AgentServer for ClaudeCode { - fn name(&self) -> &'static str { - "Claude Code" + fn name(&self) -> SharedString { + "Claude Code".into() } - fn empty_state_headline(&self) -> &'static str { + fn empty_state_headline(&self) -> SharedString { self.name() } - fn empty_state_message(&self) -> &'static str { - "How can I help you today?" + fn empty_state_message(&self) -> SharedString { + "How can I help you today?".into() } fn logo(&self) -> ui::IconName { diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs new file mode 100644 index 0000000000..e544c4f21f --- /dev/null +++ b/crates/agent_servers/src/custom.rs @@ -0,0 +1,59 @@ +use crate::{AgentServerCommand, AgentServerSettings}; +use acp_thread::AgentConnection; +use anyhow::Result; +use gpui::{App, Entity, SharedString, Task}; +use project::Project; +use std::{path::Path, rc::Rc}; +use ui::IconName; + +/// A generic agent server implementation for custom user-defined agents +pub struct CustomAgentServer { + name: SharedString, + command: AgentServerCommand, +} + +impl CustomAgentServer { + pub fn new(name: SharedString, settings: &AgentServerSettings) -> Self { + Self { + name, + command: settings.command.clone(), + } + } +} + +impl crate::AgentServer for CustomAgentServer { + fn name(&self) -> SharedString { + self.name.clone() + } + + fn logo(&self) -> IconName { + IconName::Terminal + } + + fn empty_state_headline(&self) -> SharedString { + "No conversations yet".into() + } + + fn empty_state_message(&self) -> SharedString { + format!("Start a conversation with {}", self.name).into() + } + + fn connect( + &self, + root_dir: &Path, + _project: &Entity, + cx: &mut App, + ) -> Task>> { + let server_name = self.name(); + let command = self.command.clone(); + let root_dir = root_dir.to_path_buf(); + + cx.spawn(async move |mut cx| { + crate::acp::connect(server_name, command, &root_dir, &mut cx).await + }) + } + + fn into_any(self: Rc) -> Rc { + self + } +} diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index c271079071..42264b4b4f 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -1,17 +1,15 @@ +use crate::AgentServer; +use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus}; +use agent_client_protocol as acp; +use futures::{FutureExt, StreamExt, channel::mpsc, select}; +use gpui::{AppContext, Entity, TestAppContext}; +use indoc::indoc; +use project::{FakeFs, Project}; use std::{ path::{Path, PathBuf}, sync::Arc, time::Duration, }; - -use crate::AgentServer; -use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus}; -use agent_client_protocol as acp; - -use futures::{FutureExt, StreamExt, channel::mpsc, select}; -use gpui::{AppContext, Entity, TestAppContext}; -use indoc::indoc; -use project::{FakeFs, Project}; use util::path; pub async fn test_basic(server: F, cx: &mut TestAppContext) @@ -479,6 +477,7 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc { gemini: Some(crate::AgentServerSettings { command: crate::gemini::tests::local_command(), }), + custom: collections::HashMap::default(), }, cx, ); diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index 29120fff6e..9ebcee745c 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -4,11 +4,10 @@ use std::{any::Any, path::Path}; use crate::{AgentServer, AgentServerCommand}; use acp_thread::{AgentConnection, LoadError}; use anyhow::Result; -use gpui::{Entity, Task}; +use gpui::{App, Entity, SharedString, Task}; use language_models::provider::google::GoogleLanguageModelProvider; use project::Project; use settings::SettingsStore; -use ui::App; use crate::AllAgentServersSettings; @@ -18,16 +17,16 @@ pub struct Gemini; const ACP_ARG: &str = "--experimental-acp"; impl AgentServer for Gemini { - fn name(&self) -> &'static str { - "Gemini CLI" + fn name(&self) -> SharedString { + "Gemini CLI".into() } - fn empty_state_headline(&self) -> &'static str { + fn empty_state_headline(&self) -> SharedString { self.name() } - fn empty_state_message(&self) -> &'static str { - "Ask questions, edit files, run commands" + fn empty_state_message(&self) -> SharedString { + "Ask questions, edit files, run commands".into() } fn logo(&self) -> ui::IconName { diff --git a/crates/agent_servers/src/settings.rs b/crates/agent_servers/src/settings.rs index 645674b5f1..96ac6e3cbe 100644 --- a/crates/agent_servers/src/settings.rs +++ b/crates/agent_servers/src/settings.rs @@ -1,6 +1,7 @@ use crate::AgentServerCommand; use anyhow::Result; -use gpui::App; +use collections::HashMap; +use gpui::{App, SharedString}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsSources}; @@ -13,9 +14,13 @@ pub fn init(cx: &mut App) { pub struct AllAgentServersSettings { pub gemini: Option, pub claude: Option, + + /// Custom agent servers configured by the user + #[serde(flatten)] + pub custom: HashMap, } -#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug)] +#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)] pub struct AgentServerSettings { #[serde(flatten)] pub command: AgentServerCommand, @@ -29,13 +34,26 @@ impl settings::Settings for AllAgentServersSettings { fn load(sources: SettingsSources, _: &mut App) -> Result { let mut settings = AllAgentServersSettings::default(); - for AllAgentServersSettings { gemini, claude } in sources.defaults_and_customizations() { + for AllAgentServersSettings { + gemini, + claude, + custom, + } in sources.defaults_and_customizations() + { if gemini.is_some() { settings.gemini = gemini.clone(); } if claude.is_some() { settings.claude = claude.clone(); } + + // Merge custom agents + for (name, config) in custom { + // Skip built-in agent names to avoid conflicts + if name != "gemini" && name != "claude" { + settings.custom.insert(name.clone(), config.clone()); + } + } } Ok(settings) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 3ad1234e22..87928767c6 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -600,7 +600,7 @@ impl AcpThreadView { let view = registry.read(cx).provider(&provider_id).map(|provider| { provider.configuration_view( - language_model::ConfigurationViewTargetAgent::Other(agent_name), + language_model::ConfigurationViewTargetAgent::Other(agent_name.clone()), window, cx, ) @@ -1372,7 +1372,7 @@ impl AcpThreadView { .icon_color(Color::Muted) .style(ButtonStyle::Transparent) .tooltip(move |_window, cx| { - cx.new(|_| UnavailableEditingTooltip::new(agent_name.into())) + cx.new(|_| UnavailableEditingTooltip::new(agent_name.clone())) .into() }) ) @@ -3911,13 +3911,13 @@ impl AcpThreadView { match AgentSettings::get_global(cx).notify_when_agent_waiting { NotifyWhenAgentWaiting::PrimaryScreen => { if let Some(primary) = cx.primary_display() { - self.pop_up(icon, caption.into(), title.into(), window, primary, cx); + self.pop_up(icon, caption.into(), title, window, primary, cx); } } NotifyWhenAgentWaiting::AllScreens => { let caption = caption.into(); for screen in cx.displays() { - self.pop_up(icon, caption.clone(), title.into(), window, screen, cx); + self.pop_up(icon, caption.clone(), title.clone(), window, screen, cx); } } NotifyWhenAgentWaiting::Never => { @@ -5153,16 +5153,16 @@ pub(crate) mod tests { ui::IconName::Ai } - fn name(&self) -> &'static str { - "Test" + fn name(&self) -> SharedString { + "Test".into() } - fn empty_state_headline(&self) -> &'static str { - "Test" + fn empty_state_headline(&self) -> SharedString { + "Test".into() } - fn empty_state_message(&self) -> &'static str { - "Test" + fn empty_state_message(&self) -> SharedString { + "Test".into() } fn connect( diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 0e611d0db9..50f9fc6a45 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use std::time::Duration; use acp_thread::AcpThread; +use agent_servers::AgentServerSettings; use agent2::{DbThreadMetadata, HistoryEntry}; use db::kvp::{Dismissable, KEY_VALUE_STORE}; use serde::{Deserialize, Serialize}; @@ -128,7 +129,7 @@ pub fn init(cx: &mut App) { if let Some(panel) = workspace.panel::(cx) { workspace.focus_panel::(window, cx); panel.update(cx, |panel, cx| { - panel.external_thread(action.agent, None, None, window, cx) + panel.external_thread(action.agent.clone(), None, None, window, cx) }); } }) @@ -239,7 +240,7 @@ enum WhichFontSize { None, } -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] pub enum AgentType { #[default] Zed, @@ -247,23 +248,29 @@ pub enum AgentType { Gemini, ClaudeCode, NativeAgent, + Custom { + name: SharedString, + settings: AgentServerSettings, + }, } impl AgentType { - fn label(self) -> impl Into { + fn label(&self) -> SharedString { match self { - Self::Zed | Self::TextThread => "Zed Agent", - Self::NativeAgent => "Agent 2", - Self::Gemini => "Gemini CLI", - Self::ClaudeCode => "Claude Code", + Self::Zed | Self::TextThread => "Zed Agent".into(), + Self::NativeAgent => "Agent 2".into(), + Self::Gemini => "Gemini CLI".into(), + Self::ClaudeCode => "Claude Code".into(), + Self::Custom { name, .. } => name.into(), } } - fn icon(self) -> Option { + fn icon(&self) -> Option { match self { Self::Zed | Self::NativeAgent | Self::TextThread => None, Self::Gemini => Some(IconName::AiGemini), Self::ClaudeCode => Some(IconName::AiClaude), + Self::Custom { .. } => Some(IconName::Terminal), } } } @@ -517,7 +524,7 @@ pub struct AgentPanel { impl AgentPanel { fn serialize(&mut self, cx: &mut Context) { let width = self.width; - let selected_agent = self.selected_agent; + let selected_agent = self.selected_agent.clone(); self.pending_serialization = Some(cx.background_spawn(async move { KEY_VALUE_STORE .write_kvp( @@ -607,7 +614,7 @@ impl AgentPanel { panel.update(cx, |panel, cx| { panel.width = serialized_panel.width.map(|w| w.round()); if let Some(selected_agent) = serialized_panel.selected_agent { - panel.selected_agent = selected_agent; + panel.selected_agent = selected_agent.clone(); panel.new_agent_thread(selected_agent, window, cx); } cx.notify(); @@ -1077,14 +1084,17 @@ impl AgentPanel { cx.spawn_in(window, async move |this, cx| { let ext_agent = match agent_choice { Some(agent) => { - cx.background_spawn(async move { - if let Some(serialized) = - serde_json::to_string(&LastUsedExternalAgent { agent }).log_err() - { - KEY_VALUE_STORE - .write_kvp(LAST_USED_EXTERNAL_AGENT_KEY.to_string(), serialized) - .await - .log_err(); + cx.background_spawn({ + let agent = agent.clone(); + async move { + if let Some(serialized) = + serde_json::to_string(&LastUsedExternalAgent { agent }).log_err() + { + KEY_VALUE_STORE + .write_kvp(LAST_USED_EXTERNAL_AGENT_KEY.to_string(), serialized) + .await + .log_err(); + } } }) .detach(); @@ -1110,7 +1120,9 @@ impl AgentPanel { this.update_in(cx, |this, window, cx| { match ext_agent { - crate::ExternalAgent::Gemini | crate::ExternalAgent::NativeAgent => { + crate::ExternalAgent::Gemini + | crate::ExternalAgent::NativeAgent + | crate::ExternalAgent::Custom { .. } => { if !cx.has_flag::() { return; } @@ -1839,14 +1851,14 @@ impl AgentPanel { cx: &mut Context, ) { if self.selected_agent != agent { - self.selected_agent = agent; + self.selected_agent = agent.clone(); self.serialize(cx); } self.new_agent_thread(agent, window, cx); } pub fn selected_agent(&self) -> AgentType { - self.selected_agent + self.selected_agent.clone() } pub fn new_agent_thread( @@ -1885,6 +1897,13 @@ impl AgentPanel { window, cx, ), + AgentType::Custom { name, settings } => self.external_thread( + Some(crate::ExternalAgent::Custom { name, settings }), + None, + None, + window, + cx, + ), } } @@ -2610,13 +2629,55 @@ impl AgentPanel { } }), ) + }) + .when(cx.has_flag::(), |mut menu| { + // Add custom agents from settings + let settings = + agent_servers::AllAgentServersSettings::get_global(cx); + for (agent_name, agent_settings) in &settings.custom { + menu = menu.item( + ContextMenuEntry::new(format!("New {} Thread", agent_name)) + .icon(IconName::Terminal) + .icon_color(Color::Muted) + .handler({ + let workspace = workspace.clone(); + let agent_name = agent_name.clone(); + let agent_settings = agent_settings.clone(); + move |window, cx| { + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + if let Some(panel) = + workspace.panel::(cx) + { + panel.update(cx, |panel, cx| { + panel.set_selected_agent( + AgentType::Custom { + name: agent_name + .clone(), + settings: + agent_settings + .clone(), + }, + window, + cx, + ); + }); + } + }); + } + } + }), + ); + } + + menu }); menu })) } }); - let selected_agent_label = self.selected_agent.label().into(); + let selected_agent_label = self.selected_agent.label(); let selected_agent = div() .id("selected_agent_icon") .when_some(self.selected_agent.icon(), |this, icon| { diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 6084fd6423..40f6c6a2bb 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -28,13 +28,14 @@ use std::rc::Rc; use std::sync::Arc; use agent::{Thread, ThreadId}; +use agent_servers::AgentServerSettings; use agent_settings::{AgentProfileId, AgentSettings, LanguageModelSelection}; use assistant_slash_command::SlashCommandRegistry; use client::Client; use command_palette_hooks::CommandPaletteFilter; use feature_flags::FeatureFlagAppExt as _; use fs::Fs; -use gpui::{Action, App, Entity, actions}; +use gpui::{Action, App, Entity, SharedString, actions}; use language::LanguageRegistry; use language_model::{ ConfiguredModel, LanguageModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry, @@ -159,13 +160,17 @@ pub struct NewNativeAgentThreadFromSummary { from_session_id: agent_client_protocol::SessionId, } -#[derive(Default, Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)] +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] enum ExternalAgent { #[default] Gemini, ClaudeCode, NativeAgent, + Custom { + name: SharedString, + settings: AgentServerSettings, + }, } impl ExternalAgent { @@ -175,9 +180,13 @@ impl ExternalAgent { history: Entity, ) -> Rc { match self { - ExternalAgent::Gemini => Rc::new(agent_servers::Gemini), - ExternalAgent::ClaudeCode => Rc::new(agent_servers::ClaudeCode), - ExternalAgent::NativeAgent => Rc::new(agent2::NativeAgentServer::new(fs, history)), + Self::Gemini => Rc::new(agent_servers::Gemini), + Self::ClaudeCode => Rc::new(agent_servers::ClaudeCode), + Self::NativeAgent => Rc::new(agent2::NativeAgentServer::new(fs, history)), + Self::Custom { name, settings } => Rc::new(agent_servers::CustomAgentServer::new( + name.clone(), + settings, + )), } } } diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index e0a3866443..d5313b6a3a 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -643,11 +643,11 @@ pub trait LanguageModelProvider: 'static { fn reset_credentials(&self, cx: &mut App) -> Task>; } -#[derive(Default, Clone, Copy)] +#[derive(Default, Clone)] pub enum ConfigurationViewTargetAgent { #[default] ZedAgent, - Other(&'static str), + Other(SharedString), } #[derive(PartialEq, Eq)] diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index 0d061c0587..c492edeaf5 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -1041,9 +1041,9 @@ impl Render for ConfigurationView { v_flex() .size_full() .on_action(cx.listener(Self::save_api_key)) - .child(Label::new(format!("To use {}, you need to add an API key. Follow these steps:", match self.target_agent { - ConfigurationViewTargetAgent::ZedAgent => "Zed's agent with Anthropic", - ConfigurationViewTargetAgent::Other(agent) => agent, + .child(Label::new(format!("To use {}, you need to add an API key. Follow these steps:", match &self.target_agent { + ConfigurationViewTargetAgent::ZedAgent => "Zed's agent with Anthropic".into(), + ConfigurationViewTargetAgent::Other(agent) => agent.clone(), }))) .child( List::new() diff --git a/crates/language_models/src/provider/google.rs b/crates/language_models/src/provider/google.rs index 566620675e..f252ab7aa3 100644 --- a/crates/language_models/src/provider/google.rs +++ b/crates/language_models/src/provider/google.rs @@ -921,9 +921,9 @@ impl Render for ConfigurationView { v_flex() .size_full() .on_action(cx.listener(Self::save_api_key)) - .child(Label::new(format!("To use {}, you need to add an API key. Follow these steps:", match self.target_agent { - ConfigurationViewTargetAgent::ZedAgent => "Zed's agent with Google AI", - ConfigurationViewTargetAgent::Other(agent) => agent, + .child(Label::new(format!("To use {}, you need to add an API key. Follow these steps:", match &self.target_agent { + ConfigurationViewTargetAgent::ZedAgent => "Zed's agent with Google AI".into(), + ConfigurationViewTargetAgent::Other(agent) => agent.clone(), }))) .child( List::new() From a313e9d8691fba0b92c4ac228f853492f0a463f3 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Sat, 23 Aug 2025 16:39:14 -0400 Subject: [PATCH 57/89] acp: Animate loading context creases (#36814) - Add pulsating animation for context creases while they're loading - Add spinner in message editors (replacing send button) during the window where sending has been requested, but we haven't finished loading the message contents to send to the model - During the same window, ignore further send requests, so we don't end up sending the same message twice if you mash enter while loading is in progress - Wait for context to load before rewinding the thread when sending an edited past message, avoiding an empty-looking state during the same window Release Notes: - N/A --- Cargo.lock | 1 + crates/agent_ui/Cargo.toml | 1 + crates/agent_ui/src/acp/message_editor.rs | 224 ++++++++++++++-------- crates/agent_ui/src/acp/thread_view.rs | 92 +++++++-- 4 files changed, 217 insertions(+), 101 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c9f2d952f7..21aca94384 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -403,6 +403,7 @@ dependencies = [ "parking_lot", "paths", "picker", + "postage", "pretty_assertions", "project", "prompt_store", diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 43e3b25124..6b0979ee69 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -67,6 +67,7 @@ ordered-float.workspace = true parking_lot.workspace = true paths.workspace = true picker.workspace = true +postage.workspace = true project.workspace = true prompt_store.workspace = true proto.workspace = true diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index 5a8abeb07a..7b9dba027f 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -21,12 +21,13 @@ use futures::{ future::{Shared, join_all}, }; use gpui::{ - AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable, - HighlightStyle, Image, ImageFormat, Img, KeyContext, Subscription, Task, TextStyle, - UnderlineStyle, WeakEntity, + Animation, AnimationExt as _, AppContext, ClipboardEntry, Context, Entity, EntityId, + EventEmitter, FocusHandle, Focusable, HighlightStyle, Image, ImageFormat, Img, KeyContext, + Subscription, Task, TextStyle, UnderlineStyle, WeakEntity, pulsating_between, }; use language::{Buffer, Language}; use language_model::LanguageModelImage; +use postage::stream::Stream as _; use project::{CompletionIntent, Project, ProjectItem, ProjectPath, Worktree}; use prompt_store::{PromptId, PromptStore}; use rope::Point; @@ -44,10 +45,10 @@ use std::{ use text::{OffsetRangeExt, ToOffset as _}; use theme::ThemeSettings; use ui::{ - ActiveTheme, AnyElement, App, ButtonCommon, ButtonLike, ButtonStyle, Color, Icon, IconName, - IconSize, InteractiveElement, IntoElement, Label, LabelCommon, LabelSize, ParentElement, - Render, SelectableButton, SharedString, Styled, TextSize, TintColor, Toggleable, Window, div, - h_flex, px, + ActiveTheme, AnyElement, App, ButtonCommon, ButtonLike, ButtonStyle, Color, Element as _, + FluentBuilder as _, Icon, IconName, IconSize, InteractiveElement, IntoElement, Label, + LabelCommon, LabelSize, ParentElement, Render, SelectableButton, SharedString, Styled, + TextSize, TintColor, Toggleable, Window, div, h_flex, px, }; use util::{ResultExt, debug_panic}; use workspace::{Workspace, notifications::NotifyResultExt as _}; @@ -246,7 +247,7 @@ impl MessageEditor { .buffer_snapshot .anchor_before(start_anchor.to_offset(&snapshot.buffer_snapshot) + content_len + 1); - let crease_id = if let MentionUri::File { abs_path } = &mention_uri + let crease = if let MentionUri::File { abs_path } = &mention_uri && let Some(extension) = abs_path.extension() && let Some(extension) = extension.to_str() && Img::extensions().contains(&extension) @@ -272,29 +273,31 @@ impl MessageEditor { Ok(image) }) .shared(); - insert_crease_for_image( + insert_crease_for_mention( *excerpt_id, start, content_len, - Some(abs_path.as_path().into()), - image, + mention_uri.name().into(), + IconName::Image.path().into(), + Some(image), self.editor.clone(), window, cx, ) } else { - crate::context_picker::insert_crease_for_mention( + insert_crease_for_mention( *excerpt_id, start, content_len, crease_text, mention_uri.icon_path(cx), + None, self.editor.clone(), window, cx, ) }; - let Some(crease_id) = crease_id else { + let Some((crease_id, tx)) = crease else { return Task::ready(()); }; @@ -331,7 +334,9 @@ impl MessageEditor { // Notify the user if we failed to load the mentioned context cx.spawn_in(window, async move |this, cx| { - if task.await.notify_async_err(cx).is_none() { + let result = task.await.notify_async_err(cx); + drop(tx); + if result.is_none() { this.update(cx, |this, cx| { this.editor.update(cx, |editor, cx| { // Remove mention @@ -857,12 +862,13 @@ impl MessageEditor { snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len) }); let image = Arc::new(image); - let Some(crease_id) = insert_crease_for_image( + let Some((crease_id, tx)) = insert_crease_for_mention( excerpt_id, text_anchor, content_len, - None.clone(), - Task::ready(Ok(image.clone())).shared(), + MentionUri::PastedImage.name().into(), + IconName::Image.path().into(), + Some(Task::ready(Ok(image.clone())).shared()), self.editor.clone(), window, cx, @@ -877,6 +883,7 @@ impl MessageEditor { .update(|_, cx| LanguageModelImage::from_image(image, cx)) .map_err(|e| e.to_string())? .await; + drop(tx); if let Some(image) = image { Ok(Mention::Image(MentionImage { data: image.source, @@ -1097,18 +1104,20 @@ impl MessageEditor { for (range, mention_uri, mention) in mentions { let anchor = snapshot.anchor_before(range.start); - let Some(crease_id) = crate::context_picker::insert_crease_for_mention( + let Some((crease_id, tx)) = insert_crease_for_mention( anchor.excerpt_id, anchor.text_anchor, range.end - range.start, mention_uri.name().into(), mention_uri.icon_path(cx), + None, self.editor.clone(), window, cx, ) else { continue; }; + drop(tx); self.mention_set.mentions.insert( crease_id, @@ -1227,23 +1236,21 @@ impl Render for MessageEditor { } } -pub(crate) fn insert_crease_for_image( +pub(crate) fn insert_crease_for_mention( excerpt_id: ExcerptId, anchor: text::Anchor, content_len: usize, - abs_path: Option>, - image: Shared, String>>>, + crease_label: SharedString, + crease_icon: SharedString, + // abs_path: Option>, + image: Option, String>>>>, editor: Entity, window: &mut Window, cx: &mut App, -) -> Option { - let crease_label = abs_path - .as_ref() - .and_then(|path| path.file_name()) - .map(|name| name.to_string_lossy().to_string().into()) - .unwrap_or(SharedString::from("Image")); +) -> Option<(CreaseId, postage::barrier::Sender)> { + let (tx, rx) = postage::barrier::channel(); - editor.update(cx, |editor, cx| { + let crease_id = editor.update(cx, |editor, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); let start = snapshot.anchor_in_excerpt(excerpt_id, anchor)?; @@ -1252,7 +1259,15 @@ pub(crate) fn insert_crease_for_image( let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len); let placeholder = FoldPlaceholder { - render: render_image_fold_icon_button(crease_label, image, cx.weak_entity()), + render: render_fold_icon_button( + crease_label, + crease_icon, + start..end, + rx, + image, + cx.weak_entity(), + cx, + ), merge_adjacent: false, ..Default::default() }; @@ -1269,63 +1284,112 @@ pub(crate) fn insert_crease_for_image( editor.fold_creases(vec![crease], false, window, cx); Some(ids[0]) - }) + })?; + + Some((crease_id, tx)) } -fn render_image_fold_icon_button( +fn render_fold_icon_button( label: SharedString, - image_task: Shared, String>>>, + icon: SharedString, + range: Range, + mut loading_finished: postage::barrier::Receiver, + image_task: Option, String>>>>, editor: WeakEntity, + cx: &mut App, ) -> Arc, &mut App) -> AnyElement> { - Arc::new({ - move |fold_id, fold_range, cx| { - let is_in_text_selection = editor - .update(cx, |editor, cx| editor.is_range_selected(&fold_range, cx)) - .unwrap_or_default(); - - ButtonLike::new(fold_id) - .style(ButtonStyle::Filled) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .toggle_state(is_in_text_selection) - .child( - h_flex() - .gap_1() - .child( - Icon::new(IconName::Image) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child( - Label::new(label.clone()) - .size(LabelSize::Small) - .buffer_font(cx) - .single_line(), - ), - ) - .hoverable_tooltip({ - let image_task = image_task.clone(); - move |_, cx| { - let image = image_task.peek().cloned().transpose().ok().flatten(); - let image_task = image_task.clone(); - cx.new::(|cx| ImageHover { - image, - _task: cx.spawn(async move |this, cx| { - if let Ok(image) = image_task.clone().await { - this.update(cx, |this, cx| { - if this.image.replace(image).is_none() { - cx.notify(); - } - }) - .ok(); - } - }), - }) - .into() - } - }) - .into_any_element() + let loading = cx.new(|cx| { + let loading = cx.spawn(async move |this, cx| { + loading_finished.recv().await; + this.update(cx, |this: &mut LoadingContext, cx| { + this.loading = None; + cx.notify(); + }) + .ok(); + }); + LoadingContext { + id: cx.entity_id(), + label, + icon, + range, + editor, + loading: Some(loading), + image: image_task.clone(), } - }) + }); + Arc::new(move |_fold_id, _fold_range, _cx| loading.clone().into_any_element()) +} + +struct LoadingContext { + id: EntityId, + label: SharedString, + icon: SharedString, + range: Range, + editor: WeakEntity, + loading: Option>, + image: Option, String>>>>, +} + +impl Render for LoadingContext { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let is_in_text_selection = self + .editor + .update(cx, |editor, cx| editor.is_range_selected(&self.range, cx)) + .unwrap_or_default(); + ButtonLike::new(("loading-context", self.id)) + .style(ButtonStyle::Filled) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .toggle_state(is_in_text_selection) + .when_some(self.image.clone(), |el, image_task| { + el.hoverable_tooltip(move |_, cx| { + let image = image_task.peek().cloned().transpose().ok().flatten(); + let image_task = image_task.clone(); + cx.new::(|cx| ImageHover { + image, + _task: cx.spawn(async move |this, cx| { + if let Ok(image) = image_task.clone().await { + this.update(cx, |this, cx| { + if this.image.replace(image).is_none() { + cx.notify(); + } + }) + .ok(); + } + }), + }) + .into() + }) + }) + .child( + h_flex() + .gap_1() + .child( + Icon::from_path(self.icon.clone()) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child( + Label::new(self.label.clone()) + .size(LabelSize::Small) + .buffer_font(cx) + .single_line(), + ) + .map(|el| { + if self.loading.is_some() { + el.with_animation( + "loading-context-crease", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |label, delta| label.opacity(delta), + ) + .into_any() + } else { + el.into_any() + } + }), + ) + } } struct ImageHover { diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 87928767c6..3ad3ecbf61 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -277,6 +277,7 @@ pub struct AcpThreadView { terminal_expanded: bool, editing_message: Option, prompt_capabilities: Rc>, + is_loading_contents: bool, _cancel_task: Option>, _subscriptions: [Subscription; 3], } @@ -389,6 +390,7 @@ impl AcpThreadView { history_store, hovered_recent_history_item: None, prompt_capabilities, + is_loading_contents: false, _subscriptions: subscriptions, _cancel_task: None, focus_handle: cx.focus_handle(), @@ -823,6 +825,11 @@ impl AcpThreadView { fn send(&mut self, window: &mut Window, cx: &mut Context) { let Some(thread) = self.thread() else { return }; + + if self.is_loading_contents { + return; + } + self.history_store.update(cx, |history, cx| { history.push_recently_opened_entry( HistoryEntryId::AcpThread(thread.read(cx).session_id().clone()), @@ -876,6 +883,15 @@ impl AcpThreadView { let Some(thread) = self.thread().cloned() else { return; }; + + self.is_loading_contents = true; + let guard = cx.new(|_| ()); + cx.observe_release(&guard, |this, _guard, cx| { + this.is_loading_contents = false; + cx.notify(); + }) + .detach(); + let task = cx.spawn_in(window, async move |this, cx| { let (contents, tracked_buffers) = contents.await?; @@ -896,6 +912,7 @@ impl AcpThreadView { action_log.buffer_read(buffer, cx) } }); + drop(guard); thread.send(contents, cx) })?; send.await @@ -950,19 +967,24 @@ impl AcpThreadView { let Some(thread) = self.thread().cloned() else { return; }; + if self.is_loading_contents { + return; + } - let Some(rewind) = thread.update(cx, |thread, cx| { - let user_message_id = thread.entries().get(entry_ix)?.user_message()?.id.clone()?; - Some(thread.rewind(user_message_id, cx)) + let Some(user_message_id) = thread.update(cx, |thread, _| { + thread.entries().get(entry_ix)?.user_message()?.id.clone() }) else { return; }; let contents = message_editor.update(cx, |message_editor, cx| message_editor.contents(cx)); - let task = cx.foreground_executor().spawn(async move { - rewind.await?; - contents.await + let task = cx.spawn(async move |_, cx| { + let contents = contents.await?; + thread + .update(cx, |thread, cx| thread.rewind(user_message_id, cx))? + .await?; + Ok(contents) }); self.send_impl(task, window, cx); } @@ -1341,25 +1363,34 @@ impl AcpThreadView { base_container .child( IconButton::new("cancel", IconName::Close) + .disabled(self.is_loading_contents) .icon_color(Color::Error) .icon_size(IconSize::XSmall) .on_click(cx.listener(Self::cancel_editing)) ) .child( - IconButton::new("regenerate", IconName::Return) - .icon_color(Color::Muted) - .icon_size(IconSize::XSmall) - .tooltip(Tooltip::text( - "Editing will restart the thread from this point." - )) - .on_click(cx.listener({ - let editor = editor.clone(); - move |this, _, window, cx| { - this.regenerate( - entry_ix, &editor, window, cx, - ); - } - })), + if self.is_loading_contents { + div() + .id("loading-edited-message-content") + .tooltip(Tooltip::text("Loading Added Context…")) + .child(loading_contents_spinner(IconSize::XSmall)) + .into_any_element() + } else { + IconButton::new("regenerate", IconName::Return) + .icon_color(Color::Muted) + .icon_size(IconSize::XSmall) + .tooltip(Tooltip::text( + "Editing will restart the thread from this point." + )) + .on_click(cx.listener({ + let editor = editor.clone(); + move |this, _, window, cx| { + this.regenerate( + entry_ix, &editor, window, cx, + ); + } + })).into_any_element() + } ) ) } else { @@ -3542,7 +3573,14 @@ impl AcpThreadView { .thread() .is_some_and(|thread| thread.read(cx).status() != ThreadStatus::Idle); - if is_generating && is_editor_empty { + if self.is_loading_contents { + div() + .id("loading-message-content") + .px_1() + .tooltip(Tooltip::text("Loading Added Context…")) + .child(loading_contents_spinner(IconSize::default())) + .into_any_element() + } else if is_generating && is_editor_empty { IconButton::new("stop-generation", IconName::Stop) .icon_color(Color::Error) .style(ButtonStyle::Tinted(ui::TintColor::Error)) @@ -4643,6 +4681,18 @@ impl AcpThreadView { } } +fn loading_contents_spinner(size: IconSize) -> AnyElement { + Icon::new(IconName::LoadCircle) + .size(size) + .color(Color::Accent) + .with_animation( + "load_context_circle", + Animation::new(Duration::from_secs(3)).repeat(), + |icon, delta| icon.transform(Transformation::rotate(percentage(delta))), + ) + .into_any_element() +} + impl Focusable for AcpThreadView { fn focus_handle(&self, cx: &App) -> FocusHandle { match self.thread_state { From 29120ad573808accc910a0c8e2e6bce12ab20911 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Sat, 23 Aug 2025 20:02:23 -0400 Subject: [PATCH 58/89] acp: Fix accidentally reverted thread view changes (#36825) Merge conflict resolution for #36741 accidentally reverted the changes in #36670 to allow expanding terminals individually and in #36675 to allow collapsing edit cards. This PR re-applies those changes, fixing the regression. Release Notes: - N/A --- crates/agent_ui/src/acp/thread_view.rs | 27 +++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 3ad3ecbf61..d62ccf4cef 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -274,7 +274,6 @@ pub struct AcpThreadView { edits_expanded: bool, plan_expanded: bool, editor_expanded: bool, - terminal_expanded: bool, editing_message: Option, prompt_capabilities: Rc>, is_loading_contents: bool, @@ -386,7 +385,6 @@ impl AcpThreadView { edits_expanded: false, plan_expanded: false, editor_expanded: false, - terminal_expanded: true, history_store, hovered_recent_history_item: None, prompt_capabilities, @@ -1722,10 +1720,9 @@ impl AcpThreadView { matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some(); let use_card_layout = needs_confirmation || is_edit; - let is_collapsible = !tool_call.content.is_empty() && !use_card_layout; + let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation; - let is_open = - needs_confirmation || is_edit || self.expanded_tool_calls.contains(&tool_call.id); + let is_open = needs_confirmation || self.expanded_tool_calls.contains(&tool_call.id); let gradient_overlay = |color: Hsla| { div() @@ -2195,6 +2192,8 @@ impl AcpThreadView { .map(|path| format!("{}", path.display())) .unwrap_or_else(|| "current directory".to_string()); + let is_expanded = self.expanded_tool_calls.contains(&tool_call.id); + let header = h_flex() .id(SharedString::from(format!( "terminal-tool-header-{}", @@ -2328,21 +2327,27 @@ impl AcpThreadView { "terminal-tool-disclosure-{}", terminal.entity_id() )), - self.terminal_expanded, + is_expanded, ) .opened_icon(IconName::ChevronUp) .closed_icon(IconName::ChevronDown) - .on_click(cx.listener(move |this, _event, _window, _cx| { - this.terminal_expanded = !this.terminal_expanded; - })), - ); + .on_click(cx.listener({ + let id = tool_call.id.clone(); + move |this, _event, _window, _cx| { + if is_expanded { + this.expanded_tool_calls.remove(&id); + } else { + this.expanded_tool_calls.insert(id.clone()); + } + }})), + ); let terminal_view = self .entry_view_state .read(cx) .entry(entry_ix) .and_then(|entry| entry.terminal(terminal)); - let show_output = self.terminal_expanded && terminal_view.is_some(); + let show_output = is_expanded && terminal_view.is_some(); v_flex() .mb_2() From 3b4c89124220a8d01453e08f58c3b98803d8140a Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Sun, 24 Aug 2025 13:05:39 +0200 Subject: [PATCH 59/89] acp: Cancel editing when focus is lost and message was not changed (#36822) Release Notes: - N/A --- crates/acp_thread/src/acp_thread.rs | 2 +- crates/agent_ui/src/acp/message_editor.rs | 16 ++++++++++------ crates/agent_ui/src/acp/thread_view.rs | 13 +++++++++++++ 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index c748f22275..029d175054 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -509,7 +509,7 @@ impl ContentBlock { "`Image`".into() } - fn to_markdown<'a>(&'a self, cx: &'a App) -> &'a str { + pub fn to_markdown<'a>(&'a self, cx: &'a App) -> &'a str { match self { ContentBlock::Empty => "", ContentBlock::Markdown { markdown } => markdown.read(cx).source(), diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index 7b9dba027f..e1ae4a0938 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -74,6 +74,7 @@ pub enum MessageEditorEvent { Send, Cancel, Focus, + LostFocus, } impl EventEmitter for MessageEditor {} @@ -131,10 +132,14 @@ impl MessageEditor { editor }); - cx.on_focus(&editor.focus_handle(cx), window, |_, _, cx| { + cx.on_focus_in(&editor.focus_handle(cx), window, |_, _, cx| { cx.emit(MessageEditorEvent::Focus) }) .detach(); + cx.on_focus_out(&editor.focus_handle(cx), window, |_, _, _, cx| { + cx.emit(MessageEditorEvent::LostFocus) + }) + .detach(); let mut subscriptions = Vec::new(); subscriptions.push(cx.subscribe_in(&editor, window, { @@ -1169,17 +1174,16 @@ impl MessageEditor { }) } + pub fn text(&self, cx: &App) -> String { + self.editor.read(cx).text(cx) + } + #[cfg(test)] pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context) { self.editor.update(cx, |editor, cx| { editor.set_text(text, window, cx); }); } - - #[cfg(test)] - pub fn text(&self, cx: &App) -> String { - self.editor.read(cx).text(cx) - } } fn render_directory_contents(entries: Vec<(Arc, PathBuf, String)>) -> String { diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index d62ccf4cef..9caa4bad8c 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -762,6 +762,7 @@ impl AcpThreadView { MessageEditorEvent::Focus => { self.cancel_editing(&Default::default(), window, cx); } + MessageEditorEvent::LostFocus => {} } } @@ -793,6 +794,18 @@ impl AcpThreadView { cx.notify(); } } + ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::LostFocus) => { + if let Some(thread) = self.thread() + && let Some(AgentThreadEntry::UserMessage(user_message)) = + thread.read(cx).entries().get(event.entry_index) + && user_message.id.is_some() + { + if editor.read(cx).text(cx).as_str() == user_message.content.to_markdown(cx) { + self.editing_message = None; + cx.notify(); + } + } + } ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::Send) => { self.regenerate(event.entry_index, editor, window, cx); } From 8bac692757d73379cd0b9d3f9687beed43f04c1a Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Sun, 24 Aug 2025 18:30:34 +0200 Subject: [PATCH 60/89] acp: Never build a request with a tool use without its corresponding result (#36847) Release Notes: - N/A --- crates/agent2/src/tests/mod.rs | 76 ++++++++++++++++++++++++++++++++++ crates/agent2/src/thread.rs | 74 ++++++++++++++++----------------- 2 files changed, 113 insertions(+), 37 deletions(-) diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index 60b3198081..5b935dae4c 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -4,6 +4,7 @@ use agent_client_protocol::{self as acp}; use agent_settings::AgentProfileId; use anyhow::Result; use client::{Client, UserStore}; +use cloud_llm_client::CompletionIntent; use context_server::{ContextServer, ContextServerCommand, ContextServerId}; use fs::{FakeFs, Fs}; use futures::{ @@ -1737,6 +1738,81 @@ async fn test_title_generation(cx: &mut TestAppContext) { thread.read_with(cx, |thread, _| assert_eq!(thread.title(), "Hello world")); } +#[gpui::test] +async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + let _events = thread + .update(cx, |thread, cx| { + thread.add_tool(ToolRequiringPermission); + thread.add_tool(EchoTool); + thread.send(UserMessageId::new(), ["Hey!"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + let permission_tool_use = LanguageModelToolUse { + id: "tool_id_1".into(), + name: ToolRequiringPermission::name().into(), + raw_input: "{}".into(), + input: json!({}), + is_input_complete: true, + }; + let echo_tool_use = LanguageModelToolUse { + id: "tool_id_2".into(), + name: EchoTool::name().into(), + raw_input: json!({"text": "test"}).to_string(), + input: json!({"text": "test"}), + is_input_complete: true, + }; + fake_model.send_last_completion_stream_text_chunk("Hi!"); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + permission_tool_use, + )); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + echo_tool_use.clone(), + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + // Ensure pending tools are skipped when building a request. + let request = thread + .read_with(cx, |thread, cx| { + thread.build_completion_request(CompletionIntent::EditFile, cx) + }) + .unwrap(); + assert_eq!( + request.messages[1..], + vec![ + LanguageModelRequestMessage { + role: Role::User, + content: vec!["Hey!".into()], + cache: true + }, + LanguageModelRequestMessage { + role: Role::Assistant, + content: vec![ + MessageContent::Text("Hi!".into()), + MessageContent::ToolUse(echo_tool_use.clone()) + ], + cache: false + }, + LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::ToolResult(LanguageModelToolResult { + tool_use_id: echo_tool_use.id.clone(), + tool_name: echo_tool_use.name, + is_error: false, + content: "test".into(), + output: Some("test".into()) + })], + cache: false + }, + ], + ); +} + #[gpui::test] async fn test_agent_connection(cx: &mut TestAppContext) { cx.update(settings::init); diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 6d616f73fc..c000027368 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -448,24 +448,33 @@ impl AgentMessage { cache: false, }; for chunk in &self.content { - let chunk = match chunk { + match chunk { AgentMessageContent::Text(text) => { - language_model::MessageContent::Text(text.clone()) + assistant_message + .content + .push(language_model::MessageContent::Text(text.clone())); } AgentMessageContent::Thinking { text, signature } => { - language_model::MessageContent::Thinking { - text: text.clone(), - signature: signature.clone(), - } + assistant_message + .content + .push(language_model::MessageContent::Thinking { + text: text.clone(), + signature: signature.clone(), + }); } AgentMessageContent::RedactedThinking(value) => { - language_model::MessageContent::RedactedThinking(value.clone()) + assistant_message.content.push( + language_model::MessageContent::RedactedThinking(value.clone()), + ); } - AgentMessageContent::ToolUse(value) => { - language_model::MessageContent::ToolUse(value.clone()) + AgentMessageContent::ToolUse(tool_use) => { + if self.tool_results.contains_key(&tool_use.id) { + assistant_message + .content + .push(language_model::MessageContent::ToolUse(tool_use.clone())); + } } }; - assistant_message.content.push(chunk); } let mut user_message = LanguageModelRequestMessage { @@ -1315,23 +1324,6 @@ impl Thread { } } - pub fn build_system_message(&self, cx: &App) -> LanguageModelRequestMessage { - log::debug!("Building system message"); - let prompt = SystemPromptTemplate { - project: self.project_context.read(cx), - available_tools: self.tools.keys().cloned().collect(), - } - .render(&self.templates) - .context("failed to build system prompt") - .expect("Invalid template"); - log::debug!("System message built"); - LanguageModelRequestMessage { - role: Role::System, - content: vec![prompt.into()], - cache: true, - } - } - /// A helper method that's called on every streamed completion event. /// Returns an optional tool result task, which the main agentic loop will /// send back to the model when it resolves. @@ -1773,7 +1765,7 @@ impl Thread { pub(crate) fn build_completion_request( &self, completion_intent: CompletionIntent, - cx: &mut App, + cx: &App, ) -> Result { let model = self.model().context("No language model configured")?; let tools = if let Some(turn) = self.running_turn.as_ref() { @@ -1894,21 +1886,29 @@ impl Thread { "Building request messages from {} thread messages", self.messages.len() ); - let mut messages = vec![self.build_system_message(cx)]; + + let system_prompt = SystemPromptTemplate { + project: self.project_context.read(cx), + available_tools: self.tools.keys().cloned().collect(), + } + .render(&self.templates) + .context("failed to build system prompt") + .expect("Invalid template"); + let mut messages = vec![LanguageModelRequestMessage { + role: Role::System, + content: vec![system_prompt.into()], + cache: false, + }]; for message in &self.messages { messages.extend(message.to_request()); } - if let Some(message) = self.pending_message.as_ref() { - messages.extend(message.to_request()); + if let Some(last_message) = messages.last_mut() { + last_message.cache = true; } - if let Some(last_user_message) = messages - .iter_mut() - .rev() - .find(|message| message.role == Role::User) - { - last_user_message.cache = true; + if let Some(message) = self.pending_message.as_ref() { + messages.extend(message.to_request()); } messages From 6e45a893de0aa0dcd4227e3f6a2d23ed009e1a9c Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 25 Aug 2025 08:18:23 -0300 Subject: [PATCH 61/89] thread view: Add a few UI tweaks (#36845) Release Notes: - N/A --- assets/icons/copy.svg | 5 +- crates/agent_ui/src/acp/thread_view.rs | 76 +++++++++++--------------- crates/markdown/src/markdown.rs | 11 ++-- 3 files changed, 43 insertions(+), 49 deletions(-) diff --git a/assets/icons/copy.svg b/assets/icons/copy.svg index bca13f8d56..aba193930b 100644 --- a/assets/icons/copy.svg +++ b/assets/icons/copy.svg @@ -1 +1,4 @@ - + + + + diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 9caa4bad8c..0b987e25b6 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -1306,7 +1306,11 @@ impl AcpThreadView { v_flex() .id(("user_message", entry_ix)) - .pt_2() + .map(|this| if rules_item.is_some() { + this.pt_3() + } else { + this.pt_2() + }) .pb_4() .px_2() .gap_1p5() @@ -1315,6 +1319,7 @@ impl AcpThreadView { .children(message.id.clone().and_then(|message_id| { message.checkpoint.as_ref()?.show.then(|| { h_flex() + .px_3() .gap_2() .child(Divider::horizontal()) .child( @@ -1492,9 +1497,7 @@ impl AcpThreadView { .child(self.render_thread_controls(cx)) .when_some( self.thread_feedback.comments_editor.clone(), - |this, editor| { - this.child(Self::render_feedback_feedback_editor(editor, window, cx)) - }, + |this, editor| this.child(Self::render_feedback_feedback_editor(editor, cx)), ) .into_any_element() } else { @@ -1725,6 +1728,7 @@ impl AcpThreadView { tool_call.status, ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed ); + let needs_confirmation = matches!( tool_call.status, ToolCallStatus::WaitingForConfirmation { .. } @@ -1742,7 +1746,7 @@ impl AcpThreadView { .absolute() .top_0() .right_0() - .w_16() + .w_12() .h_full() .bg(linear_gradient( 90., @@ -1902,7 +1906,7 @@ impl AcpThreadView { .into_any() }), ) - .when(in_progress && use_card_layout, |this| { + .when(in_progress && use_card_layout && !is_open, |this| { this.child( div().absolute().right_2().child( Icon::new(IconName::ArrowCircle) @@ -2460,7 +2464,6 @@ impl AcpThreadView { Some( h_flex() .px_2p5() - .pb_1() .child( Icon::new(IconName::Attach) .size(IconSize::XSmall) @@ -2476,8 +2479,7 @@ impl AcpThreadView { Label::new(user_rules_text) .size(LabelSize::XSmall) .color(Color::Muted) - .truncate() - .buffer_font(cx), + .truncate(), ) .hover(|s| s.bg(cx.theme().colors().element_hover)) .tooltip(Tooltip::text("View User Rules")) @@ -2491,7 +2493,13 @@ impl AcpThreadView { }), ) }) - .when(has_both, |this| this.child(Divider::vertical())) + .when(has_both, |this| { + this.child( + Label::new("•") + .size(LabelSize::XSmall) + .color(Color::Disabled), + ) + }) .when_some(rules_file_text, |parent, rules_file_text| { parent.child( h_flex() @@ -2500,8 +2508,7 @@ impl AcpThreadView { .child( Label::new(rules_file_text) .size(LabelSize::XSmall) - .color(Color::Muted) - .buffer_font(cx), + .color(Color::Muted), ) .hover(|s| s.bg(cx.theme().colors().element_hover)) .tooltip(Tooltip::text("View Project Rules")) @@ -3078,13 +3085,13 @@ impl AcpThreadView { h_flex() .p_1() .justify_between() + .flex_wrap() .when(expanded, |this| { this.border_b_1().border_color(cx.theme().colors().border) }) .child( h_flex() .id("edits-container") - .w_full() .gap_1() .child(Disclosure::new("edits-disclosure", expanded)) .map(|this| { @@ -4177,13 +4184,8 @@ impl AcpThreadView { container.child(open_as_markdown).child(scroll_to_top) } - fn render_feedback_feedback_editor( - editor: Entity, - window: &mut Window, - cx: &Context, - ) -> Div { - let focus_handle = editor.focus_handle(cx); - v_flex() + fn render_feedback_feedback_editor(editor: Entity, cx: &Context) -> Div { + h_flex() .key_context("AgentFeedbackMessageEditor") .on_action(cx.listener(move |this, _: &menu::Cancel, _, cx| { this.thread_feedback.dismiss_comments(); @@ -4192,43 +4194,31 @@ impl AcpThreadView { .on_action(cx.listener(move |this, _: &menu::Confirm, _window, cx| { this.submit_feedback_message(cx); })) - .mb_2() - .mx_4() .p_2() + .mb_2() + .mx_5() + .gap_1() .rounded_md() .border_1() .border_color(cx.theme().colors().border) .bg(cx.theme().colors().editor_background) - .child(editor) + .child(div().w_full().child(editor)) .child( h_flex() - .gap_1() - .justify_end() .child( - Button::new("dismiss-feedback-message", "Cancel") - .label_size(LabelSize::Small) - .key_binding( - KeyBinding::for_action_in(&menu::Cancel, &focus_handle, window, cx) - .map(|kb| kb.size(rems_from_px(10.))), - ) + IconButton::new("dismiss-feedback-message", IconName::Close) + .icon_color(Color::Error) + .icon_size(IconSize::XSmall) + .shape(ui::IconButtonShape::Square) .on_click(cx.listener(move |this, _, _window, cx| { this.thread_feedback.dismiss_comments(); cx.notify(); })), ) .child( - Button::new("submit-feedback-message", "Share Feedback") - .style(ButtonStyle::Tinted(ui::TintColor::Accent)) - .label_size(LabelSize::Small) - .key_binding( - KeyBinding::for_action_in( - &menu::Confirm, - &focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(10.))), - ) + IconButton::new("submit-feedback-message", IconName::Return) + .icon_size(IconSize::XSmall) + .shape(ui::IconButtonShape::Square) .on_click(cx.listener(move |this, _, _window, cx| { this.submit_feedback_message(cx); })), diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 39a438c512..f16da45d79 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -1085,10 +1085,10 @@ impl Element for MarkdownElement { ); el.child( h_flex() - .w_5() + .w_4() .absolute() - .top_1() - .right_1() + .top_1p5() + .right_1p5() .justify_end() .child(codeblock), ) @@ -1115,11 +1115,12 @@ impl Element for MarkdownElement { cx, ); el.child( - div() + h_flex() + .w_4() .absolute() .top_0() .right_0() - .w_5() + .justify_end() .visible_on_hover("code_block") .child(codeblock), ) From 5d0c6962348ab710da5cef33d3723ffb3dcdddec Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Mon, 25 Aug 2025 08:23:36 -0400 Subject: [PATCH 62/89] acp: Fix read_file tool flickering (#36854) We were rendering a Markdown link like `[Read file x.rs (lines Y-Z)](@selection)` while the tool ran, but then switching to just `x.rs` as soon as we got the file location from the tool call (due to an if/else in the UI code that applies to all tools). This caused a flicker, which is fixed by having `initial_title` return just the filename from the input as it arrives instead of a link that we're going to stop rendering almost immediately anyway. Release Notes: - N/A --- crates/agent2/src/tools/read_file_tool.rs | 29 ++++++----------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/crates/agent2/src/tools/read_file_tool.rs b/crates/agent2/src/tools/read_file_tool.rs index 903e1582ac..fea9732093 100644 --- a/crates/agent2/src/tools/read_file_tool.rs +++ b/crates/agent2/src/tools/read_file_tool.rs @@ -10,7 +10,7 @@ use project::{AgentLocation, ImageItem, Project, WorktreeSettings, image_store}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::Settings; -use std::sync::Arc; +use std::{path::Path, sync::Arc}; use crate::{AgentTool, ToolCallEventStream}; @@ -68,27 +68,12 @@ impl AgentTool for ReadFileTool { } fn initial_title(&self, input: Result) -> SharedString { - if let Ok(input) = input { - let path = &input.path; - match (input.start_line, input.end_line) { - (Some(start), Some(end)) => { - format!( - "[Read file `{}` (lines {}-{})](@selection:{}:({}-{}))", - path, start, end, path, start, end - ) - } - (Some(start), None) => { - format!( - "[Read file `{}` (from line {})](@selection:{}:({}-{}))", - path, start, path, start, start - ) - } - _ => format!("[Read file `{}`](@file:{})", path, path), - } - .into() - } else { - "Read file".into() - } + input + .ok() + .as_ref() + .and_then(|input| Path::new(&input.path).file_name()) + .map(|file_name| file_name.to_string_lossy().to_string().into()) + .unwrap_or_default() } fn run( From f3ab8d6111bf8aeec4db4255c9a1083e533162c7 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 25 Aug 2025 14:52:25 +0200 Subject: [PATCH 63/89] acp: Show retry button for errors (#36862) Release Notes: - N/A --------- Co-authored-by: Antonio Scandurra --- crates/acp_thread/src/acp_thread.rs | 6 +- crates/acp_thread/src/connection.rs | 8 +- crates/agent2/src/agent.rs | 8 +- crates/agent2/src/tests/mod.rs | 98 ++++++++++++++++--- crates/agent2/src/thread.rs | 124 +++++++++++++------------ crates/agent_ui/src/acp/thread_view.rs | 46 ++++++++- 6 files changed, 212 insertions(+), 78 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 029d175054..d9a7a2582a 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -1373,6 +1373,10 @@ impl AcpThread { }) } + pub fn can_resume(&self, cx: &App) -> bool { + self.connection.resume(&self.session_id, cx).is_some() + } + pub fn resume(&mut self, cx: &mut Context) -> BoxFuture<'static, Result<()>> { self.run_turn(cx, async move |this, cx| { this.update(cx, |this, cx| { @@ -2659,7 +2663,7 @@ mod tests { fn truncate( &self, session_id: &acp::SessionId, - _cx: &mut App, + _cx: &App, ) -> Option> { Some(Rc::new(FakeAgentSessionEditor { _session_id: session_id.clone(), diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 91e46dbac1..5f5032e588 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -43,7 +43,7 @@ pub trait AgentConnection { fn resume( &self, _session_id: &acp::SessionId, - _cx: &mut App, + _cx: &App, ) -> Option> { None } @@ -53,7 +53,7 @@ pub trait AgentConnection { fn truncate( &self, _session_id: &acp::SessionId, - _cx: &mut App, + _cx: &App, ) -> Option> { None } @@ -61,7 +61,7 @@ pub trait AgentConnection { fn set_title( &self, _session_id: &acp::SessionId, - _cx: &mut App, + _cx: &App, ) -> Option> { None } @@ -439,7 +439,7 @@ mod test_support { fn truncate( &self, _session_id: &agent_client_protocol::SessionId, - _cx: &mut App, + _cx: &App, ) -> Option> { Some(Rc::new(StubAgentSessionEditor)) } diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index 4eaf87e218..415933b7d1 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -936,7 +936,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection { fn resume( &self, session_id: &acp::SessionId, - _cx: &mut App, + _cx: &App, ) -> Option> { Some(Rc::new(NativeAgentSessionResume { connection: self.clone(), @@ -956,9 +956,9 @@ impl acp_thread::AgentConnection for NativeAgentConnection { fn truncate( &self, session_id: &agent_client_protocol::SessionId, - cx: &mut App, + cx: &App, ) -> Option> { - self.0.update(cx, |agent, _cx| { + self.0.read_with(cx, |agent, _cx| { agent.sessions.get(session_id).map(|session| { Rc::new(NativeAgentSessionEditor { thread: session.thread.clone(), @@ -971,7 +971,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection { fn set_title( &self, session_id: &acp::SessionId, - _cx: &mut App, + _cx: &App, ) -> Option> { Some(Rc::new(NativeAgentSessionSetTitle { connection: self.clone(), diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index 5b935dae4c..87ecc1037c 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -5,6 +5,7 @@ use agent_settings::AgentProfileId; use anyhow::Result; use client::{Client, UserStore}; use cloud_llm_client::CompletionIntent; +use collections::IndexMap; use context_server::{ContextServer, ContextServerCommand, ContextServerId}; use fs::{FakeFs, Fs}; use futures::{ @@ -673,15 +674,6 @@ async fn test_resume_after_tool_use_limit(cx: &mut TestAppContext) { "} ) }); - - // Ensure we error if calling resume when tool use limit was *not* reached. - let error = thread - .update(cx, |thread, cx| thread.resume(cx)) - .unwrap_err(); - assert_eq!( - error.to_string(), - "can only resume after tool use limit is reached" - ) } #[gpui::test] @@ -2105,6 +2097,7 @@ async fn test_send_retry_on_error(cx: &mut TestAppContext) { .unwrap(); cx.run_until_parked(); + fake_model.send_last_completion_stream_text_chunk("Hey,"); fake_model.send_last_completion_stream_error(LanguageModelCompletionError::ServerOverloaded { provider: LanguageModelProviderName::new("Anthropic"), retry_after: Some(Duration::from_secs(3)), @@ -2114,8 +2107,9 @@ async fn test_send_retry_on_error(cx: &mut TestAppContext) { cx.executor().advance_clock(Duration::from_secs(3)); cx.run_until_parked(); - fake_model.send_last_completion_stream_text_chunk("Hey!"); + fake_model.send_last_completion_stream_text_chunk("there!"); fake_model.end_last_completion_stream(); + cx.run_until_parked(); let mut retry_events = Vec::new(); while let Some(Ok(event)) = events.next().await { @@ -2143,12 +2137,94 @@ async fn test_send_retry_on_error(cx: &mut TestAppContext) { ## Assistant - Hey! + Hey, + + [resume] + + ## Assistant + + there! "} ) }); } +#[gpui::test] +async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) { + let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + let events = thread + .update(cx, |thread, cx| { + thread.set_completion_mode(agent_settings::CompletionMode::Burn, cx); + thread.add_tool(EchoTool); + thread.send(UserMessageId::new(), ["Call the echo tool!"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + let tool_use_1 = LanguageModelToolUse { + id: "tool_1".into(), + name: EchoTool::name().into(), + raw_input: json!({"text": "test"}).to_string(), + input: json!({"text": "test"}), + is_input_complete: true, + }; + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + tool_use_1.clone(), + )); + fake_model.send_last_completion_stream_error(LanguageModelCompletionError::ServerOverloaded { + provider: LanguageModelProviderName::new("Anthropic"), + retry_after: Some(Duration::from_secs(3)), + }); + fake_model.end_last_completion_stream(); + + cx.executor().advance_clock(Duration::from_secs(3)); + let completion = fake_model.pending_completions().pop().unwrap(); + assert_eq!( + completion.messages[1..], + vec![ + LanguageModelRequestMessage { + role: Role::User, + content: vec!["Call the echo tool!".into()], + cache: false + }, + LanguageModelRequestMessage { + role: Role::Assistant, + content: vec![language_model::MessageContent::ToolUse(tool_use_1.clone())], + cache: false + }, + LanguageModelRequestMessage { + role: Role::User, + content: vec![language_model::MessageContent::ToolResult( + LanguageModelToolResult { + tool_use_id: tool_use_1.id.clone(), + tool_name: tool_use_1.name.clone(), + is_error: false, + content: "test".into(), + output: Some("test".into()) + } + )], + cache: true + }, + ] + ); + + fake_model.send_last_completion_stream_text_chunk("Done"); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + events.collect::>().await; + thread.read_with(cx, |thread, _cx| { + assert_eq!( + thread.last_message(), + Some(Message::Agent(AgentMessage { + content: vec![AgentMessageContent::Text("Done".into())], + tool_results: IndexMap::default() + })) + ); + }) +} + #[gpui::test] async fn test_send_max_retries_exceeded(cx: &mut TestAppContext) { let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index c000027368..43f391ca64 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -123,7 +123,7 @@ impl Message { match self { Message::User(message) => message.to_markdown(), Message::Agent(message) => message.to_markdown(), - Message::Resume => "[resumed after tool use limit was reached]".into(), + Message::Resume => "[resume]\n".into(), } } @@ -1085,11 +1085,6 @@ impl Thread { &mut self, cx: &mut Context, ) -> Result>> { - anyhow::ensure!( - self.tool_use_limit_reached, - "can only resume after tool use limit is reached" - ); - self.messages.push(Message::Resume); cx.notify(); @@ -1216,12 +1211,13 @@ impl Thread { cx: &mut AsyncApp, ) -> Result<()> { log::debug!("Stream completion started successfully"); - let request = this.update(cx, |this, cx| { - this.build_completion_request(completion_intent, cx) - })??; let mut attempt = None; - 'retry: loop { + loop { + let request = this.update(cx, |this, cx| { + this.build_completion_request(completion_intent, cx) + })??; + telemetry::event!( "Agent Thread Completion", thread_id = this.read_with(cx, |this, _| this.id.to_string())?, @@ -1236,10 +1232,11 @@ impl Thread { attempt.unwrap_or(0) ); let mut events = model - .stream_completion(request.clone(), cx) + .stream_completion(request, cx) .await .map_err(|error| anyhow!(error))?; let mut tool_results = FuturesUnordered::new(); + let mut error = None; while let Some(event) = events.next().await { match event { @@ -1249,51 +1246,9 @@ impl Thread { this.handle_streamed_completion_event(event, event_stream, cx) })??); } - Err(error) => { - let completion_mode = - this.read_with(cx, |thread, _cx| thread.completion_mode())?; - if completion_mode == CompletionMode::Normal { - return Err(anyhow!(error))?; - } - - let Some(strategy) = Self::retry_strategy_for(&error) else { - return Err(anyhow!(error))?; - }; - - let max_attempts = match &strategy { - RetryStrategy::ExponentialBackoff { max_attempts, .. } => *max_attempts, - RetryStrategy::Fixed { max_attempts, .. } => *max_attempts, - }; - - let attempt = attempt.get_or_insert(0u8); - - *attempt += 1; - - let attempt = *attempt; - if attempt > max_attempts { - return Err(anyhow!(error))?; - } - - let delay = match &strategy { - RetryStrategy::ExponentialBackoff { initial_delay, .. } => { - let delay_secs = - initial_delay.as_secs() * 2u64.pow((attempt - 1) as u32); - Duration::from_secs(delay_secs) - } - RetryStrategy::Fixed { delay, .. } => *delay, - }; - log::debug!("Retry attempt {attempt} with delay {delay:?}"); - - event_stream.send_retry(acp_thread::RetryStatus { - last_error: error.to_string().into(), - attempt: attempt as usize, - max_attempts: max_attempts as usize, - started_at: Instant::now(), - duration: delay, - }); - - cx.background_executor().timer(delay).await; - continue 'retry; + Err(err) => { + error = Some(err); + break; } } } @@ -1320,7 +1275,58 @@ impl Thread { })?; } - return Ok(()); + if let Some(error) = error { + let completion_mode = this.read_with(cx, |thread, _cx| thread.completion_mode())?; + if completion_mode == CompletionMode::Normal { + return Err(anyhow!(error))?; + } + + let Some(strategy) = Self::retry_strategy_for(&error) else { + return Err(anyhow!(error))?; + }; + + let max_attempts = match &strategy { + RetryStrategy::ExponentialBackoff { max_attempts, .. } => *max_attempts, + RetryStrategy::Fixed { max_attempts, .. } => *max_attempts, + }; + + let attempt = attempt.get_or_insert(0u8); + + *attempt += 1; + + let attempt = *attempt; + if attempt > max_attempts { + return Err(anyhow!(error))?; + } + + let delay = match &strategy { + RetryStrategy::ExponentialBackoff { initial_delay, .. } => { + let delay_secs = initial_delay.as_secs() * 2u64.pow((attempt - 1) as u32); + Duration::from_secs(delay_secs) + } + RetryStrategy::Fixed { delay, .. } => *delay, + }; + log::debug!("Retry attempt {attempt} with delay {delay:?}"); + + event_stream.send_retry(acp_thread::RetryStatus { + last_error: error.to_string().into(), + attempt: attempt as usize, + max_attempts: max_attempts as usize, + started_at: Instant::now(), + duration: delay, + }); + cx.background_executor().timer(delay).await; + this.update(cx, |this, cx| { + this.flush_pending_message(cx); + if let Some(Message::Agent(message)) = this.messages.last() { + if message.tool_results.is_empty() { + this.messages.push(Message::Resume); + } + } + })?; + } else { + return Ok(()); + } } } @@ -1737,6 +1743,10 @@ impl Thread { return; }; + if message.content.is_empty() { + return; + } + for content in &message.content { let AgentMessageContent::ToolUse(tool_use) = content else { continue; diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 0b987e25b6..5674b15c98 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -820,6 +820,9 @@ impl AcpThreadView { let Some(thread) = self.thread() else { return; }; + if !thread.read(cx).can_resume(cx) { + return; + } let task = thread.update(cx, |thread, cx| thread.resume(cx)); cx.spawn(async move |this, cx| { @@ -4459,12 +4462,53 @@ impl AcpThreadView { } fn render_any_thread_error(&self, error: SharedString, cx: &mut Context<'_, Self>) -> Callout { + let can_resume = self + .thread() + .map_or(false, |thread| thread.read(cx).can_resume(cx)); + + let can_enable_burn_mode = self.as_native_thread(cx).map_or(false, |thread| { + let thread = thread.read(cx); + let supports_burn_mode = thread + .model() + .map_or(false, |model| model.supports_burn_mode()); + supports_burn_mode && thread.completion_mode() == CompletionMode::Normal + }); + Callout::new() .severity(Severity::Error) .title("Error") .icon(IconName::XCircle) .description(error.clone()) - .actions_slot(self.create_copy_button(error.to_string())) + .actions_slot( + h_flex() + .gap_0p5() + .when(can_resume && can_enable_burn_mode, |this| { + this.child( + Button::new("enable-burn-mode-and-retry", "Enable Burn Mode and Retry") + .icon(IconName::ZedBurnMode) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_burn_mode(&ToggleBurnMode, window, cx); + this.resume_chat(cx); + })), + ) + }) + .when(can_resume, |this| { + this.child( + Button::new("retry", "Retry") + .icon(IconName::RotateCw) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.resume_chat(cx); + })), + ) + }) + .child(self.create_copy_button(error.to_string())), + ) .dismiss_action(self.dismiss_error_button(cx)) } From b3be6ccb0f545c9199def74f962158d77ac790c4 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 25 Aug 2025 10:08:48 -0300 Subject: [PATCH 64/89] thread view: Prevent user message controls to be cut-off (#36865) In the thread view, when focusing on the user message, we display the editing control container absolutely-positioned in the top right. However, if there are no rules items and no restore checkpoint button _and_ it is the very first message, the editing controls container would be cut-off. This PR fixes that by giving it a bit more top padding. Release Notes: - N/A --- crates/agent_ui/src/acp/thread_view.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 5674b15c98..25f2745f75 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -1305,14 +1305,23 @@ impl AcpThreadView { None }; + let has_checkpoint_button = message + .checkpoint + .as_ref() + .is_some_and(|checkpoint| checkpoint.show); + let agent_name = self.agent.name(); v_flex() .id(("user_message", entry_ix)) - .map(|this| if rules_item.is_some() { - this.pt_3() - } else { - this.pt_2() + .map(|this| { + if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() { + this.pt_4() + } else if rules_item.is_some() { + this.pt_3() + } else { + this.pt_2() + } }) .pb_4() .px_2() From c3574e6046898c9ac573370446a4fa21901fb953 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 25 Aug 2025 15:14:48 +0200 Subject: [PATCH 65/89] agent2: Less noisy logs (#36863) Release Notes: - N/A --- crates/agent2/src/agent.rs | 10 ++++----- crates/agent2/src/native_agent_server.rs | 4 ++-- crates/agent2/src/thread.rs | 26 ++++++++++++------------ 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index 415933b7d1..1576c3cf96 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -180,7 +180,7 @@ impl NativeAgent { fs: Arc, cx: &mut AsyncApp, ) -> Result> { - log::info!("Creating new NativeAgent"); + log::debug!("Creating new NativeAgent"); let project_context = cx .update(|cx| Self::build_project_context(&project, prompt_store.as_ref(), cx))? @@ -756,7 +756,7 @@ impl NativeAgentConnection { } } - log::info!("Response stream completed"); + log::debug!("Response stream completed"); anyhow::Ok(acp::PromptResponse { stop_reason: acp::StopReason::EndTurn, }) @@ -781,7 +781,7 @@ impl AgentModelSelector for NativeAgentConnection { model_id: acp_thread::AgentModelId, cx: &mut App, ) -> Task> { - log::info!("Setting model for session {}: {}", session_id, model_id); + log::debug!("Setting model for session {}: {}", session_id, model_id); let Some(thread) = self .0 .read(cx) @@ -852,7 +852,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection { cx: &mut App, ) -> Task>> { let agent = self.0.clone(); - log::info!("Creating new thread for project at: {:?}", cwd); + log::debug!("Creating new thread for project at: {:?}", cwd); cx.spawn(async move |cx| { log::debug!("Starting thread creation in async context"); @@ -917,7 +917,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection { .into_iter() .map(Into::into) .collect::>(); - log::info!("Converted prompt to message: {} chars", content.len()); + log::debug!("Converted prompt to message: {} chars", content.len()); log::debug!("Message id: {:?}", id); log::debug!("Message content: {:?}", content); diff --git a/crates/agent2/src/native_agent_server.rs b/crates/agent2/src/native_agent_server.rs index 12d3c79d1b..33ee44c9a3 100644 --- a/crates/agent2/src/native_agent_server.rs +++ b/crates/agent2/src/native_agent_server.rs @@ -44,7 +44,7 @@ impl AgentServer for NativeAgentServer { project: &Entity, cx: &mut App, ) -> Task>> { - log::info!( + log::debug!( "NativeAgentServer::connect called for path: {:?}", _root_dir ); @@ -63,7 +63,7 @@ impl AgentServer for NativeAgentServer { // Create the connection wrapper let connection = NativeAgentConnection(agent); - log::info!("NativeAgentServer connection established successfully"); + log::debug!("NativeAgentServer connection established successfully"); Ok(Rc::new(connection) as Rc) }) diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 43f391ca64..4bbbdbdec7 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -1088,7 +1088,7 @@ impl Thread { self.messages.push(Message::Resume); cx.notify(); - log::info!("Total messages in thread: {}", self.messages.len()); + log::debug!("Total messages in thread: {}", self.messages.len()); self.run_turn(cx) } @@ -1106,7 +1106,7 @@ impl Thread { { let model = self.model().context("No language model configured")?; - log::info!("Thread::send called with model: {:?}", model.name()); + log::info!("Thread::send called with model: {}", model.name().0); self.advance_prompt_id(); let content = content.into_iter().map(Into::into).collect::>(); @@ -1116,7 +1116,7 @@ impl Thread { .push(Message::User(UserMessage { id, content })); cx.notify(); - log::info!("Total messages in thread: {}", self.messages.len()); + log::debug!("Total messages in thread: {}", self.messages.len()); self.run_turn(cx) } @@ -1140,7 +1140,7 @@ impl Thread { event_stream: event_stream.clone(), tools: self.enabled_tools(profile, &model, cx), _task: cx.spawn(async move |this, cx| { - log::info!("Starting agent turn execution"); + log::debug!("Starting agent turn execution"); let turn_result: Result<()> = async { let mut intent = CompletionIntent::UserPrompt; @@ -1165,7 +1165,7 @@ impl Thread { log::info!("Tool use limit reached, completing turn"); return Err(language_model::ToolUseLimitReachedError.into()); } else if end_turn { - log::info!("No tool uses found, completing turn"); + log::debug!("No tool uses found, completing turn"); return Ok(()); } else { intent = CompletionIntent::ToolResults; @@ -1177,7 +1177,7 @@ impl Thread { match turn_result { Ok(()) => { - log::info!("Turn execution completed"); + log::debug!("Turn execution completed"); event_stream.send_stop(acp::StopReason::EndTurn); } Err(error) => { @@ -1227,7 +1227,7 @@ impl Thread { attempt ); - log::info!( + log::debug!( "Calling model.stream_completion, attempt {}", attempt.unwrap_or(0) ); @@ -1254,7 +1254,7 @@ impl Thread { } while let Some(tool_result) = tool_results.next().await { - log::info!("Tool finished {:?}", tool_result); + log::debug!("Tool finished {:?}", tool_result); event_stream.update_tool_call_fields( &tool_result.tool_use_id, @@ -1528,7 +1528,7 @@ impl Thread { }); let supports_images = self.model().is_some_and(|model| model.supports_images()); let tool_result = tool.run(tool_use.input, tool_event_stream, cx); - log::info!("Running tool {}", tool_use.name); + log::debug!("Running tool {}", tool_use.name); Some(cx.foreground_executor().spawn(async move { let tool_result = tool_result.await.and_then(|output| { if let LanguageModelToolResultContent::Image(_) = &output.llm_output @@ -1640,7 +1640,7 @@ impl Thread { summary.extend(lines.next()); } - log::info!("Setting summary: {}", summary); + log::debug!("Setting summary: {}", summary); let summary = SharedString::from(summary); this.update(cx, |this, cx| { @@ -1657,7 +1657,7 @@ impl Thread { return; }; - log::info!( + log::debug!( "Generating title with model: {:?}", self.summarization_model.as_ref().map(|model| model.name()) ); @@ -1799,8 +1799,8 @@ impl Thread { log::debug!("Completion mode: {:?}", self.completion_mode); let messages = self.build_request_messages(cx); - log::info!("Request will include {} messages", messages.len()); - log::info!("Request includes {} tools", tools.len()); + log::debug!("Request will include {} messages", messages.len()); + log::debug!("Request includes {} tools", tools.len()); let request = LanguageModelRequest { thread_id: Some(self.id.to_string()), From 7a6f01f37ac43f31ed974e0f0ee34a3396ad535a Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 25 Aug 2025 15:38:19 +0200 Subject: [PATCH 66/89] acp: Simplify control flow for native agent loop (#36868) Release Notes: - N/A Co-authored-by: Bennet Bo Fenner --- crates/agent2/src/thread.rs | 164 ++++++++++++++++-------------------- 1 file changed, 73 insertions(+), 91 deletions(-) diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 4bbbdbdec7..2d1e608297 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -1142,37 +1142,7 @@ impl Thread { _task: cx.spawn(async move |this, cx| { log::debug!("Starting agent turn execution"); - let turn_result: Result<()> = async { - let mut intent = CompletionIntent::UserPrompt; - loop { - Self::stream_completion(&this, &model, intent, &event_stream, cx).await?; - - let mut end_turn = true; - this.update(cx, |this, cx| { - // Generate title if needed. - if this.title.is_none() && this.pending_title_generation.is_none() { - this.generate_title(cx); - } - - // End the turn if the model didn't use tools. - let message = this.pending_message.as_ref(); - end_turn = - message.map_or(true, |message| message.tool_results.is_empty()); - this.flush_pending_message(cx); - })?; - - if this.read_with(cx, |this, _| this.tool_use_limit_reached)? { - log::info!("Tool use limit reached, completing turn"); - return Err(language_model::ToolUseLimitReachedError.into()); - } else if end_turn { - log::debug!("No tool uses found, completing turn"); - return Ok(()); - } else { - intent = CompletionIntent::ToolResults; - } - } - } - .await; + let turn_result = Self::run_turn_internal(&this, model, &event_stream, cx).await; _ = this.update(cx, |this, cx| this.flush_pending_message(cx)); match turn_result { @@ -1203,20 +1173,17 @@ impl Thread { Ok(events_rx) } - async fn stream_completion( + async fn run_turn_internal( this: &WeakEntity, - model: &Arc, - completion_intent: CompletionIntent, + model: Arc, event_stream: &ThreadEventStream, cx: &mut AsyncApp, ) -> Result<()> { - log::debug!("Stream completion started successfully"); - - let mut attempt = None; + let mut attempt = 0; + let mut intent = CompletionIntent::UserPrompt; loop { - let request = this.update(cx, |this, cx| { - this.build_completion_request(completion_intent, cx) - })??; + let request = + this.update(cx, |this, cx| this.build_completion_request(intent, cx))??; telemetry::event!( "Agent Thread Completion", @@ -1227,23 +1194,19 @@ impl Thread { attempt ); - log::debug!( - "Calling model.stream_completion, attempt {}", - attempt.unwrap_or(0) - ); + log::debug!("Calling model.stream_completion, attempt {}", attempt); let mut events = model .stream_completion(request, cx) .await .map_err(|error| anyhow!(error))?; let mut tool_results = FuturesUnordered::new(); let mut error = None; - while let Some(event) = events.next().await { + log::trace!("Received completion event: {:?}", event); match event { Ok(event) => { - log::trace!("Received completion event: {:?}", event); tool_results.extend(this.update(cx, |this, cx| { - this.handle_streamed_completion_event(event, event_stream, cx) + this.handle_completion_event(event, event_stream, cx) })??); } Err(err) => { @@ -1253,6 +1216,7 @@ impl Thread { } } + let end_turn = tool_results.is_empty(); while let Some(tool_result) = tool_results.next().await { log::debug!("Tool finished {:?}", tool_result); @@ -1275,65 +1239,83 @@ impl Thread { })?; } + this.update(cx, |this, cx| { + this.flush_pending_message(cx); + if this.title.is_none() && this.pending_title_generation.is_none() { + this.generate_title(cx); + } + })?; + if let Some(error) = error { - let completion_mode = this.read_with(cx, |thread, _cx| thread.completion_mode())?; - if completion_mode == CompletionMode::Normal { - return Err(anyhow!(error))?; - } - - let Some(strategy) = Self::retry_strategy_for(&error) else { - return Err(anyhow!(error))?; - }; - - let max_attempts = match &strategy { - RetryStrategy::ExponentialBackoff { max_attempts, .. } => *max_attempts, - RetryStrategy::Fixed { max_attempts, .. } => *max_attempts, - }; - - let attempt = attempt.get_or_insert(0u8); - - *attempt += 1; - - let attempt = *attempt; - if attempt > max_attempts { - return Err(anyhow!(error))?; - } - - let delay = match &strategy { - RetryStrategy::ExponentialBackoff { initial_delay, .. } => { - let delay_secs = initial_delay.as_secs() * 2u64.pow((attempt - 1) as u32); - Duration::from_secs(delay_secs) - } - RetryStrategy::Fixed { delay, .. } => *delay, - }; - log::debug!("Retry attempt {attempt} with delay {delay:?}"); - - event_stream.send_retry(acp_thread::RetryStatus { - last_error: error.to_string().into(), - attempt: attempt as usize, - max_attempts: max_attempts as usize, - started_at: Instant::now(), - duration: delay, - }); - cx.background_executor().timer(delay).await; - this.update(cx, |this, cx| { - this.flush_pending_message(cx); + attempt += 1; + let retry = + this.update(cx, |this, _| this.handle_completion_error(error, attempt))??; + let timer = cx.background_executor().timer(retry.duration); + event_stream.send_retry(retry); + timer.await; + this.update(cx, |this, _cx| { if let Some(Message::Agent(message)) = this.messages.last() { if message.tool_results.is_empty() { + intent = CompletionIntent::UserPrompt; this.messages.push(Message::Resume); } } })?; - } else { + } else if this.read_with(cx, |this, _| this.tool_use_limit_reached)? { + return Err(language_model::ToolUseLimitReachedError.into()); + } else if end_turn { return Ok(()); + } else { + intent = CompletionIntent::ToolResults; + attempt = 0; } } } + fn handle_completion_error( + &mut self, + error: LanguageModelCompletionError, + attempt: u8, + ) -> Result { + if self.completion_mode == CompletionMode::Normal { + return Err(anyhow!(error)); + } + + let Some(strategy) = Self::retry_strategy_for(&error) else { + return Err(anyhow!(error)); + }; + + let max_attempts = match &strategy { + RetryStrategy::ExponentialBackoff { max_attempts, .. } => *max_attempts, + RetryStrategy::Fixed { max_attempts, .. } => *max_attempts, + }; + + if attempt > max_attempts { + return Err(anyhow!(error)); + } + + let delay = match &strategy { + RetryStrategy::ExponentialBackoff { initial_delay, .. } => { + let delay_secs = initial_delay.as_secs() * 2u64.pow((attempt - 1) as u32); + Duration::from_secs(delay_secs) + } + RetryStrategy::Fixed { delay, .. } => *delay, + }; + log::debug!("Retry attempt {attempt} with delay {delay:?}"); + + Ok(acp_thread::RetryStatus { + last_error: error.to_string().into(), + attempt: attempt as usize, + max_attempts: max_attempts as usize, + started_at: Instant::now(), + duration: delay, + }) + } + /// A helper method that's called on every streamed completion event. /// Returns an optional tool result task, which the main agentic loop will /// send back to the model when it resolves. - fn handle_streamed_completion_event( + fn handle_completion_event( &mut self, event: LanguageModelCompletionEvent, event_stream: &ThreadEventStream, From 7b17be62ea54b500abc7d986d677288fadb25d40 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 25 Aug 2025 09:34:30 -0600 Subject: [PATCH 67/89] acp: Remember following state (#36793) A beta user reported that following was "lost" when asking for confirmation, I suspect they moved their cursor in the agent file while reviewing the change. Now we will resume following when the agent starts up again. Release Notes: - N/A --- crates/acp_thread/src/acp_thread.rs | 2 +- crates/agent_ui/src/acp/thread_view.rs | 73 ++++++++++++++++++++------ 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index d9a7a2582a..cc33879586 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -774,7 +774,7 @@ pub enum AcpThreadEvent { impl EventEmitter for AcpThread {} -#[derive(PartialEq, Eq)] +#[derive(PartialEq, Eq, Debug)] pub enum ThreadStatus { Idle, WaitingForToolConfirmation, diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 25f2745f75..609777e2d1 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -274,6 +274,7 @@ pub struct AcpThreadView { edits_expanded: bool, plan_expanded: bool, editor_expanded: bool, + should_be_following: bool, editing_message: Option, prompt_capabilities: Rc>, is_loading_contents: bool, @@ -385,6 +386,7 @@ impl AcpThreadView { edits_expanded: false, plan_expanded: false, editor_expanded: false, + should_be_following: false, history_store, hovered_recent_history_item: None, prompt_capabilities, @@ -897,6 +899,13 @@ impl AcpThreadView { let Some(thread) = self.thread().cloned() else { return; }; + if self.should_be_following { + self.workspace + .update(cx, |workspace, cx| { + workspace.follow(CollaboratorId::Agent, window, cx); + }) + .ok(); + } self.is_loading_contents = true; let guard = cx.new(|_| ()); @@ -938,6 +947,16 @@ impl AcpThreadView { this.handle_thread_error(err, cx); }) .ok(); + } else { + this.update(cx, |this, cx| { + this.should_be_following = this + .workspace + .update(cx, |workspace, _| { + workspace.is_being_followed(CollaboratorId::Agent) + }) + .unwrap_or_default(); + }) + .ok(); } }) .detach(); @@ -1254,6 +1273,7 @@ impl AcpThreadView { tool_call_id: acp::ToolCallId, option_id: acp::PermissionOptionId, option_kind: acp::PermissionOptionKind, + window: &mut Window, cx: &mut Context, ) { let Some(thread) = self.thread() else { @@ -1262,6 +1282,13 @@ impl AcpThreadView { thread.update(cx, |thread, cx| { thread.authorize_tool_call(tool_call_id, option_id, option_kind, cx); }); + if self.should_be_following { + self.workspace + .update(cx, |workspace, cx| { + workspace.follow(CollaboratorId::Agent, window, cx); + }) + .ok(); + } cx.notify(); } @@ -2095,11 +2122,12 @@ impl AcpThreadView { let tool_call_id = tool_call_id.clone(); let option_id = option.id.clone(); let option_kind = option.kind; - move |this, _, _, cx| { + move |this, _, window, cx| { this.authorize_tool_call( tool_call_id.clone(), option_id.clone(), option_kind, + window, cx, ); } @@ -3652,13 +3680,34 @@ impl AcpThreadView { } } - fn render_follow_toggle(&self, cx: &mut Context) -> impl IntoElement { - let following = self - .workspace - .read_with(cx, |workspace, _| { - workspace.is_being_followed(CollaboratorId::Agent) + fn is_following(&self, cx: &App) -> bool { + match self.thread().map(|thread| thread.read(cx).status()) { + Some(ThreadStatus::Generating) => self + .workspace + .read_with(cx, |workspace, _| { + workspace.is_being_followed(CollaboratorId::Agent) + }) + .unwrap_or(false), + _ => self.should_be_following, + } + } + + fn toggle_following(&mut self, window: &mut Window, cx: &mut Context) { + let following = self.is_following(cx); + self.should_be_following = !following; + self.workspace + .update(cx, |workspace, cx| { + if following { + workspace.unfollow(CollaboratorId::Agent, window, cx); + } else { + workspace.follow(CollaboratorId::Agent, window, cx); + } }) - .unwrap_or(false); + .ok(); + } + + fn render_follow_toggle(&self, cx: &mut Context) -> impl IntoElement { + let following = self.is_following(cx); IconButton::new("follow-agent", IconName::Crosshair) .icon_size(IconSize::Small) @@ -3679,15 +3728,7 @@ impl AcpThreadView { } }) .on_click(cx.listener(move |this, _, window, cx| { - this.workspace - .update(cx, |workspace, cx| { - if following { - workspace.unfollow(CollaboratorId::Agent, window, cx); - } else { - workspace.follow(CollaboratorId::Agent, window, cx); - } - }) - .ok(); + this.toggle_following(window, cx); })) } From 8fccb89ff0c861c262855f5b86f15ecfe6428798 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 25 Aug 2025 17:46:07 +0200 Subject: [PATCH 68/89] acp: Add Reauthenticate to dropdown (#36878) Release Notes: - N/A Co-authored-by: Conrad Irwin --- crates/agent_ui/src/acp/thread_view.rs | 18 ++++++++++++++++++ crates/agent_ui/src/agent_panel.rs | 13 +++++++++++++ crates/zed_actions/src/lib.rs | 4 +++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 609777e2d1..18a65ec634 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -4746,6 +4746,24 @@ impl AcpThreadView { })) } + pub(crate) fn reauthenticate(&mut self, window: &mut Window, cx: &mut Context) { + let agent = self.agent.clone(); + let ThreadState::Ready { thread, .. } = &self.thread_state else { + return; + }; + + let connection = thread.read(cx).connection().clone(); + let err = AuthRequired { + description: None, + provider_id: None, + }; + self.clear_thread_error(cx); + let this = cx.weak_entity(); + window.defer(cx, |window, cx| { + Self::handle_auth_required(this, err, agent, connection, window, cx); + }) + } + fn upgrade_button(&self, cx: &mut Context) -> impl IntoElement { Button::new("upgrade", "Upgrade") .label_size(LabelSize::Small) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 50f9fc6a45..f1a8a744ee 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -9,6 +9,7 @@ use agent_servers::AgentServerSettings; use agent2::{DbThreadMetadata, HistoryEntry}; use db::kvp::{Dismissable, KEY_VALUE_STORE}; use serde::{Deserialize, Serialize}; +use zed_actions::agent::ReauthenticateAgent; use crate::acp::{AcpThreadHistory, ThreadHistoryEvent}; use crate::agent_diff::AgentDiffThread; @@ -2204,6 +2205,8 @@ impl AgentPanel { "Enable Full Screen" }; + let selected_agent = self.selected_agent.clone(); + PopoverMenu::new("agent-options-menu") .trigger_with_tooltip( IconButton::new("agent-options-menu", IconName::Ellipsis) @@ -2283,6 +2286,11 @@ impl AgentPanel { .action("Settings", Box::new(OpenSettings)) .separator() .action(full_screen_label, Box::new(ToggleZoom)); + + if selected_agent == AgentType::Gemini { + menu = menu.action("Reauthenticate", Box::new(ReauthenticateAgent)) + } + menu })) } @@ -3751,6 +3759,11 @@ impl Render for AgentPanel { } })) .on_action(cx.listener(Self::toggle_burn_mode)) + .on_action(cx.listener(|this, _: &ReauthenticateAgent, window, cx| { + if let Some(thread_view) = this.active_thread_view() { + thread_view.update(cx, |thread_view, cx| thread_view.reauthenticate(window, cx)) + } + })) .child(self.render_toolbar(window, cx)) .children(self.render_onboarding(window, cx)) .map(|parent| match &self.active_view { diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 9455369e9a..2e8b3dd168 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -287,7 +287,9 @@ pub mod agent { Chat, /// Toggles the language model selector dropdown. #[action(deprecated_aliases = ["assistant::ToggleModelSelector", "assistant2::ToggleModelSelector"])] - ToggleModelSelector + ToggleModelSelector, + /// Triggers re-authentication on Gemini + ReauthenticateAgent ] ); } From 66d9fb09ccb7af85f416432cf634be1978b07317 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 25 Aug 2025 10:03:07 -0600 Subject: [PATCH 69/89] Require confirmation for fetch tool (#36881) Using prompt injection, the agent may be tricked into making a fetch request that includes unexpected data from the conversation in the URL. As agent conversations may contain sensitive information (like private code, or potentially even API keys), this seems bad. The easiest way to prevent this is to require the user to look at the URL before the model is allowed to fetch it. Thanks to @ant4g0nist for bringing this to our attention. Release Notes: - agent panel: The fetch tool now requires confirmation. --- crates/agent2/src/tools/fetch_tool.rs | 9 +++++++-- crates/assistant_tools/src/fetch_tool.rs | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/agent2/src/tools/fetch_tool.rs b/crates/agent2/src/tools/fetch_tool.rs index 0313c4e4c2..dd97271a79 100644 --- a/crates/agent2/src/tools/fetch_tool.rs +++ b/crates/agent2/src/tools/fetch_tool.rs @@ -136,12 +136,17 @@ impl AgentTool for FetchTool { fn run( self: Arc, input: Self::Input, - _event_stream: ToolCallEventStream, + event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { + let authorize = event_stream.authorize(input.url.clone(), cx); + let text = cx.background_spawn({ let http_client = self.http_client.clone(); - async move { Self::build_message(http_client, &input.url).await } + async move { + authorize.await?; + Self::build_message(http_client, &input.url).await + } }); cx.foreground_executor().spawn(async move { diff --git a/crates/assistant_tools/src/fetch_tool.rs b/crates/assistant_tools/src/fetch_tool.rs index 79e205f205..cc22c9fc09 100644 --- a/crates/assistant_tools/src/fetch_tool.rs +++ b/crates/assistant_tools/src/fetch_tool.rs @@ -118,7 +118,7 @@ impl Tool for FetchTool { } fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false + true } fn may_perform_edits(&self) -> bool { From 5d8e0f6ad1709293a27d7ce6754ead96fc49eb2d Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Mon, 25 Aug 2025 14:28:11 -0400 Subject: [PATCH 70/89] acp: Model-specific prompt capabilities for 1PA (#36879) Adds support for per-session prompt capabilities and capability changes on the Zed side (ACP itself still only has per-connection static capabilities for now), and uses it to reflect image support accurately in 1PA threads based on the currently-selected model. Release Notes: - N/A --- crates/acp_thread/src/acp_thread.rs | 38 +++++++++++++++++------ crates/acp_thread/src/connection.rs | 18 +++++------ crates/agent2/src/agent.rs | 13 +++----- crates/agent2/src/thread.rs | 21 +++++++++++++ crates/agent_servers/src/acp.rs | 9 +++--- crates/agent_servers/src/claude.rs | 16 +++++----- crates/agent_ui/src/acp/message_editor.rs | 2 +- crates/agent_ui/src/acp/thread_view.rs | 20 ++++++------ crates/agent_ui/src/agent_diff.rs | 1 + crates/watch/src/watch.rs | 13 ++++++++ 10 files changed, 98 insertions(+), 53 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index cc33879586..779f9964da 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -756,6 +756,8 @@ pub struct AcpThread { connection: Rc, session_id: acp::SessionId, token_usage: Option, + prompt_capabilities: acp::PromptCapabilities, + _observe_prompt_capabilities: Task>, } #[derive(Debug)] @@ -770,6 +772,7 @@ pub enum AcpThreadEvent { Stopped, Error, LoadError(LoadError), + PromptCapabilitiesUpdated, } impl EventEmitter for AcpThread {} @@ -821,7 +824,20 @@ impl AcpThread { project: Entity, action_log: Entity, session_id: acp::SessionId, + mut prompt_capabilities_rx: watch::Receiver, + cx: &mut Context, ) -> Self { + let prompt_capabilities = *prompt_capabilities_rx.borrow(); + let task = cx.spawn::<_, anyhow::Result<()>>(async move |this, cx| { + loop { + let caps = prompt_capabilities_rx.recv().await?; + this.update(cx, |this, cx| { + this.prompt_capabilities = caps; + cx.emit(AcpThreadEvent::PromptCapabilitiesUpdated); + })?; + } + }); + Self { action_log, shared_buffers: Default::default(), @@ -833,9 +849,15 @@ impl AcpThread { connection, session_id, token_usage: None, + prompt_capabilities, + _observe_prompt_capabilities: task, } } + pub fn prompt_capabilities(&self) -> acp::PromptCapabilities { + self.prompt_capabilities + } + pub fn connection(&self) -> &Rc { &self.connection } @@ -2599,13 +2621,19 @@ mod tests { .into(), ); let action_log = cx.new(|_| ActionLog::new(project.clone())); - let thread = cx.new(|_cx| { + let thread = cx.new(|cx| { AcpThread::new( "Test", self.clone(), project, action_log, session_id.clone(), + watch::Receiver::constant(acp::PromptCapabilities { + image: true, + audio: true, + embedded_context: true, + }), + cx, ) }); self.sessions.lock().insert(session_id, thread.downgrade()); @@ -2639,14 +2667,6 @@ mod tests { } } - fn prompt_capabilities(&self) -> acp::PromptCapabilities { - acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - } - } - fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) { let sessions = self.sessions.lock(); let thread = sessions.get(session_id).unwrap().clone(); diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 5f5032e588..af229b7545 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -38,8 +38,6 @@ pub trait AgentConnection { cx: &mut App, ) -> Task>; - fn prompt_capabilities(&self) -> acp::PromptCapabilities; - fn resume( &self, _session_id: &acp::SessionId, @@ -329,13 +327,19 @@ mod test_support { ) -> Task>> { let session_id = acp::SessionId(self.sessions.lock().len().to_string().into()); let action_log = cx.new(|_| ActionLog::new(project.clone())); - let thread = cx.new(|_cx| { + let thread = cx.new(|cx| { AcpThread::new( "Test", self.clone(), project, action_log, session_id.clone(), + watch::Receiver::constant(acp::PromptCapabilities { + image: true, + audio: true, + embedded_context: true, + }), + cx, ) }); self.sessions.lock().insert( @@ -348,14 +352,6 @@ mod test_support { Task::ready(Ok(thread)) } - fn prompt_capabilities(&self) -> acp::PromptCapabilities { - acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - } - } - fn authenticate( &self, _method_id: acp::AuthMethodId, diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index 1576c3cf96..ecfaea4b49 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -240,13 +240,16 @@ impl NativeAgent { let title = thread.title(); let project = thread.project.clone(); let action_log = thread.action_log.clone(); - let acp_thread = cx.new(|_cx| { + let prompt_capabilities_rx = thread.prompt_capabilities_rx.clone(); + let acp_thread = cx.new(|cx| { acp_thread::AcpThread::new( title, connection, project.clone(), action_log.clone(), session_id.clone(), + prompt_capabilities_rx, + cx, ) }); let subscriptions = vec![ @@ -925,14 +928,6 @@ impl acp_thread::AgentConnection for NativeAgentConnection { }) } - fn prompt_capabilities(&self) -> acp::PromptCapabilities { - acp::PromptCapabilities { - image: true, - audio: false, - embedded_context: true, - } - } - fn resume( &self, session_id: &acp::SessionId, diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 2d1e608297..1b1c014b79 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -575,11 +575,22 @@ pub struct Thread { templates: Arc, model: Option>, summarization_model: Option>, + prompt_capabilities_tx: watch::Sender, + pub(crate) prompt_capabilities_rx: watch::Receiver, pub(crate) project: Entity, pub(crate) action_log: Entity, } impl Thread { + fn prompt_capabilities(model: Option<&dyn LanguageModel>) -> acp::PromptCapabilities { + let image = model.map_or(true, |model| model.supports_images()); + acp::PromptCapabilities { + image, + audio: false, + embedded_context: true, + } + } + pub fn new( project: Entity, project_context: Entity, @@ -590,6 +601,8 @@ impl Thread { ) -> Self { let profile_id = AgentSettings::get_global(cx).default_profile.clone(); let action_log = cx.new(|_cx| ActionLog::new(project.clone())); + let (prompt_capabilities_tx, prompt_capabilities_rx) = + watch::channel(Self::prompt_capabilities(model.as_deref())); Self { id: acp::SessionId(uuid::Uuid::new_v4().to_string().into()), prompt_id: PromptId::new(), @@ -617,6 +630,8 @@ impl Thread { templates, model, summarization_model: None, + prompt_capabilities_tx, + prompt_capabilities_rx, project, action_log, } @@ -750,6 +765,8 @@ impl Thread { .or_else(|| registry.default_model()) .map(|model| model.model) }); + let (prompt_capabilities_tx, prompt_capabilities_rx) = + watch::channel(Self::prompt_capabilities(model.as_deref())); Self { id, @@ -779,6 +796,8 @@ impl Thread { project, action_log, updated_at: db_thread.updated_at, + prompt_capabilities_tx, + prompt_capabilities_rx, } } @@ -946,10 +965,12 @@ impl Thread { pub fn set_model(&mut self, model: Arc, cx: &mut Context) { let old_usage = self.latest_token_usage(); self.model = Some(model); + let new_caps = Self::prompt_capabilities(self.model.as_deref()); let new_usage = self.latest_token_usage(); if old_usage != new_usage { cx.emit(TokenUsageUpdated(new_usage)); } + self.prompt_capabilities_tx.send(new_caps).log_err(); cx.notify() } diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index c9c938c6c0..5a4efe12e5 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -185,13 +185,16 @@ impl AgentConnection for AcpConnection { let session_id = response.session_id; let action_log = cx.new(|_| ActionLog::new(project.clone()))?; - let thread = cx.new(|_cx| { + let thread = cx.new(|cx| { AcpThread::new( self.server_name.clone(), self.clone(), project, action_log, session_id.clone(), + // ACP doesn't currently support per-session prompt capabilities or changing capabilities dynamically. + watch::Receiver::constant(self.prompt_capabilities), + cx, ) })?; @@ -279,10 +282,6 @@ impl AgentConnection for AcpConnection { }) } - fn prompt_capabilities(&self) -> acp::PromptCapabilities { - self.prompt_capabilities - } - fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) { if let Some(session) = self.sessions.borrow_mut().get_mut(session_id) { session.suppress_abort_err = true; diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index 048563103f..6006bf3edb 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -249,13 +249,19 @@ impl AgentConnection for ClaudeAgentConnection { }); let action_log = cx.new(|_| ActionLog::new(project.clone()))?; - let thread = cx.new(|_cx| { + let thread = cx.new(|cx| { AcpThread::new( "Claude Code", self.clone(), project, action_log, session_id.clone(), + watch::Receiver::constant(acp::PromptCapabilities { + image: true, + audio: false, + embedded_context: true, + }), + cx, ) })?; @@ -319,14 +325,6 @@ impl AgentConnection for ClaudeAgentConnection { cx.foreground_executor().spawn(async move { end_rx.await? }) } - fn prompt_capabilities(&self) -> acp::PromptCapabilities { - acp::PromptCapabilities { - image: true, - audio: false, - embedded_context: true, - } - } - fn cancel(&self, session_id: &acp::SessionId, _cx: &mut App) { let sessions = self.sessions.borrow(); let Some(session) = sessions.get(session_id) else { diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index e1ae4a0938..ef0ac36f6e 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -373,7 +373,7 @@ impl MessageEditor { if Img::extensions().contains(&extension) && !extension.contains("svg") { if !self.prompt_capabilities.get().image { - return Task::ready(Err(anyhow!("This agent does not support images yet"))); + return Task::ready(Err(anyhow!("This model does not support images yet"))); } let task = self .project diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 18a65ec634..faba18acb1 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -474,7 +474,7 @@ impl AcpThreadView { let action_log = thread.read(cx).action_log().clone(); this.prompt_capabilities - .set(connection.prompt_capabilities()); + .set(thread.read(cx).prompt_capabilities()); let count = thread.read(cx).entries().len(); this.list_state.splice(0..0, count); @@ -1163,6 +1163,10 @@ impl AcpThreadView { }); } } + AcpThreadEvent::PromptCapabilitiesUpdated => { + self.prompt_capabilities + .set(thread.read(cx).prompt_capabilities()); + } AcpThreadEvent::TokenUsageUpdated => {} } cx.notify(); @@ -5367,6 +5371,12 @@ pub(crate) mod tests { project, action_log, SessionId("test".into()), + watch::Receiver::constant(acp::PromptCapabilities { + image: true, + audio: true, + embedded_context: true, + }), + cx, ) }))) } @@ -5375,14 +5385,6 @@ pub(crate) mod tests { &[] } - fn prompt_capabilities(&self) -> acp::PromptCapabilities { - acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - } - } - fn authenticate( &self, _method_id: acp::AuthMethodId, diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index e07424987c..1e1ff95178 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -1529,6 +1529,7 @@ impl AgentDiff { | AcpThreadEvent::TokenUsageUpdated | AcpThreadEvent::EntriesRemoved(_) | AcpThreadEvent::ToolAuthorizationRequired + | AcpThreadEvent::PromptCapabilitiesUpdated | AcpThreadEvent::Retry(_) => {} } } diff --git a/crates/watch/src/watch.rs b/crates/watch/src/watch.rs index f0ed5b4a18..71dab74820 100644 --- a/crates/watch/src/watch.rs +++ b/crates/watch/src/watch.rs @@ -162,6 +162,19 @@ impl Receiver { pending_waker_id: None, } } + + /// Creates a new [`Receiver`] holding an initial value that will never change. + pub fn constant(value: T) -> Self { + let state = Arc::new(RwLock::new(State { + value, + wakers: BTreeMap::new(), + next_waker_id: WakerId::default(), + version: 0, + closed: false, + })); + + Self { state, version: 0 } + } } impl Receiver { From 92a6ae1559724c55cb0660be2ae655e41b76d67d Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 25 Aug 2025 15:45:24 -0300 Subject: [PATCH 71/89] agent: Add section for agent servers in settings view (#35206) Release Notes: - N/A --------- Co-authored-by: Cole Miller --- crates/agent_servers/src/agent_servers.rs | 2 +- crates/agent_servers/src/gemini.rs | 18 +- crates/agent_ui/src/acp/thread_view.rs | 4 +- crates/agent_ui/src/agent_configuration.rs | 249 +++++++++++++++++++-- crates/agent_ui/src/agent_panel.rs | 2 + crates/agent_ui/src/agent_ui.rs | 1 + 6 files changed, 254 insertions(+), 22 deletions(-) diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index fa59201338..0439934094 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -97,7 +97,7 @@ pub struct AgentServerCommand { } impl AgentServerCommand { - pub(crate) async fn resolve( + pub async fn resolve( path_bin_name: &'static str, extra_args: &[&'static str], fallback_path: Option<&Path>, diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index 9ebcee745c..d09829fe65 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -53,7 +53,7 @@ impl AgentServer for Gemini { return Err(LoadError::NotInstalled { error_message: "Failed to find Gemini CLI binary".into(), install_message: "Install Gemini CLI".into(), - install_command: "npm install -g @google/gemini-cli@preview".into() + install_command: Self::install_command().into(), }.into()); }; @@ -88,7 +88,7 @@ impl AgentServer for Gemini { current_version ).into(), upgrade_message: "Upgrade Gemini CLI to latest".into(), - upgrade_command: "npm install -g @google/gemini-cli@preview".into(), + upgrade_command: Self::upgrade_command().into(), }.into()) } } @@ -101,6 +101,20 @@ impl AgentServer for Gemini { } } +impl Gemini { + pub fn binary_name() -> &'static str { + "gemini" + } + + pub fn install_command() -> &'static str { + "npm install -g @google/gemini-cli@preview" + } + + pub fn upgrade_command() -> &'static str { + "npm install -g @google/gemini-cli@preview" + } +} + #[cfg(test)] pub(crate) mod tests { use super::*; diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index faba18acb1..97af249ae5 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -2811,7 +2811,7 @@ impl AcpThreadView { let cwd = project.first_project_directory(cx); let shell = project.terminal_settings(&cwd, cx).shell.clone(); let spawn_in_terminal = task::SpawnInTerminal { - id: task::TaskId("install".to_string()), + id: task::TaskId(install_command.clone()), full_label: install_command.clone(), label: install_command.clone(), command: Some(install_command.clone()), @@ -2868,7 +2868,7 @@ impl AcpThreadView { let cwd = project.first_project_directory(cx); let shell = project.terminal_settings(&cwd, cx).shell.clone(); let spawn_in_terminal = task::SpawnInTerminal { - id: task::TaskId("upgrade".to_string()), + id: task::TaskId(upgrade_command.to_string()), full_label: upgrade_command.clone(), label: upgrade_command.clone(), command: Some(upgrade_command.clone()), diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 6ed353b31a..3fa618dad8 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -5,6 +5,7 @@ mod tool_picker; use std::{sync::Arc, time::Duration}; +use agent_servers::{AgentServerCommand, AllAgentServersSettings, Gemini}; use agent_settings::AgentSettings; use assistant_tool::{ToolSource, ToolWorkingSet}; use cloud_llm_client::Plan; @@ -15,7 +16,7 @@ use extension_host::ExtensionStore; use fs::Fs; use gpui::{ Action, Animation, AnimationExt as _, AnyView, App, Corner, Entity, EventEmitter, FocusHandle, - Focusable, ScrollHandle, Subscription, Task, Transformation, WeakEntity, percentage, + Focusable, Hsla, ScrollHandle, Subscription, Task, Transformation, WeakEntity, percentage, }; use language::LanguageRegistry; use language_model::{ @@ -23,10 +24,11 @@ use language_model::{ }; use notifications::status_toast::{StatusToast, ToastIcon}; use project::{ + Project, context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore}, project_settings::{ContextServerSettings, ProjectSettings}, }; -use settings::{Settings, update_settings_file}; +use settings::{Settings, SettingsStore, update_settings_file}; use ui::{ Chip, ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex, Indicator, PopoverMenu, Scrollbar, ScrollbarState, Switch, SwitchColor, SwitchField, Tooltip, prelude::*, @@ -39,7 +41,7 @@ pub(crate) use configure_context_server_modal::ConfigureContextServerModal; pub(crate) use manage_profiles_modal::ManageProfilesModal; use crate::{ - AddContextServer, + AddContextServer, ExternalAgent, NewExternalAgentThread, agent_configuration::add_llm_provider_modal::{AddLlmProviderModal, LlmCompatibleProvider}, }; @@ -47,6 +49,7 @@ pub struct AgentConfiguration { fs: Arc, language_registry: Arc, workspace: WeakEntity, + project: WeakEntity, focus_handle: FocusHandle, configuration_views_by_provider: HashMap, context_server_store: Entity, @@ -56,6 +59,8 @@ pub struct AgentConfiguration { _registry_subscription: Subscription, scroll_handle: ScrollHandle, scrollbar_state: ScrollbarState, + gemini_is_installed: bool, + _check_for_gemini: Task<()>, } impl AgentConfiguration { @@ -65,6 +70,7 @@ impl AgentConfiguration { tools: Entity, language_registry: Arc, workspace: WeakEntity, + project: WeakEntity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -89,6 +95,11 @@ impl AgentConfiguration { cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify()) .detach(); + cx.observe_global_in::(window, |this, _, cx| { + this.check_for_gemini(cx); + cx.notify(); + }) + .detach(); let scroll_handle = ScrollHandle::new(); let scrollbar_state = ScrollbarState::new(scroll_handle.clone()); @@ -97,6 +108,7 @@ impl AgentConfiguration { fs, language_registry, workspace, + project, focus_handle, configuration_views_by_provider: HashMap::default(), context_server_store, @@ -106,8 +118,11 @@ impl AgentConfiguration { _registry_subscription: registry_subscription, scroll_handle, scrollbar_state, + gemini_is_installed: false, + _check_for_gemini: Task::ready(()), }; this.build_provider_configuration_views(window, cx); + this.check_for_gemini(cx); this } @@ -137,6 +152,34 @@ impl AgentConfiguration { self.configuration_views_by_provider .insert(provider.id(), configuration_view); } + + fn check_for_gemini(&mut self, cx: &mut Context) { + let project = self.project.clone(); + let settings = AllAgentServersSettings::get_global(cx).clone(); + self._check_for_gemini = cx.spawn({ + async move |this, cx| { + let Some(project) = project.upgrade() else { + return; + }; + let gemini_is_installed = AgentServerCommand::resolve( + Gemini::binary_name(), + &[], + // TODO expose fallback path from the Gemini/CC types so we don't have to hardcode it again here + None, + settings.gemini, + &project, + cx, + ) + .await + .is_some(); + this.update(cx, |this, cx| { + this.gemini_is_installed = gemini_is_installed; + cx.notify(); + }) + .ok(); + } + }); + } } impl Focusable for AgentConfiguration { @@ -211,7 +254,6 @@ impl AgentConfiguration { .child( h_flex() .id(provider_id_string.clone()) - .cursor_pointer() .px_2() .py_0p5() .w_full() @@ -231,10 +273,7 @@ impl AgentConfiguration { h_flex() .w_full() .gap_1() - .child( - Label::new(provider_name.clone()) - .size(LabelSize::Large), - ) + .child(Label::new(provider_name.clone())) .map(|this| { if is_zed_provider && is_signed_in { this.child( @@ -279,7 +318,7 @@ impl AgentConfiguration { "Start New Thread", ) .icon_position(IconPosition::Start) - .icon(IconName::Plus) + .icon(IconName::Thread) .icon_size(IconSize::Small) .icon_color(Color::Muted) .label_size(LabelSize::Small) @@ -378,7 +417,7 @@ impl AgentConfiguration { ), ) .child( - Label::new("Add at least one provider to use AI-powered features.") + Label::new("Add at least one provider to use AI-powered features with Zed's native agent.") .color(Color::Muted), ), ), @@ -519,6 +558,14 @@ impl AgentConfiguration { } } + fn card_item_bg_color(&self, cx: &mut Context) -> Hsla { + cx.theme().colors().background.opacity(0.25) + } + + fn card_item_border_color(&self, cx: &mut Context) -> Hsla { + cx.theme().colors().border.opacity(0.6) + } + fn render_context_servers_section( &mut self, window: &mut Window, @@ -536,7 +583,12 @@ impl AgentConfiguration { v_flex() .gap_0p5() .child(Headline::new("Model Context Protocol (MCP) Servers")) - .child(Label::new("Connect to context servers through the Model Context Protocol, either using Zed extensions or directly.").color(Color::Muted)), + .child( + Label::new( + "All context servers connected through the Model Context Protocol.", + ) + .color(Color::Muted), + ), ) .children( context_server_ids.into_iter().map(|context_server_id| { @@ -546,7 +598,7 @@ impl AgentConfiguration { .child( h_flex() .justify_between() - .gap_2() + .gap_1p5() .child( h_flex().w_full().child( Button::new("add-context-server", "Add Custom Server") @@ -637,8 +689,6 @@ impl AgentConfiguration { .map_or([].as_slice(), |tools| tools.as_slice()); let tool_count = tools.len(); - let border_color = cx.theme().colors().border.opacity(0.6); - let (source_icon, source_tooltip) = if is_from_extension { ( IconName::ZedMcpExtension, @@ -781,8 +831,8 @@ impl AgentConfiguration { .id(item_id.clone()) .border_1() .rounded_md() - .border_color(border_color) - .bg(cx.theme().colors().background.opacity(0.2)) + .border_color(self.card_item_border_color(cx)) + .bg(self.card_item_bg_color(cx)) .overflow_hidden() .child( h_flex() @@ -790,7 +840,11 @@ impl AgentConfiguration { .justify_between() .when( error.is_some() || are_tools_expanded && tool_count >= 1, - |element| element.border_b_1().border_color(border_color), + |element| { + element + .border_b_1() + .border_color(self.card_item_border_color(cx)) + }, ) .child( h_flex() @@ -972,6 +1026,166 @@ impl AgentConfiguration { )) }) } + + fn render_agent_servers_section(&mut self, cx: &mut Context) -> impl IntoElement { + let settings = AllAgentServersSettings::get_global(cx).clone(); + let user_defined_agents = settings + .custom + .iter() + .map(|(name, settings)| { + self.render_agent_server( + IconName::Ai, + name.clone(), + ExternalAgent::Custom { + name: name.clone(), + settings: settings.clone(), + }, + None, + cx, + ) + .into_any_element() + }) + .collect::>(); + + v_flex() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + v_flex() + .p(DynamicSpacing::Base16.rems(cx)) + .pr(DynamicSpacing::Base20.rems(cx)) + .gap_2() + .child( + v_flex() + .gap_0p5() + .child(Headline::new("External Agents")) + .child( + Label::new( + "Use the full power of Zed's UI with your favorite agent, connected via the Agent Client Protocol.", + ) + .color(Color::Muted), + ), + ) + .child(self.render_agent_server( + IconName::AiGemini, + "Gemini CLI", + ExternalAgent::Gemini, + (!self.gemini_is_installed).then_some(Gemini::install_command().into()), + cx, + )) + // TODO add CC + .children(user_defined_agents), + ) + } + + fn render_agent_server( + &self, + icon: IconName, + name: impl Into, + agent: ExternalAgent, + install_command: Option, + cx: &mut Context, + ) -> impl IntoElement { + let name = name.into(); + h_flex() + .p_1() + .pl_2() + .gap_1p5() + .justify_between() + .border_1() + .rounded_md() + .border_color(self.card_item_border_color(cx)) + .bg(self.card_item_bg_color(cx)) + .overflow_hidden() + .child( + h_flex() + .gap_1p5() + .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)) + .child(Label::new(name.clone())), + ) + .map(|this| { + if let Some(install_command) = install_command { + this.child( + Button::new( + SharedString::from(format!("install_external_agent-{name}")), + "Install Agent", + ) + .label_size(LabelSize::Small) + .icon(IconName::Plus) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .tooltip(Tooltip::text(install_command.clone())) + .on_click(cx.listener( + move |this, _, window, cx| { + let Some(project) = this.project.upgrade() else { + return; + }; + let Some(workspace) = this.workspace.upgrade() else { + return; + }; + let cwd = project.read(cx).first_project_directory(cx); + let shell = + project.read(cx).terminal_settings(&cwd, cx).shell.clone(); + let spawn_in_terminal = task::SpawnInTerminal { + id: task::TaskId(install_command.to_string()), + full_label: install_command.to_string(), + label: install_command.to_string(), + command: Some(install_command.to_string()), + args: Vec::new(), + command_label: install_command.to_string(), + cwd, + env: Default::default(), + use_new_terminal: true, + allow_concurrent_runs: true, + reveal: Default::default(), + reveal_target: Default::default(), + hide: Default::default(), + shell, + show_summary: true, + show_command: true, + show_rerun: false, + }; + let task = workspace.update(cx, |workspace, cx| { + workspace.spawn_in_terminal(spawn_in_terminal, window, cx) + }); + cx.spawn(async move |this, cx| { + task.await; + this.update(cx, |this, cx| { + this.check_for_gemini(cx); + }) + .ok(); + }) + .detach(); + }, + )), + ) + } else { + this.child( + h_flex().gap_1().child( + Button::new( + SharedString::from(format!("start_acp_thread-{name}")), + "Start New Thread", + ) + .label_size(LabelSize::Small) + .icon(IconName::Thread) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .on_click(move |_, window, cx| { + window.dispatch_action( + NewExternalAgentThread { + agent: Some(agent.clone()), + } + .boxed_clone(), + cx, + ); + }), + ), + ) + } + }) + } } impl Render for AgentConfiguration { @@ -991,6 +1205,7 @@ impl Render for AgentConfiguration { .size_full() .overflow_y_scroll() .child(self.render_general_settings_section(cx)) + .child(self.render_agent_servers_section(cx)) .child(self.render_context_servers_section(window, cx)) .child(self.render_provider_configuration_section(cx)), ) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index f1a8a744ee..c825785755 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -241,6 +241,7 @@ enum WhichFontSize { None, } +// TODO unify this with ExternalAgent #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] pub enum AgentType { #[default] @@ -1474,6 +1475,7 @@ impl AgentPanel { tools, self.language_registry.clone(), self.workspace.clone(), + self.project.downgrade(), window, cx, ) diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 40f6c6a2bb..d159f375b5 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -160,6 +160,7 @@ pub struct NewNativeAgentThreadFromSummary { from_session_id: agent_client_protocol::SessionId, } +// TODO unify this with AgentType #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] enum ExternalAgent { From 40ceeea91e5c6cf1bd65023d8c006abfab11be9a Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 25 Aug 2025 20:51:23 +0200 Subject: [PATCH 72/89] acp: Add telemetry (#36894) Release Notes: - N/A --------- Co-authored-by: Conrad Irwin --- crates/agent2/src/native_agent_server.rs | 4 ++ crates/agent2/src/tests/mod.rs | 1 + crates/agent_servers/src/agent_servers.rs | 1 + crates/agent_servers/src/claude.rs | 4 ++ crates/agent_servers/src/custom.rs | 4 ++ crates/agent_servers/src/gemini.rs | 4 ++ crates/agent_ui/src/acp/thread_view.rs | 79 ++++++++++++++++------- crates/agent_ui/src/agent_panel.rs | 8 +++ crates/agent_ui/src/agent_ui.rs | 9 +++ crates/agent_ui/src/text_thread_editor.rs | 1 + 10 files changed, 93 insertions(+), 22 deletions(-) diff --git a/crates/agent2/src/native_agent_server.rs b/crates/agent2/src/native_agent_server.rs index 33ee44c9a3..9ff98ccd18 100644 --- a/crates/agent2/src/native_agent_server.rs +++ b/crates/agent2/src/native_agent_server.rs @@ -22,6 +22,10 @@ impl NativeAgentServer { } impl AgentServer for NativeAgentServer { + fn telemetry_id(&self) -> &'static str { + "zed" + } + fn name(&self) -> SharedString { "Zed Agent".into() } diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index 87ecc1037c..864fbf8b10 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -1685,6 +1685,7 @@ async fn test_truncate_second_message(cx: &mut TestAppContext) { } #[gpui::test] +#[cfg_attr(target_os = "windows", ignore)] // TODO: Fix this test on Windows async fn test_title_generation(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; let fake_model = model.as_fake(); diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index 0439934094..7c7e124ca7 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -36,6 +36,7 @@ pub trait AgentServer: Send { fn name(&self) -> SharedString; fn empty_state_headline(&self) -> SharedString; fn empty_state_message(&self) -> SharedString; + fn telemetry_id(&self) -> &'static str; fn connect( &self, diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index 6006bf3edb..250e564526 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -43,6 +43,10 @@ use acp_thread::{AcpThread, AgentConnection, AuthRequired, LoadError, MentionUri pub struct ClaudeCode; impl AgentServer for ClaudeCode { + fn telemetry_id(&self) -> &'static str { + "claude-code" + } + fn name(&self) -> SharedString { "Claude Code".into() } diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index e544c4f21f..72823026d7 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -22,6 +22,10 @@ impl CustomAgentServer { } impl crate::AgentServer for CustomAgentServer { + fn telemetry_id(&self) -> &'static str { + "custom" + } + fn name(&self) -> SharedString { self.name.clone() } diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index d09829fe65..5d6a70fa64 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -17,6 +17,10 @@ pub struct Gemini; const ACP_ARG: &str = "--experimental-acp"; impl AgentServer for Gemini { + fn telemetry_id(&self) -> &'static str { + "gemini-cli" + } + fn name(&self) -> SharedString { "Gemini CLI".into() } diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 97af249ae5..d80f4eabce 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -892,6 +892,8 @@ impl AcpThreadView { window: &mut Window, cx: &mut Context, ) { + let agent_telemetry_id = self.agent.telemetry_id(); + self.thread_error.take(); self.editing_message.take(); self.thread_feedback.clear(); @@ -936,6 +938,9 @@ impl AcpThreadView { } }); drop(guard); + + telemetry::event!("Agent Message Sent", agent = agent_telemetry_id); + thread.send(contents, cx) })?; send.await @@ -1246,30 +1251,44 @@ impl AcpThreadView { pending_auth_method.replace(method.clone()); let authenticate = connection.authenticate(method, cx); cx.notify(); - self.auth_task = Some(cx.spawn_in(window, { - let project = self.project.clone(); - let agent = self.agent.clone(); - async move |this, cx| { - let result = authenticate.await; + self.auth_task = + Some(cx.spawn_in(window, { + let project = self.project.clone(); + let agent = self.agent.clone(); + async move |this, cx| { + let result = authenticate.await; - this.update_in(cx, |this, window, cx| { - if let Err(err) = result { - this.handle_thread_error(err, cx); - } else { - this.thread_state = Self::initial_state( - agent, - None, - this.workspace.clone(), - project.clone(), - window, - cx, - ) + match &result { + Ok(_) => telemetry::event!( + "Authenticate Agent Succeeded", + agent = agent.telemetry_id() + ), + Err(_) => { + telemetry::event!( + "Authenticate Agent Failed", + agent = agent.telemetry_id(), + ) + } } - this.auth_task.take() - }) - .ok(); - } - })); + + this.update_in(cx, |this, window, cx| { + if let Err(err) = result { + this.handle_thread_error(err, cx); + } else { + this.thread_state = Self::initial_state( + agent, + None, + this.workspace.clone(), + project.clone(), + window, + cx, + ) + } + this.auth_task.take() + }) + .ok(); + } + })); } fn authorize_tool_call( @@ -2776,6 +2795,12 @@ impl AcpThreadView { .on_click({ let method_id = method.id.clone(); cx.listener(move |this, _, window, cx| { + telemetry::event!( + "Authenticate Agent Started", + agent = this.agent.telemetry_id(), + method = method_id + ); + this.authenticate(method_id.clone(), window, cx) }) }) @@ -2804,6 +2829,8 @@ impl AcpThreadView { .icon_color(Color::Muted) .icon_position(IconPosition::Start) .on_click(cx.listener(move |this, _, window, cx| { + telemetry::event!("Agent Install CLI", agent = this.agent.telemetry_id()); + let task = this .workspace .update(cx, |workspace, cx| { @@ -2861,6 +2888,8 @@ impl AcpThreadView { .icon_color(Color::Muted) .icon_position(IconPosition::Start) .on_click(cx.listener(move |this, _, window, cx| { + telemetry::event!("Agent Upgrade CLI", agent = this.agent.telemetry_id()); + let task = this .workspace .update(cx, |workspace, cx| { @@ -3708,6 +3737,8 @@ impl AcpThreadView { } }) .ok(); + + telemetry::event!("Follow Agent Selected", following = !following); } fn render_follow_toggle(&self, cx: &mut Context) -> impl IntoElement { @@ -5323,6 +5354,10 @@ pub(crate) mod tests { where C: 'static + AgentConnection + Send + Clone, { + fn telemetry_id(&self) -> &'static str { + "test" + } + fn logo(&self) -> ui::IconName { ui::IconName::Ai } diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index c825785755..1eafb8dd4d 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1026,6 +1026,8 @@ impl AgentPanel { } fn new_prompt_editor(&mut self, window: &mut Window, cx: &mut Context) { + telemetry::event!("Agent Thread Started", agent = "zed-text"); + let context = self .context_store .update(cx, |context_store, cx| context_store.create(cx)); @@ -1118,6 +1120,8 @@ impl AgentPanel { } }; + telemetry::event!("Agent Thread Started", agent = ext_agent.name()); + let server = ext_agent.server(fs, history); this.update_in(cx, |this, window, cx| { @@ -2327,6 +2331,8 @@ impl AgentPanel { .menu({ let menu = self.assistant_navigation_menu.clone(); move |window, cx| { + telemetry::event!("View Thread History Clicked"); + if let Some(menu) = menu.as_ref() { menu.update(cx, |_, cx| { cx.defer_in(window, |menu, window, cx| { @@ -2505,6 +2511,8 @@ impl AgentPanel { let workspace = self.workspace.clone(); move |window, cx| { + telemetry::event!("New Thread Clicked"); + let active_thread = active_thread.clone(); Some(ContextMenu::build(window, cx, |mut menu, _window, cx| { menu = menu diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index d159f375b5..110c432df3 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -175,6 +175,15 @@ enum ExternalAgent { } impl ExternalAgent { + fn name(&self) -> &'static str { + match self { + Self::NativeAgent => "zed", + Self::Gemini => "gemini-cli", + Self::ClaudeCode => "claude-code", + Self::Custom { .. } => "custom", + } + } + pub fn server( &self, fs: Arc, diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs index edb672a872..e9e7eba4b6 100644 --- a/crates/agent_ui/src/text_thread_editor.rs +++ b/crates/agent_ui/src/text_thread_editor.rs @@ -361,6 +361,7 @@ impl TextThreadEditor { if self.sending_disabled(cx) { return; } + telemetry::event!("Agent Message Sent", agent = "zed-text"); self.send_to_model(window, cx); } From fb766a58934172c36271fae981eb0b0f9192b126 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 25 Aug 2025 16:07:30 -0300 Subject: [PATCH 73/89] thread view: Fix some design papercuts (#36893) Release Notes: - N/A --------- Co-authored-by: Conrad Irwin Co-authored-by: Ben Brandt Co-authored-by: Matt Miller --- crates/agent2/src/tools/find_path_tool.rs | 27 +- crates/agent_ui/src/acp/thread_history.rs | 2 +- crates/agent_ui/src/acp/thread_view.rs | 296 +++++++++---------- crates/assistant_tools/src/find_path_tool.rs | 8 +- crates/assistant_tools/src/read_file_tool.rs | 2 +- 5 files changed, 152 insertions(+), 183 deletions(-) diff --git a/crates/agent2/src/tools/find_path_tool.rs b/crates/agent2/src/tools/find_path_tool.rs index 5b35c40f85..384bd56e77 100644 --- a/crates/agent2/src/tools/find_path_tool.rs +++ b/crates/agent2/src/tools/find_path_tool.rs @@ -165,16 +165,17 @@ fn search_paths(glob: &str, project: Entity, cx: &mut App) -> Task(if hovered || selected { + .end_slot::(if hovered { Some( IconButton::new("delete", IconName::Trash) .shape(IconButtonShape::Square) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index d80f4eabce..837ce6f90a 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -35,6 +35,7 @@ use prompt_store::{PromptId, PromptStore}; use rope::Point; use settings::{Settings as _, SettingsStore}; use std::cell::Cell; +use std::path::Path; use std::sync::Arc; use std::time::Instant; use std::{collections::BTreeMap, rc::Rc, time::Duration}; @@ -1551,12 +1552,11 @@ impl AcpThreadView { return primary; }; - let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); - let primary = if entry_ix == total_entries - 1 && !is_generating { + let primary = if entry_ix == total_entries - 1 { v_flex() .w_full() .child(primary) - .child(self.render_thread_controls(cx)) + .child(self.render_thread_controls(&thread, cx)) .when_some( self.thread_feedback.comments_editor.clone(), |this, editor| this.child(Self::render_feedback_feedback_editor(editor, cx)), @@ -1698,15 +1698,16 @@ impl AcpThreadView { .into_any_element() } - fn render_tool_call_icon( + fn render_tool_call( &self, - group_name: SharedString, entry_ix: usize, - is_collapsible: bool, - is_open: bool, tool_call: &ToolCall, + window: &Window, cx: &Context, ) -> Div { + let header_id = SharedString::from(format!("outer-tool-call-header-{}", entry_ix)); + let card_header_id = SharedString::from("inner-tool-call-header"); + let tool_icon = if tool_call.kind == acp::ToolKind::Edit && tool_call.locations.len() == 1 { FileIcons::get_icon(&tool_call.locations[0].path, cx) @@ -1714,7 +1715,7 @@ impl AcpThreadView { .unwrap_or(Icon::new(IconName::ToolPencil)) } else { Icon::new(match tool_call.kind { - acp::ToolKind::Read => IconName::ToolRead, + acp::ToolKind::Read => IconName::ToolSearch, acp::ToolKind::Edit => IconName::ToolPencil, acp::ToolKind::Delete => IconName::ToolDeleteFile, acp::ToolKind::Move => IconName::ArrowRightLeft, @@ -1728,59 +1729,6 @@ impl AcpThreadView { .size(IconSize::Small) .color(Color::Muted); - let base_container = h_flex().flex_shrink_0().size_4().justify_center(); - - if is_collapsible { - base_container - .child( - div() - .group_hover(&group_name, |s| s.invisible().w_0()) - .child(tool_icon), - ) - .child( - h_flex() - .absolute() - .inset_0() - .invisible() - .justify_center() - .group_hover(&group_name, |s| s.visible()) - .child( - Disclosure::new(("expand", entry_ix), is_open) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronRight) - .on_click(cx.listener({ - let id = tool_call.id.clone(); - move |this: &mut Self, _, _, cx: &mut Context| { - if is_open { - this.expanded_tool_calls.remove(&id); - } else { - this.expanded_tool_calls.insert(id.clone()); - } - cx.notify(); - } - })), - ), - ) - } else { - base_container.child(tool_icon) - } - } - - fn render_tool_call( - &self, - entry_ix: usize, - tool_call: &ToolCall, - window: &Window, - cx: &Context, - ) -> Div { - let header_id = SharedString::from(format!("outer-tool-call-header-{}", entry_ix)); - let card_header_id = SharedString::from("inner-tool-call-header"); - - let in_progress = match &tool_call.status { - ToolCallStatus::InProgress => true, - _ => false, - }; - let failed_or_canceled = match &tool_call.status { ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed => true, _ => false, @@ -1880,6 +1828,7 @@ impl AcpThreadView { .child( h_flex() .id(header_id) + .group(&card_header_id) .relative() .w_full() .max_w_full() @@ -1897,19 +1846,11 @@ impl AcpThreadView { }) .child( h_flex() - .group(&card_header_id) .relative() .w_full() .h(window.line_height() - px(2.)) .text_size(self.tool_name_font_size()) - .child(self.render_tool_call_icon( - card_header_id, - entry_ix, - is_collapsible, - is_open, - tool_call, - cx, - )) + .child(tool_icon) .child(if tool_call.locations.len() == 1 { let name = tool_call.locations[0] .path @@ -1937,13 +1878,13 @@ impl AcpThreadView { }) .child(name) .tooltip(Tooltip::text("Jump to File")) + .cursor(gpui::CursorStyle::PointingHand) .on_click(cx.listener(move |this, _, window, cx| { this.open_tool_call_location(entry_ix, 0, window, cx); })) .into_any_element() } else { h_flex() - .id("non-card-label-container") .relative() .w_full() .max_w_full() @@ -1954,47 +1895,39 @@ impl AcpThreadView { default_markdown_style(false, true, window, cx), ))) .child(gradient_overlay(gradient_color)) - .on_click(cx.listener({ - let id = tool_call.id.clone(); - move |this: &mut Self, _, _, cx: &mut Context| { - if is_open { - this.expanded_tool_calls.remove(&id); - } else { - this.expanded_tool_calls.insert(id.clone()); - } - cx.notify(); - } - })) .into_any() }), ) - .when(in_progress && use_card_layout && !is_open, |this| { - this.child( - div().absolute().right_2().child( - Icon::new(IconName::ArrowCircle) - .color(Color::Muted) - .size(IconSize::Small) - .with_animation( - "running", - Animation::new(Duration::from_secs(3)).repeat(), - |icon, delta| { - icon.transform(Transformation::rotate(percentage( - delta, - ))) - }, - ), - ), - ) - }) - .when(failed_or_canceled, |this| { - this.child( - div().absolute().right_2().child( - Icon::new(IconName::Close) - .color(Color::Error) - .size(IconSize::Small), - ), - ) - }), + .child( + h_flex() + .gap_px() + .when(is_collapsible, |this| { + this.child( + Disclosure::new(("expand", entry_ix), is_open) + .opened_icon(IconName::ChevronUp) + .closed_icon(IconName::ChevronDown) + .visible_on_hover(&card_header_id) + .on_click(cx.listener({ + let id = tool_call.id.clone(); + move |this: &mut Self, _, _, cx: &mut Context| { + if is_open { + this.expanded_tool_calls.remove(&id); + } else { + this.expanded_tool_calls.insert(id.clone()); + } + cx.notify(); + } + })), + ) + }) + .when(failed_or_canceled, |this| { + this.child( + Icon::new(IconName::Close) + .color(Color::Error) + .size(IconSize::Small), + ) + }), + ), ) .children(tool_output_display) } @@ -2064,9 +1997,27 @@ impl AcpThreadView { cx: &Context, ) -> AnyElement { let uri: SharedString = resource_link.uri.clone().into(); + let is_file = resource_link.uri.strip_prefix("file://"); - let label: SharedString = if let Some(path) = resource_link.uri.strip_prefix("file://") { - path.to_string().into() + let label: SharedString = if let Some(abs_path) = is_file { + if let Some(project_path) = self + .project + .read(cx) + .project_path_for_absolute_path(&Path::new(abs_path), cx) + && let Some(worktree) = self + .project + .read(cx) + .worktree_for_id(project_path.worktree_id, cx) + { + worktree + .read(cx) + .full_path(&project_path.path) + .to_string_lossy() + .to_string() + .into() + } else { + abs_path.to_string().into() + } } else { uri.clone() }; @@ -2083,10 +2034,12 @@ impl AcpThreadView { Button::new(button_id, label) .label_size(LabelSize::Small) .color(Color::Muted) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) .truncate(true) + .when(is_file.is_none(), |this| { + this.icon(IconName::ArrowUpRight) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + }) .on_click(cx.listener({ let workspace = self.workspace.clone(); move |_, _, window, cx: &mut Context| { @@ -3727,16 +3680,19 @@ impl AcpThreadView { fn toggle_following(&mut self, window: &mut Window, cx: &mut Context) { let following = self.is_following(cx); + self.should_be_following = !following; - self.workspace - .update(cx, |workspace, cx| { - if following { - workspace.unfollow(CollaboratorId::Agent, window, cx); - } else { - workspace.follow(CollaboratorId::Agent, window, cx); - } - }) - .ok(); + if self.thread().map(|thread| thread.read(cx).status()) == Some(ThreadStatus::Generating) { + self.workspace + .update(cx, |workspace, cx| { + if following { + workspace.unfollow(CollaboratorId::Agent, window, cx); + } else { + workspace.follow(CollaboratorId::Agent, window, cx); + } + }) + .ok(); + } telemetry::event!("Follow Agent Selected", following = !following); } @@ -3744,6 +3700,20 @@ impl AcpThreadView { fn render_follow_toggle(&self, cx: &mut Context) -> impl IntoElement { let following = self.is_following(cx); + let tooltip_label = if following { + if self.agent.name() == "Zed Agent" { + format!("Stop Following the {}", self.agent.name()) + } else { + format!("Stop Following {}", self.agent.name()) + } + } else { + if self.agent.name() == "Zed Agent" { + format!("Follow the {}", self.agent.name()) + } else { + format!("Follow {}", self.agent.name()) + } + }; + IconButton::new("follow-agent", IconName::Crosshair) .icon_size(IconSize::Small) .icon_color(Color::Muted) @@ -3751,10 +3721,10 @@ impl AcpThreadView { .selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor))) .tooltip(move |window, cx| { if following { - Tooltip::for_action("Stop Following Agent", &Follow, window, cx) + Tooltip::for_action(tooltip_label.clone(), &Follow, window, cx) } else { Tooltip::with_meta( - "Follow Agent", + tooltip_label.clone(), Some(&Follow), "Track the agent's location as it reads and edits files.", window, @@ -4175,7 +4145,20 @@ impl AcpThreadView { } } - fn render_thread_controls(&self, cx: &Context) -> impl IntoElement { + fn render_thread_controls( + &self, + thread: &Entity, + cx: &Context, + ) -> impl IntoElement { + let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); + if is_generating { + return h_flex().id("thread-controls-container").ml_1().child( + div() + .py_2() + .px(rems_from_px(22.)) + .child(SpinnerLabel::new().size(LabelSize::Small)), + ); + } let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown) .shape(ui::IconButtonShape::Square) .icon_size(IconSize::Small) @@ -4899,45 +4882,30 @@ impl Render for AcpThreadView { .items_center() .justify_end() .child(self.render_load_error(e, cx)), - ThreadState::Ready { thread, .. } => { - let thread_clone = thread.clone(); - - v_flex().flex_1().map(|this| { - if has_messages { - this.child( - list( - self.list_state.clone(), - cx.processor(|this, index: usize, window, cx| { - let Some((entry, len)) = this.thread().and_then(|thread| { - let entries = &thread.read(cx).entries(); - Some((entries.get(index)?, entries.len())) - }) else { - return Empty.into_any(); - }; - this.render_entry(index, len, entry, window, cx) - }), - ) - .with_sizing_behavior(gpui::ListSizingBehavior::Auto) - .flex_grow() - .into_any(), + ThreadState::Ready { .. } => v_flex().flex_1().map(|this| { + if has_messages { + this.child( + list( + self.list_state.clone(), + cx.processor(|this, index: usize, window, cx| { + let Some((entry, len)) = this.thread().and_then(|thread| { + let entries = &thread.read(cx).entries(); + Some((entries.get(index)?, entries.len())) + }) else { + return Empty.into_any(); + }; + this.render_entry(index, len, entry, window, cx) + }), ) - .child(self.render_vertical_scrollbar(cx)) - .children( - match thread_clone.read(cx).status() { - ThreadStatus::Idle - | ThreadStatus::WaitingForToolConfirmation => None, - ThreadStatus::Generating => div() - .py_2() - .px(rems_from_px(22.)) - .child(SpinnerLabel::new().size(LabelSize::Small)) - .into(), - }, - ) - } else { - this.child(self.render_recent_history(window, cx)) - } - }) - } + .with_sizing_behavior(gpui::ListSizingBehavior::Auto) + .flex_grow() + .into_any(), + ) + .child(self.render_vertical_scrollbar(cx)) + } else { + this.child(self.render_recent_history(window, cx)) + } + }), }) // The activity bar is intentionally rendered outside of the ThreadState::Ready match // above so that the scrollbar doesn't render behind it. The current setup allows diff --git a/crates/assistant_tools/src/find_path_tool.rs b/crates/assistant_tools/src/find_path_tool.rs index ac2c7a32ab..d1451132ae 100644 --- a/crates/assistant_tools/src/find_path_tool.rs +++ b/crates/assistant_tools/src/find_path_tool.rs @@ -435,8 +435,8 @@ mod test { assert_eq!( matches, &[ - PathBuf::from("root/apple/banana/carrot"), - PathBuf::from("root/apple/bandana/carbonara") + PathBuf::from(path!("root/apple/banana/carrot")), + PathBuf::from(path!("root/apple/bandana/carbonara")) ] ); @@ -447,8 +447,8 @@ mod test { assert_eq!( matches, &[ - PathBuf::from("root/apple/banana/carrot"), - PathBuf::from("root/apple/bandana/carbonara") + PathBuf::from(path!("root/apple/banana/carrot")), + PathBuf::from(path!("root/apple/bandana/carbonara")) ] ); } diff --git a/crates/assistant_tools/src/read_file_tool.rs b/crates/assistant_tools/src/read_file_tool.rs index 766ee3b161..a6e984fca6 100644 --- a/crates/assistant_tools/src/read_file_tool.rs +++ b/crates/assistant_tools/src/read_file_tool.rs @@ -68,7 +68,7 @@ impl Tool for ReadFileTool { } fn icon(&self) -> IconName { - IconName::ToolRead + IconName::ToolSearch } fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { From 0b9ff531d969bf095cf9292261b863e5155879e5 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 25 Aug 2025 14:07:10 -0600 Subject: [PATCH 74/89] acp: Update error matching (#36898) Release Notes: - N/A --- crates/acp_thread/src/acp_thread.rs | 21 ++++++++++++--------- crates/agent_servers/src/acp.rs | 4 +++- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 779f9964da..4ded647a74 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -183,16 +183,15 @@ impl ToolCall { language_registry: Arc, cx: &mut App, ) -> Self { + let title = if let Some((first_line, _)) = tool_call.title.split_once("\n") { + first_line.to_owned() + "…" + } else { + tool_call.title + }; Self { id: tool_call.id, - label: cx.new(|cx| { - Markdown::new( - tool_call.title.into(), - Some(language_registry.clone()), - None, - cx, - ) - }), + label: cx + .new(|cx| Markdown::new(title.into(), Some(language_registry.clone()), None, cx)), kind: tool_call.kind, content: tool_call .content @@ -233,7 +232,11 @@ impl ToolCall { if let Some(title) = title { self.label.update(cx, |label, cx| { - label.replace(title, cx); + if let Some((first_line, _)) = title.split_once("\n") { + label.replace(first_line.to_owned() + "…", cx) + } else { + label.replace(title, cx); + } }); } diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 5a4efe12e5..9080fc1ab0 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -266,7 +266,9 @@ impl AgentConnection for AcpConnection { match serde_json::from_value(data.clone()) { Ok(ErrorDetails { details }) => { - if suppress_abort_err && details.contains("This operation was aborted") + if suppress_abort_err + && (details.contains("This operation was aborted") + || details.contains("The user aborted a request")) { Ok(acp::PromptResponse { stop_reason: acp::StopReason::Cancelled, From f5ef0e3714d6a2f2feb9b1cb165853a75d66ea5d Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Mon, 25 Aug 2025 16:10:17 -0400 Subject: [PATCH 75/89] acp: Show output for read_file tool in a code block (#36900) Release Notes: - N/A --- crates/agent2/src/tools/read_file_tool.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/agent2/src/tools/read_file_tool.rs b/crates/agent2/src/tools/read_file_tool.rs index fea9732093..e771c26eca 100644 --- a/crates/agent2/src/tools/read_file_tool.rs +++ b/crates/agent2/src/tools/read_file_tool.rs @@ -11,6 +11,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::Settings; use std::{path::Path, sync::Arc}; +use util::markdown::MarkdownCodeBlock; use crate::{AgentTool, ToolCallEventStream}; @@ -243,6 +244,19 @@ impl AgentTool for ReadFileTool { }]), ..Default::default() }); + if let Ok(LanguageModelToolResultContent::Text(text)) = &result { + let markdown = MarkdownCodeBlock { + tag: &input.path, + text, + } + .to_string(); + event_stream.update_fields(ToolCallUpdateFields { + content: Some(vec![acp::ToolCallContent::Content { + content: markdown.into(), + }]), + ..Default::default() + }) + } } })?; From 662e6a8e6dc1e4fe2d27c54e02363dd1e63bfc4b Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Mon, 25 Aug 2025 16:29:38 -0400 Subject: [PATCH 76/89] Sync `Cargo.lock` with `Cargo.toml` --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 21aca94384..cdbc5c00b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1384,7 +1384,7 @@ version = "0.1.0" dependencies = [ "anyhow", "collections", - "derive_more 0.99.19", + "derive_more", "gpui", "parking_lot", "rodio", From 1d96a7af3903c271afc2c632e15cff1916758cd6 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Mon, 25 Aug 2025 16:30:49 -0400 Subject: [PATCH 77/89] zed 0.201.4 --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cdbc5c00b8..37853f82ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20373,7 +20373,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.201.3" +version = "0.201.4" dependencies = [ "acp_tools", "activity_indicator", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index fa903b70b8..508dd3861d 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "0.201.3" +version = "0.201.4" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] From d0471d4feafcdb8537bbfff92f776a3549c85266 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:34:55 -0300 Subject: [PATCH 78/89] thread view: Add link to docs in the toolbar plus menu (#36883) Release Notes: - N/A --- crates/agent_ui/src/agent_panel.rs | 10 ++++++++++ crates/ui/src/components/context_menu.rs | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 1eafb8dd4d..269aec3365 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -9,6 +9,7 @@ use agent_servers::AgentServerSettings; use agent2::{DbThreadMetadata, HistoryEntry}; use db::kvp::{Dismissable, KEY_VALUE_STORE}; use serde::{Deserialize, Serialize}; +use zed_actions::OpenBrowser; use zed_actions::agent::ReauthenticateAgent; use crate::acp::{AcpThreadHistory, ThreadHistoryEvent}; @@ -2689,6 +2690,15 @@ impl AgentPanel { } menu + }) + .when(cx.has_flag::(), |menu| { + menu.separator().link( + "Add Your Own Agent", + OpenBrowser { + url: "https://agentclientprotocol.com/".into(), + } + .boxed_clone(), + ) }); menu })) diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index 25575c4f1e..21ab283d88 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -561,7 +561,7 @@ impl ContextMenu { action: Some(action.boxed_clone()), handler: Rc::new(move |_, window, cx| window.dispatch_action(action.boxed_clone(), cx)), icon: Some(IconName::ArrowUpRight), - icon_size: IconSize::Small, + icon_size: IconSize::XSmall, icon_position: IconPosition::End, icon_color: None, disabled: false, From 4174b722cfc1835283ee1af0a019966ba3701748 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 25 Aug 2025 16:04:44 -0600 Subject: [PATCH 79/89] acp: Rename dev command (#36908) Release Notes: - N/A --- crates/acp_tools/src/acp_tools.rs | 4 ++-- crates/zed/src/zed.rs | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/acp_tools/src/acp_tools.rs b/crates/acp_tools/src/acp_tools.rs index ee12b04cde..e20a040e9d 100644 --- a/crates/acp_tools/src/acp_tools.rs +++ b/crates/acp_tools/src/acp_tools.rs @@ -21,12 +21,12 @@ use ui::prelude::*; use util::ResultExt as _; use workspace::{Item, Workspace}; -actions!(acp, [OpenDebugTools]); +actions!(dev, [OpenAcpLogs]); pub fn init(cx: &mut App) { cx.observe_new( |workspace: &mut Workspace, _window, _cx: &mut Context| { - workspace.register_action(|workspace, _: &OpenDebugTools, window, cx| { + workspace.register_action(|workspace, _: &OpenAcpLogs, window, cx| { let acp_tools = Box::new(cx.new(|cx| AcpTools::new(workspace.project().clone(), cx))); workspace.add_item_to_active_pane(acp_tools, None, true, window, cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 6be4ccda97..958149825a 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -4424,7 +4424,6 @@ mod tests { assert_eq!(actions_without_namespace, Vec::<&str>::new()); let expected_namespaces = vec![ - "acp", "activity_indicator", "agent", #[cfg(not(target_os = "macos"))] From ee2b1f96d30ce59dce561a4e85785f542d1c5559 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 25 Aug 2025 16:23:58 -0600 Subject: [PATCH 80/89] Remove unused files (#36909) Closes #ISSUE Release Notes: - N/A --- crates/agent2/src/tests/mod.rs | 1 + crates/agent_servers/src/acp/v0.rs | 524 ----------------------------- crates/agent_servers/src/acp/v1.rs | 376 --------------------- 3 files changed, 1 insertion(+), 900 deletions(-) delete mode 100644 crates/agent_servers/src/acp/v0.rs delete mode 100644 crates/agent_servers/src/acp/v1.rs diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index 864fbf8b10..093b8ba971 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -1347,6 +1347,7 @@ async fn test_cancellation(cx: &mut TestAppContext) { } #[gpui::test] +#[cfg_attr(target_os = "windows", ignore)] // TODO: Fix this test on Windows async fn test_in_progress_send_canceled_by_next_send(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; let fake_model = model.as_fake(); diff --git a/crates/agent_servers/src/acp/v0.rs b/crates/agent_servers/src/acp/v0.rs deleted file mode 100644 index be96048929..0000000000 --- a/crates/agent_servers/src/acp/v0.rs +++ /dev/null @@ -1,524 +0,0 @@ -// Translates old acp agents into the new schema -use action_log::ActionLog; -use agent_client_protocol as acp; -use agentic_coding_protocol::{self as acp_old, AgentRequest as _}; -use anyhow::{Context as _, Result, anyhow}; -use futures::channel::oneshot; -use gpui::{AppContext as _, AsyncApp, Entity, Task, WeakEntity}; -use project::Project; -use std::{any::Any, cell::RefCell, path::Path, rc::Rc}; -use ui::App; -use util::ResultExt as _; - -use crate::AgentServerCommand; -use acp_thread::{AcpThread, AgentConnection, AuthRequired}; - -#[derive(Clone)] -struct OldAcpClientDelegate { - thread: Rc>>, - cx: AsyncApp, - next_tool_call_id: Rc>, - // sent_buffer_versions: HashMap, HashMap>, -} - -impl OldAcpClientDelegate { - fn new(thread: Rc>>, cx: AsyncApp) -> Self { - Self { - thread, - cx, - next_tool_call_id: Rc::new(RefCell::new(0)), - } - } -} - -impl acp_old::Client for OldAcpClientDelegate { - async fn stream_assistant_message_chunk( - &self, - params: acp_old::StreamAssistantMessageChunkParams, - ) -> Result<(), acp_old::Error> { - let cx = &mut self.cx.clone(); - - cx.update(|cx| { - self.thread - .borrow() - .update(cx, |thread, cx| match params.chunk { - acp_old::AssistantMessageChunk::Text { text } => { - thread.push_assistant_content_block(text.into(), false, cx) - } - acp_old::AssistantMessageChunk::Thought { thought } => { - thread.push_assistant_content_block(thought.into(), true, cx) - } - }) - .log_err(); - })?; - - Ok(()) - } - - async fn request_tool_call_confirmation( - &self, - request: acp_old::RequestToolCallConfirmationParams, - ) -> Result { - let cx = &mut self.cx.clone(); - - let old_acp_id = *self.next_tool_call_id.borrow() + 1; - self.next_tool_call_id.replace(old_acp_id); - - let tool_call = into_new_tool_call( - acp::ToolCallId(old_acp_id.to_string().into()), - request.tool_call, - ); - - let mut options = match request.confirmation { - acp_old::ToolCallConfirmation::Edit { .. } => vec![( - acp_old::ToolCallConfirmationOutcome::AlwaysAllow, - acp::PermissionOptionKind::AllowAlways, - "Always Allow Edits".to_string(), - )], - acp_old::ToolCallConfirmation::Execute { root_command, .. } => vec![( - acp_old::ToolCallConfirmationOutcome::AlwaysAllow, - acp::PermissionOptionKind::AllowAlways, - format!("Always Allow {}", root_command), - )], - acp_old::ToolCallConfirmation::Mcp { - server_name, - tool_name, - .. - } => vec![ - ( - acp_old::ToolCallConfirmationOutcome::AlwaysAllowMcpServer, - acp::PermissionOptionKind::AllowAlways, - format!("Always Allow {}", server_name), - ), - ( - acp_old::ToolCallConfirmationOutcome::AlwaysAllowTool, - acp::PermissionOptionKind::AllowAlways, - format!("Always Allow {}", tool_name), - ), - ], - acp_old::ToolCallConfirmation::Fetch { .. } => vec![( - acp_old::ToolCallConfirmationOutcome::AlwaysAllow, - acp::PermissionOptionKind::AllowAlways, - "Always Allow".to_string(), - )], - acp_old::ToolCallConfirmation::Other { .. } => vec![( - acp_old::ToolCallConfirmationOutcome::AlwaysAllow, - acp::PermissionOptionKind::AllowAlways, - "Always Allow".to_string(), - )], - }; - - options.extend([ - ( - acp_old::ToolCallConfirmationOutcome::Allow, - acp::PermissionOptionKind::AllowOnce, - "Allow".to_string(), - ), - ( - acp_old::ToolCallConfirmationOutcome::Reject, - acp::PermissionOptionKind::RejectOnce, - "Reject".to_string(), - ), - ]); - - let mut outcomes = Vec::with_capacity(options.len()); - let mut acp_options = Vec::with_capacity(options.len()); - - for (index, (outcome, kind, label)) in options.into_iter().enumerate() { - outcomes.push(outcome); - acp_options.push(acp::PermissionOption { - id: acp::PermissionOptionId(index.to_string().into()), - name: label, - kind, - }) - } - - let response = cx - .update(|cx| { - self.thread.borrow().update(cx, |thread, cx| { - thread.request_tool_call_authorization(tool_call.into(), acp_options, cx) - }) - })?? - .context("Failed to update thread")? - .await; - - let outcome = match response { - Ok(option_id) => outcomes[option_id.0.parse::().unwrap_or(0)], - Err(oneshot::Canceled) => acp_old::ToolCallConfirmationOutcome::Cancel, - }; - - Ok(acp_old::RequestToolCallConfirmationResponse { - id: acp_old::ToolCallId(old_acp_id), - outcome, - }) - } - - async fn push_tool_call( - &self, - request: acp_old::PushToolCallParams, - ) -> Result { - let cx = &mut self.cx.clone(); - - let old_acp_id = *self.next_tool_call_id.borrow() + 1; - self.next_tool_call_id.replace(old_acp_id); - - cx.update(|cx| { - self.thread.borrow().update(cx, |thread, cx| { - thread.upsert_tool_call( - into_new_tool_call(acp::ToolCallId(old_acp_id.to_string().into()), request), - cx, - ) - }) - })?? - .context("Failed to update thread")?; - - Ok(acp_old::PushToolCallResponse { - id: acp_old::ToolCallId(old_acp_id), - }) - } - - async fn update_tool_call( - &self, - request: acp_old::UpdateToolCallParams, - ) -> Result<(), acp_old::Error> { - let cx = &mut self.cx.clone(); - - cx.update(|cx| { - self.thread.borrow().update(cx, |thread, cx| { - thread.update_tool_call( - acp::ToolCallUpdate { - id: acp::ToolCallId(request.tool_call_id.0.to_string().into()), - fields: acp::ToolCallUpdateFields { - status: Some(into_new_tool_call_status(request.status)), - content: Some( - request - .content - .into_iter() - .map(into_new_tool_call_content) - .collect::>(), - ), - ..Default::default() - }, - }, - cx, - ) - }) - })? - .context("Failed to update thread")??; - - Ok(()) - } - - async fn update_plan(&self, request: acp_old::UpdatePlanParams) -> Result<(), acp_old::Error> { - let cx = &mut self.cx.clone(); - - cx.update(|cx| { - self.thread.borrow().update(cx, |thread, cx| { - thread.update_plan( - acp::Plan { - entries: request - .entries - .into_iter() - .map(into_new_plan_entry) - .collect(), - }, - cx, - ) - }) - })? - .context("Failed to update thread")?; - - Ok(()) - } - - async fn read_text_file( - &self, - acp_old::ReadTextFileParams { path, line, limit }: acp_old::ReadTextFileParams, - ) -> Result { - let content = self - .cx - .update(|cx| { - self.thread.borrow().update(cx, |thread, cx| { - thread.read_text_file(path, line, limit, false, cx) - }) - })? - .context("Failed to update thread")? - .await?; - Ok(acp_old::ReadTextFileResponse { content }) - } - - async fn write_text_file( - &self, - acp_old::WriteTextFileParams { path, content }: acp_old::WriteTextFileParams, - ) -> Result<(), acp_old::Error> { - self.cx - .update(|cx| { - self.thread - .borrow() - .update(cx, |thread, cx| thread.write_text_file(path, content, cx)) - })? - .context("Failed to update thread")? - .await?; - - Ok(()) - } -} - -fn into_new_tool_call(id: acp::ToolCallId, request: acp_old::PushToolCallParams) -> acp::ToolCall { - acp::ToolCall { - id, - title: request.label, - kind: acp_kind_from_old_icon(request.icon), - status: acp::ToolCallStatus::InProgress, - content: request - .content - .into_iter() - .map(into_new_tool_call_content) - .collect(), - locations: request - .locations - .into_iter() - .map(into_new_tool_call_location) - .collect(), - raw_input: None, - raw_output: None, - } -} - -fn acp_kind_from_old_icon(icon: acp_old::Icon) -> acp::ToolKind { - match icon { - acp_old::Icon::FileSearch => acp::ToolKind::Search, - acp_old::Icon::Folder => acp::ToolKind::Search, - acp_old::Icon::Globe => acp::ToolKind::Search, - acp_old::Icon::Hammer => acp::ToolKind::Other, - acp_old::Icon::LightBulb => acp::ToolKind::Think, - acp_old::Icon::Pencil => acp::ToolKind::Edit, - acp_old::Icon::Regex => acp::ToolKind::Search, - acp_old::Icon::Terminal => acp::ToolKind::Execute, - } -} - -fn into_new_tool_call_status(status: acp_old::ToolCallStatus) -> acp::ToolCallStatus { - match status { - acp_old::ToolCallStatus::Running => acp::ToolCallStatus::InProgress, - acp_old::ToolCallStatus::Finished => acp::ToolCallStatus::Completed, - acp_old::ToolCallStatus::Error => acp::ToolCallStatus::Failed, - } -} - -fn into_new_tool_call_content(content: acp_old::ToolCallContent) -> acp::ToolCallContent { - match content { - acp_old::ToolCallContent::Markdown { markdown } => markdown.into(), - acp_old::ToolCallContent::Diff { diff } => acp::ToolCallContent::Diff { - diff: into_new_diff(diff), - }, - } -} - -fn into_new_diff(diff: acp_old::Diff) -> acp::Diff { - acp::Diff { - path: diff.path, - old_text: diff.old_text, - new_text: diff.new_text, - } -} - -fn into_new_tool_call_location(location: acp_old::ToolCallLocation) -> acp::ToolCallLocation { - acp::ToolCallLocation { - path: location.path, - line: location.line, - } -} - -fn into_new_plan_entry(entry: acp_old::PlanEntry) -> acp::PlanEntry { - acp::PlanEntry { - content: entry.content, - priority: into_new_plan_priority(entry.priority), - status: into_new_plan_status(entry.status), - } -} - -fn into_new_plan_priority(priority: acp_old::PlanEntryPriority) -> acp::PlanEntryPriority { - match priority { - acp_old::PlanEntryPriority::Low => acp::PlanEntryPriority::Low, - acp_old::PlanEntryPriority::Medium => acp::PlanEntryPriority::Medium, - acp_old::PlanEntryPriority::High => acp::PlanEntryPriority::High, - } -} - -fn into_new_plan_status(status: acp_old::PlanEntryStatus) -> acp::PlanEntryStatus { - match status { - acp_old::PlanEntryStatus::Pending => acp::PlanEntryStatus::Pending, - acp_old::PlanEntryStatus::InProgress => acp::PlanEntryStatus::InProgress, - acp_old::PlanEntryStatus::Completed => acp::PlanEntryStatus::Completed, - } -} - -pub struct AcpConnection { - pub name: &'static str, - pub connection: acp_old::AgentConnection, - pub _child_status: Task>, - pub current_thread: Rc>>, -} - -impl AcpConnection { - pub fn stdio( - name: &'static str, - command: AgentServerCommand, - root_dir: &Path, - cx: &mut AsyncApp, - ) -> Task> { - let root_dir = root_dir.to_path_buf(); - - cx.spawn(async move |cx| { - let mut child = util::command::new_smol_command(&command.path) - .args(command.args.iter()) - .current_dir(root_dir) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::inherit()) - .kill_on_drop(true) - .spawn()?; - - let stdin = child.stdin.take().unwrap(); - let stdout = child.stdout.take().unwrap(); - log::trace!("Spawned (pid: {})", child.id()); - - let foreground_executor = cx.foreground_executor().clone(); - - let thread_rc = Rc::new(RefCell::new(WeakEntity::new_invalid())); - - let (connection, io_fut) = acp_old::AgentConnection::connect_to_agent( - OldAcpClientDelegate::new(thread_rc.clone(), cx.clone()), - stdin, - stdout, - move |fut| foreground_executor.spawn(fut).detach(), - ); - - let io_task = cx.background_spawn(async move { - io_fut.await.log_err(); - }); - - let child_status = cx.background_spawn(async move { - let result = match child.status().await { - Err(e) => Err(anyhow!(e)), - Ok(result) if result.success() => Ok(()), - Ok(result) => Err(anyhow!(result)), - }; - drop(io_task); - result - }); - - Ok(Self { - name, - connection, - _child_status: child_status, - current_thread: thread_rc, - }) - }) - } -} - -impl AgentConnection for AcpConnection { - fn new_thread( - self: Rc, - project: Entity, - _cwd: &Path, - cx: &mut App, - ) -> Task>> { - let task = self.connection.request_any( - acp_old::InitializeParams { - protocol_version: acp_old::ProtocolVersion::latest(), - } - .into_any(), - ); - let current_thread = self.current_thread.clone(); - cx.spawn(async move |cx| { - let result = task.await?; - let result = acp_old::InitializeParams::response_from_any(result)?; - - if !result.is_authenticated { - anyhow::bail!(AuthRequired::new()) - } - - cx.update(|cx| { - let thread = cx.new(|cx| { - let session_id = acp::SessionId("acp-old-no-id".into()); - let action_log = cx.new(|_| ActionLog::new(project.clone())); - AcpThread::new(self.name, self.clone(), project, action_log, session_id) - }); - current_thread.replace(thread.downgrade()); - thread - }) - }) - } - - fn auth_methods(&self) -> &[acp::AuthMethod] { - &[] - } - - fn authenticate(&self, _method_id: acp::AuthMethodId, cx: &mut App) -> Task> { - let task = self - .connection - .request_any(acp_old::AuthenticateParams.into_any()); - cx.foreground_executor().spawn(async move { - task.await?; - Ok(()) - }) - } - - fn prompt( - &self, - _id: Option, - params: acp::PromptRequest, - cx: &mut App, - ) -> Task> { - let chunks = params - .prompt - .into_iter() - .filter_map(|block| match block { - acp::ContentBlock::Text(text) => { - Some(acp_old::UserMessageChunk::Text { text: text.text }) - } - acp::ContentBlock::ResourceLink(link) => Some(acp_old::UserMessageChunk::Path { - path: link.uri.into(), - }), - _ => None, - }) - .collect(); - - let task = self - .connection - .request_any(acp_old::SendUserMessageParams { chunks }.into_any()); - cx.foreground_executor().spawn(async move { - task.await?; - anyhow::Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - }) - }) - } - - fn prompt_capabilities(&self) -> acp::PromptCapabilities { - acp::PromptCapabilities { - image: false, - audio: false, - embedded_context: false, - } - } - - fn cancel(&self, _session_id: &acp::SessionId, cx: &mut App) { - let task = self - .connection - .request_any(acp_old::CancelSendMessageParams.into_any()); - cx.foreground_executor() - .spawn(async move { - task.await?; - anyhow::Ok(()) - }) - .detach_and_log_err(cx) - } - - fn into_any(self: Rc) -> Rc { - self - } -} diff --git a/crates/agent_servers/src/acp/v1.rs b/crates/agent_servers/src/acp/v1.rs deleted file mode 100644 index 1945ad2483..0000000000 --- a/crates/agent_servers/src/acp/v1.rs +++ /dev/null @@ -1,376 +0,0 @@ -use acp_tools::AcpConnectionRegistry; -use action_log::ActionLog; -use agent_client_protocol::{self as acp, Agent as _, ErrorCode}; -use anyhow::anyhow; -use collections::HashMap; -use futures::AsyncBufReadExt as _; -use futures::channel::oneshot; -use futures::io::BufReader; -use project::Project; -use serde::Deserialize; -use std::path::Path; -use std::rc::Rc; -use std::{any::Any, cell::RefCell}; - -use anyhow::{Context as _, Result}; -use gpui::{App, AppContext as _, AsyncApp, Entity, Task, WeakEntity}; - -use crate::{AgentServerCommand, acp::UnsupportedVersion}; -use acp_thread::{AcpThread, AgentConnection, AuthRequired, LoadError}; - -pub struct AcpConnection { - server_name: &'static str, - connection: Rc, - sessions: Rc>>, - auth_methods: Vec, - prompt_capabilities: acp::PromptCapabilities, - _io_task: Task>, -} - -pub struct AcpSession { - thread: WeakEntity, - suppress_abort_err: bool, -} - -const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::V1; - -impl AcpConnection { - pub async fn stdio( - server_name: &'static str, - command: AgentServerCommand, - root_dir: &Path, - cx: &mut AsyncApp, - ) -> Result { - let mut child = util::command::new_smol_command(&command.path) - .args(command.args.iter().map(|arg| arg.as_str())) - .envs(command.env.iter().flatten()) - .current_dir(root_dir) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true) - .spawn()?; - - let stdout = child.stdout.take().context("Failed to take stdout")?; - let stdin = child.stdin.take().context("Failed to take stdin")?; - let stderr = child.stderr.take().context("Failed to take stderr")?; - log::trace!("Spawned (pid: {})", child.id()); - - let sessions = Rc::new(RefCell::new(HashMap::default())); - - let client = ClientDelegate { - sessions: sessions.clone(), - cx: cx.clone(), - }; - let (connection, io_task) = acp::ClientSideConnection::new(client, stdin, stdout, { - let foreground_executor = cx.foreground_executor().clone(); - move |fut| { - foreground_executor.spawn(fut).detach(); - } - }); - - let io_task = cx.background_spawn(io_task); - - cx.background_spawn(async move { - let mut stderr = BufReader::new(stderr); - let mut line = String::new(); - while let Ok(n) = stderr.read_line(&mut line).await - && n > 0 - { - log::warn!("agent stderr: {}", &line); - line.clear(); - } - }) - .detach(); - - cx.spawn({ - let sessions = sessions.clone(); - async move |cx| { - let status = child.status().await?; - - for session in sessions.borrow().values() { - session - .thread - .update(cx, |thread, cx| { - thread.emit_load_error(LoadError::Exited { status }, cx) - }) - .ok(); - } - - anyhow::Ok(()) - } - }) - .detach(); - - let connection = Rc::new(connection); - - cx.update(|cx| { - AcpConnectionRegistry::default_global(cx).update(cx, |registry, cx| { - registry.set_active_connection(server_name, &connection, cx) - }); - })?; - - let response = connection - .initialize(acp::InitializeRequest { - protocol_version: acp::VERSION, - client_capabilities: acp::ClientCapabilities { - fs: acp::FileSystemCapability { - read_text_file: true, - write_text_file: true, - }, - }, - }) - .await?; - - if response.protocol_version < MINIMUM_SUPPORTED_VERSION { - return Err(UnsupportedVersion.into()); - } - - Ok(Self { - auth_methods: response.auth_methods, - connection, - server_name, - sessions, - prompt_capabilities: response.agent_capabilities.prompt_capabilities, - _io_task: io_task, - }) - } -} - -impl AgentConnection for AcpConnection { - fn new_thread( - self: Rc, - project: Entity, - cwd: &Path, - cx: &mut App, - ) -> Task>> { - let conn = self.connection.clone(); - let sessions = self.sessions.clone(); - let cwd = cwd.to_path_buf(); - cx.spawn(async move |cx| { - let response = conn - .new_session(acp::NewSessionRequest { - mcp_servers: vec![], - cwd, - }) - .await - .map_err(|err| { - if err.code == acp::ErrorCode::AUTH_REQUIRED.code { - let mut error = AuthRequired::new(); - - if err.message != acp::ErrorCode::AUTH_REQUIRED.message { - error = error.with_description(err.message); - } - - anyhow!(error) - } else { - anyhow!(err) - } - })?; - - let session_id = response.session_id; - let action_log = cx.new(|_| ActionLog::new(project.clone()))?; - let thread = cx.new(|_cx| { - AcpThread::new( - self.server_name, - self.clone(), - project, - action_log, - session_id.clone(), - ) - })?; - - let session = AcpSession { - thread: thread.downgrade(), - suppress_abort_err: false, - }; - sessions.borrow_mut().insert(session_id, session); - - Ok(thread) - }) - } - - fn auth_methods(&self) -> &[acp::AuthMethod] { - &self.auth_methods - } - - fn authenticate(&self, method_id: acp::AuthMethodId, cx: &mut App) -> Task> { - let conn = self.connection.clone(); - cx.foreground_executor().spawn(async move { - let result = conn - .authenticate(acp::AuthenticateRequest { - method_id: method_id.clone(), - }) - .await?; - - Ok(result) - }) - } - - fn prompt( - &self, - _id: Option, - params: acp::PromptRequest, - cx: &mut App, - ) -> Task> { - let conn = self.connection.clone(); - let sessions = self.sessions.clone(); - let session_id = params.session_id.clone(); - cx.foreground_executor().spawn(async move { - let result = conn.prompt(params).await; - - let mut suppress_abort_err = false; - - if let Some(session) = sessions.borrow_mut().get_mut(&session_id) { - suppress_abort_err = session.suppress_abort_err; - session.suppress_abort_err = false; - } - - match result { - Ok(response) => Ok(response), - Err(err) => { - if err.code != ErrorCode::INTERNAL_ERROR.code { - anyhow::bail!(err) - } - - let Some(data) = &err.data else { - anyhow::bail!(err) - }; - - // Temporary workaround until the following PR is generally available: - // https://github.com/google-gemini/gemini-cli/pull/6656 - - #[derive(Deserialize)] - #[serde(deny_unknown_fields)] - struct ErrorDetails { - details: Box, - } - - match serde_json::from_value(data.clone()) { - Ok(ErrorDetails { details }) => { - if suppress_abort_err && details.contains("This operation was aborted") - { - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::Cancelled, - }) - } else { - Err(anyhow!(details)) - } - } - Err(_) => Err(anyhow!(err)), - } - } - } - }) - } - - fn prompt_capabilities(&self) -> acp::PromptCapabilities { - self.prompt_capabilities - } - - fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) { - if let Some(session) = self.sessions.borrow_mut().get_mut(session_id) { - session.suppress_abort_err = true; - } - let conn = self.connection.clone(); - let params = acp::CancelNotification { - session_id: session_id.clone(), - }; - cx.foreground_executor() - .spawn(async move { conn.cancel(params).await }) - .detach(); - } - - fn into_any(self: Rc) -> Rc { - self - } -} - -struct ClientDelegate { - sessions: Rc>>, - cx: AsyncApp, -} - -impl acp::Client for ClientDelegate { - async fn request_permission( - &self, - arguments: acp::RequestPermissionRequest, - ) -> Result { - let cx = &mut self.cx.clone(); - let rx = self - .sessions - .borrow() - .get(&arguments.session_id) - .context("Failed to get session")? - .thread - .update(cx, |thread, cx| { - thread.request_tool_call_authorization(arguments.tool_call, arguments.options, cx) - })?; - - let result = rx?.await; - - let outcome = match result { - Ok(option) => acp::RequestPermissionOutcome::Selected { option_id: option }, - Err(oneshot::Canceled) => acp::RequestPermissionOutcome::Cancelled, - }; - - Ok(acp::RequestPermissionResponse { outcome }) - } - - async fn write_text_file( - &self, - arguments: acp::WriteTextFileRequest, - ) -> Result<(), acp::Error> { - let cx = &mut self.cx.clone(); - let task = self - .sessions - .borrow() - .get(&arguments.session_id) - .context("Failed to get session")? - .thread - .update(cx, |thread, cx| { - thread.write_text_file(arguments.path, arguments.content, cx) - })?; - - task.await?; - - Ok(()) - } - - async fn read_text_file( - &self, - arguments: acp::ReadTextFileRequest, - ) -> Result { - let cx = &mut self.cx.clone(); - let task = self - .sessions - .borrow() - .get(&arguments.session_id) - .context("Failed to get session")? - .thread - .update(cx, |thread, cx| { - thread.read_text_file(arguments.path, arguments.line, arguments.limit, false, cx) - })?; - - let content = task.await?; - - Ok(acp::ReadTextFileResponse { content }) - } - - async fn session_notification( - &self, - notification: acp::SessionNotification, - ) -> Result<(), acp::Error> { - let cx = &mut self.cx.clone(); - let sessions = self.sessions.borrow(); - let session = sessions - .get(¬ification.session_id) - .context("Failed to get session")?; - - session.thread.update(cx, |thread, cx| { - thread.handle_session_update(notification.update, cx) - })??; - - Ok(()) - } -} From 6a7588c66c904e9984b2432cc2e79e7af86aa675 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 25 Aug 2025 18:55:24 -0600 Subject: [PATCH 81/89] acp: Send user-configured MCP tools (#36910) Release Notes: - N/A --- crates/agent2/src/tests/mod.rs | 1 + crates/agent_servers/src/acp.rs | 30 ++++++++++++++++++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index 093b8ba971..78e5c88280 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -72,6 +72,7 @@ async fn test_echo(cx: &mut TestAppContext) { } #[gpui::test] +#[cfg_attr(target_os = "windows", ignore)] // TODO: Fix this test on Windows async fn test_thinking(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; let fake_model = model.as_fake(); diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 9080fc1ab0..b4e897374a 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -162,12 +162,34 @@ impl AgentConnection for AcpConnection { let conn = self.connection.clone(); let sessions = self.sessions.clone(); let cwd = cwd.to_path_buf(); + let context_server_store = project.read(cx).context_server_store().read(cx); + let mcp_servers = context_server_store + .configured_server_ids() + .iter() + .filter_map(|id| { + let configuration = context_server_store.configuration_for_server(id)?; + let command = configuration.command(); + Some(acp::McpServer { + name: id.0.to_string(), + command: command.path.clone(), + args: command.args.clone(), + env: if let Some(env) = command.env.as_ref() { + env.iter() + .map(|(name, value)| acp::EnvVariable { + name: name.clone(), + value: value.clone(), + }) + .collect() + } else { + vec![] + }, + }) + }) + .collect(); + cx.spawn(async move |cx| { let response = conn - .new_session(acp::NewSessionRequest { - mcp_servers: vec![], - cwd, - }) + .new_session(acp::NewSessionRequest { mcp_servers, cwd }) .await .map_err(|err| { if err.code == acp::ErrorCode::AUTH_REQUIRED.code { From 28b0b4c21645cefa74f4ccff48acb60143937ad8 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Tue, 26 Aug 2025 11:20:33 +0200 Subject: [PATCH 82/89] acp: Add button to configure custom agent in the configuration view (#36923) Release Notes: - N/A --- crates/agent_ui/src/agent_configuration.rs | 107 ++++++++++++++++++++- 1 file changed, 102 insertions(+), 5 deletions(-) diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 3fa618dad8..b4a55debbb 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -5,18 +5,21 @@ mod tool_picker; use std::{sync::Arc, time::Duration}; -use agent_servers::{AgentServerCommand, AllAgentServersSettings, Gemini}; +use agent_servers::{AgentServerCommand, AgentServerSettings, AllAgentServersSettings, Gemini}; use agent_settings::AgentSettings; +use anyhow::Result; use assistant_tool::{ToolSource, ToolWorkingSet}; use cloud_llm_client::Plan; use collections::HashMap; use context_server::ContextServerId; +use editor::{Editor, SelectionEffects, scroll::Autoscroll}; use extension::ExtensionManifest; use extension_host::ExtensionStore; use fs::Fs; use gpui::{ - Action, Animation, AnimationExt as _, AnyView, App, Corner, Entity, EventEmitter, FocusHandle, - Focusable, Hsla, ScrollHandle, Subscription, Task, Transformation, WeakEntity, percentage, + Action, Animation, AnimationExt as _, AnyView, App, AsyncWindowContext, Corner, Entity, + EventEmitter, FocusHandle, Focusable, Hsla, ScrollHandle, Subscription, Task, Transformation, + WeakEntity, percentage, }; use language::LanguageRegistry; use language_model::{ @@ -34,7 +37,7 @@ use ui::{ Scrollbar, ScrollbarState, Switch, SwitchColor, SwitchField, Tooltip, prelude::*, }; use util::ResultExt as _; -use workspace::Workspace; +use workspace::{Workspace, create_and_open_local_file}; use zed_actions::ExtensionCategoryFilter; pub(crate) use configure_context_server_modal::ConfigureContextServerModal; @@ -1058,7 +1061,36 @@ impl AgentConfiguration { .child( v_flex() .gap_0p5() - .child(Headline::new("External Agents")) + .child( + h_flex() + .w_full() + .gap_2() + .justify_between() + .child(Headline::new("External Agents")) + .child( + Button::new("add-agent", "Add Agent") + .icon_position(IconPosition::Start) + .icon(IconName::Plus) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .label_size(LabelSize::Small) + .on_click( + move |_, window, cx| { + if let Some(workspace) = window.root().flatten() { + let workspace = workspace.downgrade(); + window + .spawn(cx, async |cx| { + open_new_agent_servers_entry_in_settings_editor( + workspace, + cx, + ).await + }) + .detach_and_log_err(cx); + } + } + ), + ) + ) .child( Label::new( "Use the full power of Zed's UI with your favorite agent, connected via the Agent Client Protocol.", @@ -1324,3 +1356,68 @@ fn show_unable_to_uninstall_extension_with_context_server( workspace.toggle_status_toast(status_toast, cx); } + +async fn open_new_agent_servers_entry_in_settings_editor( + workspace: WeakEntity, + cx: &mut AsyncWindowContext, +) -> Result<()> { + let settings_editor = workspace + .update_in(cx, |_, window, cx| { + create_and_open_local_file(paths::settings_file(), window, cx, || { + settings::initial_user_settings_content().as_ref().into() + }) + })? + .await? + .downcast::() + .unwrap(); + + settings_editor + .downgrade() + .update_in(cx, |item, window, cx| { + let text = item.buffer().read(cx).snapshot(cx).text(); + + let settings = cx.global::(); + + let edits = settings.edits_for_update::(&text, |file| { + let unique_server_name = (0..u8::MAX) + .map(|i| { + if i == 0 { + "your_agent".into() + } else { + format!("your_agent_{}", i).into() + } + }) + .find(|name| !file.custom.contains_key(name)); + if let Some(server_name) = unique_server_name { + file.custom.insert( + server_name, + AgentServerSettings { + command: AgentServerCommand { + path: "path_to_executable".into(), + args: vec![], + env: Some(HashMap::default()), + }, + }, + ); + } + }); + + if !edits.is_empty() { + let ranges = edits + .iter() + .map(|(range, _)| range.clone()) + .collect::>(); + + item.edit(edits, cx); + + item.change_selections( + SelectionEffects::scroll(Autoscroll::newest()), + window, + cx, + |selections| { + selections.select_ranges(ranges); + }, + ); + } + }) +} From dabad05ecdd7a49930da2328c1332646a4723b69 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 26 Aug 2025 02:46:29 -0700 Subject: [PATCH 83/89] agent2: Always finalize diffs from the edit tool (#36918) Previously, we wouldn't finalize the diff if an error occurred during editing or the tool call was canceled. Release Notes: - N/A --------- Co-authored-by: Antonio Scandurra --- crates/agent2/src/thread.rs | 24 +++++ crates/agent2/src/tools/edit_file_tool.rs | 103 ++++++++++++++++++++- crates/language_model/src/fake_provider.rs | 31 ++++++- 3 files changed, 152 insertions(+), 6 deletions(-) diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 1b1c014b79..4acd72f275 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -2459,6 +2459,30 @@ impl ToolCallEventStreamReceiver { } } + pub async fn expect_update_fields(&mut self) -> acp::ToolCallUpdateFields { + let event = self.0.next().await; + if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields( + update, + )))) = event + { + update.fields + } else { + panic!("Expected update fields but got: {:?}", event); + } + } + + pub async fn expect_diff(&mut self) -> Entity { + let event = self.0.next().await; + if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateDiff( + update, + )))) = event + { + update.diff + } else { + panic!("Expected diff but got: {:?}", event); + } + } + pub async fn expect_terminal(&mut self) -> Entity { let event = self.0.next().await; if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateTerminal( diff --git a/crates/agent2/src/tools/edit_file_tool.rs b/crates/agent2/src/tools/edit_file_tool.rs index 5a68d0c70a..f86bfd25f7 100644 --- a/crates/agent2/src/tools/edit_file_tool.rs +++ b/crates/agent2/src/tools/edit_file_tool.rs @@ -273,6 +273,13 @@ impl AgentTool for EditFileTool { let diff = cx.new(|cx| Diff::new(buffer.clone(), cx))?; event_stream.update_diff(diff.clone()); + let _finalize_diff = util::defer({ + let diff = diff.downgrade(); + let mut cx = cx.clone(); + move || { + diff.update(&mut cx, |diff, cx| diff.finalize(cx)).ok(); + } + }); let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; let old_text = cx @@ -389,8 +396,6 @@ impl AgentTool for EditFileTool { }) .await; - diff.update(cx, |diff, cx| diff.finalize(cx)).ok(); - let input_path = input.path.display(); if unified_diff.is_empty() { anyhow::ensure!( @@ -1545,6 +1550,100 @@ mod tests { ); } + #[gpui::test] + async fn test_diff_finalization(cx: &mut TestAppContext) { + init_test(cx); + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree("/", json!({"main.rs": ""})).await; + + let project = Project::test(fs.clone(), [path!("/").as_ref()], cx).await; + let languages = project.read_with(cx, |project, _cx| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry.clone(), + Templates::new(), + Some(model.clone()), + cx, + ) + }); + + // Ensure the diff is finalized after the edit completes. + { + let tool = Arc::new(EditFileTool::new(thread.downgrade(), languages.clone())); + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let edit = cx.update(|cx| { + tool.run( + EditFileToolInput { + display_description: "Edit file".into(), + path: path!("/main.rs").into(), + mode: EditFileMode::Edit, + }, + stream_tx, + cx, + ) + }); + stream_rx.expect_update_fields().await; + let diff = stream_rx.expect_diff().await; + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_)))); + cx.run_until_parked(); + model.end_last_completion_stream(); + edit.await.unwrap(); + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_)))); + } + + // Ensure the diff is finalized if an error occurs while editing. + { + model.forbid_requests(); + let tool = Arc::new(EditFileTool::new(thread.downgrade(), languages.clone())); + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let edit = cx.update(|cx| { + tool.run( + EditFileToolInput { + display_description: "Edit file".into(), + path: path!("/main.rs").into(), + mode: EditFileMode::Edit, + }, + stream_tx, + cx, + ) + }); + stream_rx.expect_update_fields().await; + let diff = stream_rx.expect_diff().await; + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_)))); + edit.await.unwrap_err(); + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_)))); + model.allow_requests(); + } + + // Ensure the diff is finalized if the tool call gets dropped. + { + let tool = Arc::new(EditFileTool::new(thread.downgrade(), languages.clone())); + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let edit = cx.update(|cx| { + tool.run( + EditFileToolInput { + display_description: "Edit file".into(), + path: path!("/main.rs").into(), + mode: EditFileMode::Edit, + }, + stream_tx, + cx, + ) + }); + stream_rx.expect_update_fields().await; + let diff = stream_rx.expect_diff().await; + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_)))); + drop(edit); + cx.run_until_parked(); + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_)))); + } + } + fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); diff --git a/crates/language_model/src/fake_provider.rs b/crates/language_model/src/fake_provider.rs index ebfd37d16c..b06a475f93 100644 --- a/crates/language_model/src/fake_provider.rs +++ b/crates/language_model/src/fake_provider.rs @@ -4,12 +4,16 @@ use crate::{ LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, }; +use anyhow::anyhow; use futures::{FutureExt, channel::mpsc, future::BoxFuture, stream::BoxStream}; use gpui::{AnyView, App, AsyncApp, Entity, Task, Window}; use http_client::Result; use parking_lot::Mutex; use smol::stream::StreamExt; -use std::sync::Arc; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering::SeqCst}, +}; #[derive(Clone)] pub struct FakeLanguageModelProvider { @@ -106,6 +110,7 @@ pub struct FakeLanguageModel { >, )>, >, + forbid_requests: AtomicBool, } impl Default for FakeLanguageModel { @@ -114,11 +119,20 @@ impl Default for FakeLanguageModel { provider_id: LanguageModelProviderId::from("fake".to_string()), provider_name: LanguageModelProviderName::from("Fake".to_string()), current_completion_txs: Mutex::new(Vec::new()), + forbid_requests: AtomicBool::new(false), } } } impl FakeLanguageModel { + pub fn allow_requests(&self) { + self.forbid_requests.store(false, SeqCst); + } + + pub fn forbid_requests(&self) { + self.forbid_requests.store(true, SeqCst); + } + pub fn pending_completions(&self) -> Vec { self.current_completion_txs .lock() @@ -251,9 +265,18 @@ impl LanguageModel for FakeLanguageModel { LanguageModelCompletionError, >, > { - let (tx, rx) = mpsc::unbounded(); - self.current_completion_txs.lock().push((request, tx)); - async move { Ok(rx.boxed()) }.boxed() + if self.forbid_requests.load(SeqCst) { + async move { + Err(LanguageModelCompletionError::Other(anyhow!( + "requests are forbidden" + ))) + } + .boxed() + } else { + let (tx, rx) = mpsc::unbounded(); + self.current_completion_txs.lock().push((request, tx)); + async move { Ok(rx.boxed()) }.boxed() + } } fn as_fake(&self) -> &Self { From ba07eb2d5f458671bb041ae6ea0bc33d18ef34d9 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Tue, 26 Aug 2025 12:55:45 +0200 Subject: [PATCH 84/89] acp: Polish UI (#36927) Release Notes: - N/A --------- Co-authored-by: Antonio Scandurra --- crates/agent2/src/tests/mod.rs | 2 +- crates/agent2/src/thread.rs | 14 ++++++++++++-- crates/agent_ui/src/acp/thread_view.rs | 7 ++++--- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index 78e5c88280..a55eaacee3 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -472,7 +472,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { tool_name: ToolRequiringPermission::name().into(), is_error: true, content: "Permission to run tool denied by user".into(), - output: None + output: Some("Permission to run tool denied by user".into()) }) ] ); diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 4acd72f275..97ea1caf1d 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -732,7 +732,17 @@ impl Thread { stream.update_tool_call_fields( &tool_use.id, acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::Completed), + status: Some( + tool_result + .as_ref() + .map_or(acp::ToolCallStatus::Failed, |result| { + if result.is_error { + acp::ToolCallStatus::Failed + } else { + acp::ToolCallStatus::Completed + } + }), + ), raw_output: output, ..Default::default() }, @@ -1557,7 +1567,7 @@ impl Thread { tool_name: tool_use.name, is_error: true, content: LanguageModelToolResultContent::Text(Arc::from(error.to_string())), - output: None, + output: Some(error.to_string().into()), }, } })) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 837ce6f90a..6d8f8fb82e 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -1684,7 +1684,7 @@ impl AcpThreadView { div() .relative() .mt_1p5() - .ml(px(7.)) + .ml(rems(0.4)) .pl_4() .border_l_1() .border_color(self.tool_card_border_color(cx)) @@ -1850,6 +1850,7 @@ impl AcpThreadView { .w_full() .h(window.line_height() - px(2.)) .text_size(self.tool_name_font_size()) + .gap_0p5() .child(tool_icon) .child(if tool_call.locations.len() == 1 { let name = tool_call.locations[0] @@ -1968,7 +1969,7 @@ impl AcpThreadView { v_flex() .mt_1p5() - .ml(px(7.)) + .ml(rems(0.4)) .px_3p5() .gap_2() .border_l_1() @@ -2025,7 +2026,7 @@ impl AcpThreadView { let button_id = SharedString::from(format!("item-{}", uri)); div() - .ml(px(7.)) + .ml(rems(0.4)) .pl_2p5() .border_l_1() .border_color(self.tool_card_border_color(cx)) From 6c8180544c452da64eb20e81f9e3e15cd77cf520 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Tue, 26 Aug 2025 13:18:50 +0200 Subject: [PATCH 85/89] acp: Improve matching logic when adding new entry to agent_servers (#36926) Release Notes: - N/A --------- Co-authored-by: Antonio Scandurra --- crates/agent_ui/src/agent_configuration.rs | 75 +++++++++++++++++----- 1 file changed, 58 insertions(+), 17 deletions(-) diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index b4a55debbb..8cc680b377 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -3,7 +3,7 @@ mod configure_context_server_modal; mod manage_profiles_modal; mod tool_picker; -use std::{sync::Arc, time::Duration}; +use std::{ops::Range, sync::Arc, time::Duration}; use agent_servers::{AgentServerCommand, AgentServerSettings, AllAgentServersSettings, Gemini}; use agent_settings::AgentSettings; @@ -1378,8 +1378,9 @@ async fn open_new_agent_servers_entry_in_settings_editor( let settings = cx.global::(); + let mut unique_server_name = None; let edits = settings.edits_for_update::(&text, |file| { - let unique_server_name = (0..u8::MAX) + let server_name: Option = (0..u8::MAX) .map(|i| { if i == 0 { "your_agent".into() @@ -1388,7 +1389,8 @@ async fn open_new_agent_servers_entry_in_settings_editor( } }) .find(|name| !file.custom.contains_key(name)); - if let Some(server_name) = unique_server_name { + if let Some(server_name) = server_name { + unique_server_name = Some(server_name.clone()); file.custom.insert( server_name, AgentServerSettings { @@ -1402,22 +1404,61 @@ async fn open_new_agent_servers_entry_in_settings_editor( } }); - if !edits.is_empty() { - let ranges = edits - .iter() - .map(|(range, _)| range.clone()) - .collect::>(); + if edits.is_empty() { + return; + } - item.edit(edits, cx); + let ranges = edits + .iter() + .map(|(range, _)| range.clone()) + .collect::>(); - item.change_selections( - SelectionEffects::scroll(Autoscroll::newest()), - window, - cx, - |selections| { - selections.select_ranges(ranges); - }, - ); + item.edit(edits, cx); + if let Some((unique_server_name, buffer)) = + unique_server_name.zip(item.buffer().read(cx).as_singleton()) + { + let snapshot = buffer.read(cx).snapshot(); + if let Some(range) = + find_text_in_buffer(&unique_server_name, ranges[0].start, &snapshot) + { + item.change_selections( + SelectionEffects::scroll(Autoscroll::newest()), + window, + cx, + |selections| { + selections.select_ranges(vec![range]); + }, + ); + } } }) } + +fn find_text_in_buffer( + text: &str, + start: usize, + snapshot: &language::BufferSnapshot, +) -> Option> { + let chars = text.chars().collect::>(); + + let mut offset = start; + let mut char_offset = 0; + for c in snapshot.chars_at(start) { + if char_offset >= chars.len() { + break; + } + offset += 1; + + if c == chars[char_offset] { + char_offset += 1; + } else { + char_offset = 0; + } + } + + if char_offset == chars.len() { + Some(offset.saturating_sub(chars.len())..offset) + } else { + None + } +} From 1f35c625779d608be23c989a787a3a71e95bd6bf Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Tue, 26 Aug 2025 15:55:09 +0200 Subject: [PATCH 86/89] Revert "ai: Auto select user model when there's no default" (#36932) Reverts zed-industries/zed#36722 Release Notes: - N/A --- crates/agent/src/thread.rs | 17 +-- crates/agent2/src/agent.rs | 4 +- crates/agent2/src/tests/mod.rs | 4 +- .../agent_ui/src/language_model_selector.rs | 55 ++++++++- crates/git_ui/src/git_panel.rs | 2 +- crates/language_model/src/registry.rs | 114 ++++++++---------- crates/language_models/Cargo.toml | 1 - crates/language_models/src/language_models.rs | 103 +--------------- crates/language_models/src/provider/cloud.rs | 6 +- 9 files changed, 122 insertions(+), 184 deletions(-) diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 899e360ab0..7b70fde56a 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -664,7 +664,7 @@ impl Thread { } pub fn get_or_init_configured_model(&mut self, cx: &App) -> Option { - if self.configured_model.is_none() || self.messages.is_empty() { + if self.configured_model.is_none() { self.configured_model = LanguageModelRegistry::read_global(cx).default_model(); } self.configured_model.clone() @@ -2097,7 +2097,7 @@ impl Thread { } pub fn summarize(&mut self, cx: &mut Context) { - let Some(model) = LanguageModelRegistry::read_global(cx).thread_summary_model(cx) else { + let Some(model) = LanguageModelRegistry::read_global(cx).thread_summary_model() else { println!("No thread summary model"); return; }; @@ -2416,7 +2416,7 @@ impl Thread { } let Some(ConfiguredModel { model, provider }) = - LanguageModelRegistry::read_global(cx).thread_summary_model(cx) + LanguageModelRegistry::read_global(cx).thread_summary_model() else { return; }; @@ -5410,10 +5410,13 @@ fn main() {{ }), cx, ); - registry.set_thread_summary_model(Some(ConfiguredModel { - provider, - model: model.clone(), - })); + registry.set_thread_summary_model( + Some(ConfiguredModel { + provider, + model: model.clone(), + }), + cx, + ); }) }); diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index ecfaea4b49..6fa36d33d5 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -228,7 +228,7 @@ impl NativeAgent { ) -> Entity { let connection = Rc::new(NativeAgentConnection(cx.entity())); let registry = LanguageModelRegistry::read_global(cx); - let summarization_model = registry.thread_summary_model(cx).map(|c| c.model); + let summarization_model = registry.thread_summary_model().map(|c| c.model); thread_handle.update(cx, |thread, cx| { thread.set_summarization_model(summarization_model, cx); @@ -524,7 +524,7 @@ impl NativeAgent { let registry = LanguageModelRegistry::read_global(cx); let default_model = registry.default_model().map(|m| m.model); - let summarization_model = registry.thread_summary_model(cx).map(|m| m.model); + let summarization_model = registry.thread_summary_model().map(|m| m.model); for session in self.sessions.values_mut() { session.thread.update(cx, |thread, cx| { diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index a55eaacee3..fbeee46a48 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -1822,11 +1822,11 @@ async fn test_agent_connection(cx: &mut TestAppContext) { let clock = Arc::new(clock::FakeSystemClock::new()); let client = Client::new(clock, http_client, cx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - Project::init_settings(cx); - agent_settings::init(cx); language_model::init(client.clone(), cx); language_models::init(user_store, client.clone(), cx); + Project::init_settings(cx); LanguageModelRegistry::test(cx); + agent_settings::init(cx); }); cx.executor().forbid_parking(); diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs index aceca79dbf..3633e533da 100644 --- a/crates/agent_ui/src/language_model_selector.rs +++ b/crates/agent_ui/src/language_model_selector.rs @@ -6,7 +6,8 @@ use feature_flags::ZedProFeatureFlag; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{Action, AnyElement, App, BackgroundExecutor, DismissEvent, Subscription, Task}; use language_model::{ - ConfiguredModel, LanguageModel, LanguageModelProviderId, LanguageModelRegistry, + AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelProviderId, + LanguageModelRegistry, }; use ordered_float::OrderedFloat; use picker::{Picker, PickerDelegate}; @@ -76,6 +77,7 @@ pub struct LanguageModelPickerDelegate { all_models: Arc, filtered_entries: Vec, selected_index: usize, + _authenticate_all_providers_task: Task<()>, _subscriptions: Vec, } @@ -96,6 +98,7 @@ impl LanguageModelPickerDelegate { selected_index: Self::get_active_model_index(&entries, get_active_model(cx)), filtered_entries: entries, get_active_model: Arc::new(get_active_model), + _authenticate_all_providers_task: Self::authenticate_all_providers(cx), _subscriptions: vec![cx.subscribe_in( &LanguageModelRegistry::global(cx), window, @@ -139,6 +142,56 @@ impl LanguageModelPickerDelegate { .unwrap_or(0) } + /// Authenticates all providers in the [`LanguageModelRegistry`]. + /// + /// We do this so that we can populate the language selector with all of the + /// models from the configured providers. + fn authenticate_all_providers(cx: &mut App) -> Task<()> { + let authenticate_all_providers = LanguageModelRegistry::global(cx) + .read(cx) + .providers() + .iter() + .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx))) + .collect::>(); + + cx.spawn(async move |_cx| { + for (provider_id, provider_name, authenticate_task) in authenticate_all_providers { + if let Err(err) = authenticate_task.await { + if matches!(err, AuthenticateError::CredentialsNotFound) { + // Since we're authenticating these providers in the + // background for the purposes of populating the + // language selector, we don't care about providers + // where the credentials are not found. + } else { + // Some providers have noisy failure states that we + // don't want to spam the logs with every time the + // language model selector is initialized. + // + // Ideally these should have more clear failure modes + // that we know are safe to ignore here, like what we do + // with `CredentialsNotFound` above. + match provider_id.0.as_ref() { + "lmstudio" | "ollama" => { + // LM Studio and Ollama both make fetch requests to the local APIs to determine if they are "authenticated". + // + // These fail noisily, so we don't log them. + } + "copilot_chat" => { + // Copilot Chat returns an error if Copilot is not enabled, so we don't log those errors. + } + _ => { + log::error!( + "Failed to authenticate provider: {}: {err}", + provider_name.0 + ); + } + } + } + } + } + }) + } + pub fn active_model(&self, cx: &App) -> Option { (self.get_active_model)(cx) } diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 958a609a09..4ecb4a8829 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -4466,7 +4466,7 @@ fn current_language_model(cx: &Context<'_, GitPanel>) -> Option, - /// This model is automatically configured by a user's environment after - /// authenticating all providers. It's only used when default_model is not available. - environment_fallback_model: Option, + default_fast_model: Option, inline_assistant_model: Option, commit_message_model: Option, thread_summary_model: Option, @@ -99,6 +98,9 @@ impl ConfiguredModel { pub enum Event { DefaultModelChanged, + InlineAssistantModelChanged, + CommitMessageModelChanged, + ThreadSummaryModelChanged, ProviderStateChanged(LanguageModelProviderId), AddedProvider(LanguageModelProviderId), RemovedProvider(LanguageModelProviderId), @@ -224,7 +226,7 @@ impl LanguageModelRegistry { cx: &mut Context, ) { let configured_model = model.and_then(|model| self.select_model(model, cx)); - self.set_inline_assistant_model(configured_model); + self.set_inline_assistant_model(configured_model, cx); } pub fn select_commit_message_model( @@ -233,7 +235,7 @@ impl LanguageModelRegistry { cx: &mut Context, ) { let configured_model = model.and_then(|model| self.select_model(model, cx)); - self.set_commit_message_model(configured_model); + self.set_commit_message_model(configured_model, cx); } pub fn select_thread_summary_model( @@ -242,7 +244,7 @@ impl LanguageModelRegistry { cx: &mut Context, ) { let configured_model = model.and_then(|model| self.select_model(model, cx)); - self.set_thread_summary_model(configured_model); + self.set_thread_summary_model(configured_model, cx); } /// Selects and sets the inline alternatives for language models based on @@ -276,60 +278,68 @@ impl LanguageModelRegistry { } pub fn set_default_model(&mut self, model: Option, cx: &mut Context) { - match (self.default_model(), model.as_ref()) { + match (self.default_model.as_ref(), model.as_ref()) { (Some(old), Some(new)) if old.is_same_as(new) => {} (None, None) => {} _ => cx.emit(Event::DefaultModelChanged), } + self.default_fast_model = maybe!({ + let provider = &model.as_ref()?.provider; + let fast_model = provider.default_fast_model(cx)?; + Some(ConfiguredModel { + provider: provider.clone(), + model: fast_model, + }) + }); self.default_model = model; } - pub fn set_environment_fallback_model( + pub fn set_inline_assistant_model( &mut self, model: Option, cx: &mut Context, ) { - if self.default_model.is_none() { - match (self.environment_fallback_model.as_ref(), model.as_ref()) { - (Some(old), Some(new)) if old.is_same_as(new) => {} - (None, None) => {} - _ => cx.emit(Event::DefaultModelChanged), - } + match (self.inline_assistant_model.as_ref(), model.as_ref()) { + (Some(old), Some(new)) if old.is_same_as(new) => {} + (None, None) => {} + _ => cx.emit(Event::InlineAssistantModelChanged), } - self.environment_fallback_model = model; - } - - pub fn set_inline_assistant_model(&mut self, model: Option) { self.inline_assistant_model = model; } - pub fn set_commit_message_model(&mut self, model: Option) { + pub fn set_commit_message_model( + &mut self, + model: Option, + cx: &mut Context, + ) { + match (self.commit_message_model.as_ref(), model.as_ref()) { + (Some(old), Some(new)) if old.is_same_as(new) => {} + (None, None) => {} + _ => cx.emit(Event::CommitMessageModelChanged), + } self.commit_message_model = model; } - pub fn set_thread_summary_model(&mut self, model: Option) { + pub fn set_thread_summary_model( + &mut self, + model: Option, + cx: &mut Context, + ) { + match (self.thread_summary_model.as_ref(), model.as_ref()) { + (Some(old), Some(new)) if old.is_same_as(new) => {} + (None, None) => {} + _ => cx.emit(Event::ThreadSummaryModelChanged), + } self.thread_summary_model = model; } - #[track_caller] pub fn default_model(&self) -> Option { #[cfg(debug_assertions)] if std::env::var("ZED_SIMULATE_NO_LLM_PROVIDER").is_ok() { return None; } - self.default_model - .clone() - .or_else(|| self.environment_fallback_model.clone()) - } - - pub fn default_fast_model(&self, cx: &App) -> Option { - let provider = self.default_model()?.provider; - let fast_model = provider.default_fast_model(cx)?; - Some(ConfiguredModel { - provider, - model: fast_model, - }) + self.default_model.clone() } pub fn inline_assistant_model(&self) -> Option { @@ -343,7 +353,7 @@ impl LanguageModelRegistry { .or_else(|| self.default_model.clone()) } - pub fn commit_message_model(&self, cx: &App) -> Option { + pub fn commit_message_model(&self) -> Option { #[cfg(debug_assertions)] if std::env::var("ZED_SIMULATE_NO_LLM_PROVIDER").is_ok() { return None; @@ -351,11 +361,11 @@ impl LanguageModelRegistry { self.commit_message_model .clone() - .or_else(|| self.default_fast_model(cx)) + .or_else(|| self.default_fast_model.clone()) .or_else(|| self.default_model.clone()) } - pub fn thread_summary_model(&self, cx: &App) -> Option { + pub fn thread_summary_model(&self) -> Option { #[cfg(debug_assertions)] if std::env::var("ZED_SIMULATE_NO_LLM_PROVIDER").is_ok() { return None; @@ -363,7 +373,7 @@ impl LanguageModelRegistry { self.thread_summary_model .clone() - .or_else(|| self.default_fast_model(cx)) + .or_else(|| self.default_fast_model.clone()) .or_else(|| self.default_model.clone()) } @@ -400,34 +410,4 @@ mod tests { let providers = registry.read(cx).providers(); assert!(providers.is_empty()); } - - #[gpui::test] - async fn test_configure_environment_fallback_model(cx: &mut gpui::TestAppContext) { - let registry = cx.new(|_| LanguageModelRegistry::default()); - - let provider = FakeLanguageModelProvider::default(); - registry.update(cx, |registry, cx| { - registry.register_provider(provider.clone(), cx); - }); - - cx.update(|cx| provider.authenticate(cx)).await.unwrap(); - - registry.update(cx, |registry, cx| { - let provider = registry.provider(&provider.id()).unwrap(); - - registry.set_environment_fallback_model( - Some(ConfiguredModel { - provider: provider.clone(), - model: provider.default_model(cx).unwrap(), - }), - cx, - ); - - let default_model = registry.default_model().unwrap(); - let fallback_model = registry.environment_fallback_model.clone().unwrap(); - - assert_eq!(default_model.model.id(), fallback_model.model.id()); - assert_eq!(default_model.provider.id(), fallback_model.provider.id()); - }); - } } diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index cd41478668..b5bfb870f6 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -44,7 +44,6 @@ ollama = { workspace = true, features = ["schemars"] } open_ai = { workspace = true, features = ["schemars"] } open_router = { workspace = true, features = ["schemars"] } partial-json-fixer.workspace = true -project.workspace = true release_channel.workspace = true schemars.workspace = true serde.workspace = true diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index beed306e74..738b72b0c9 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -3,12 +3,8 @@ use std::sync::Arc; use ::settings::{Settings, SettingsStore}; use client::{Client, UserStore}; use collections::HashSet; -use futures::future; -use gpui::{App, AppContext as _, Context, Entity}; -use language_model::{ - AuthenticateError, ConfiguredModel, LanguageModelProviderId, LanguageModelRegistry, -}; -use project::DisableAiSettings; +use gpui::{App, Context, Entity}; +use language_model::{LanguageModelProviderId, LanguageModelRegistry}; use provider::deepseek::DeepSeekLanguageModelProvider; pub mod provider; @@ -17,7 +13,7 @@ pub mod ui; use crate::provider::anthropic::AnthropicLanguageModelProvider; use crate::provider::bedrock::BedrockLanguageModelProvider; -use crate::provider::cloud::{self, CloudLanguageModelProvider}; +use crate::provider::cloud::CloudLanguageModelProvider; use crate::provider::copilot_chat::CopilotChatLanguageModelProvider; use crate::provider::google::GoogleLanguageModelProvider; use crate::provider::lmstudio::LmStudioLanguageModelProvider; @@ -52,13 +48,6 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { cx, ); }); - - let mut already_authenticated = false; - if !DisableAiSettings::get_global(cx).disable_ai { - authenticate_all_providers(registry.clone(), cx); - already_authenticated = true; - } - cx.observe_global::(move |cx| { let openai_compatible_providers_new = AllLanguageModelSettings::get_global(cx) .openai_compatible @@ -76,12 +65,6 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { ); }); openai_compatible_providers = openai_compatible_providers_new; - already_authenticated = false; - } - - if !DisableAiSettings::get_global(cx).disable_ai && !already_authenticated { - authenticate_all_providers(registry.clone(), cx); - already_authenticated = true; } }) .detach(); @@ -168,83 +151,3 @@ fn register_language_model_providers( registry.register_provider(XAiLanguageModelProvider::new(client.http_client(), cx), cx); registry.register_provider(CopilotChatLanguageModelProvider::new(cx), cx); } - -/// Authenticates all providers in the [`LanguageModelRegistry`]. -/// -/// We do this so that we can populate the language selector with all of the -/// models from the configured providers. -/// -/// This function won't do anything if AI is disabled. -fn authenticate_all_providers(registry: Entity, cx: &mut App) { - let providers_to_authenticate = registry - .read(cx) - .providers() - .iter() - .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx))) - .collect::>(); - - let mut tasks = Vec::with_capacity(providers_to_authenticate.len()); - - for (provider_id, provider_name, authenticate_task) in providers_to_authenticate { - tasks.push(cx.background_spawn(async move { - if let Err(err) = authenticate_task.await { - if matches!(err, AuthenticateError::CredentialsNotFound) { - // Since we're authenticating these providers in the - // background for the purposes of populating the - // language selector, we don't care about providers - // where the credentials are not found. - } else { - // Some providers have noisy failure states that we - // don't want to spam the logs with every time the - // language model selector is initialized. - // - // Ideally these should have more clear failure modes - // that we know are safe to ignore here, like what we do - // with `CredentialsNotFound` above. - match provider_id.0.as_ref() { - "lmstudio" | "ollama" => { - // LM Studio and Ollama both make fetch requests to the local APIs to determine if they are "authenticated". - // - // These fail noisily, so we don't log them. - } - "copilot_chat" => { - // Copilot Chat returns an error if Copilot is not enabled, so we don't log those errors. - } - _ => { - log::error!( - "Failed to authenticate provider: {}: {err}", - provider_name.0 - ); - } - } - } - } - })); - } - - let all_authenticated_future = future::join_all(tasks); - - cx.spawn(async move |cx| { - all_authenticated_future.await; - - registry - .update(cx, |registry, cx| { - let cloud_provider = registry.provider(&cloud::PROVIDER_ID); - let fallback_model = cloud_provider - .iter() - .chain(registry.providers().iter()) - .find(|provider| provider.is_authenticated(cx)) - .and_then(|provider| { - Some(ConfiguredModel { - provider: provider.clone(), - model: provider - .default_model(cx) - .or_else(|| provider.recommended_models(cx).first().cloned())?, - }) - }); - registry.set_environment_fallback_model(fallback_model, cx); - }) - .ok(); - }) - .detach(); -} diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index fb6e2fb1e4..b473d06357 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -44,8 +44,8 @@ use crate::provider::anthropic::{AnthropicEventMapper, count_anthropic_tokens, i use crate::provider::google::{GoogleEventMapper, into_google}; use crate::provider::open_ai::{OpenAiEventMapper, count_open_ai_tokens, into_open_ai}; -pub const PROVIDER_ID: LanguageModelProviderId = language_model::ZED_CLOUD_PROVIDER_ID; -pub const PROVIDER_NAME: LanguageModelProviderName = language_model::ZED_CLOUD_PROVIDER_NAME; +const PROVIDER_ID: LanguageModelProviderId = language_model::ZED_CLOUD_PROVIDER_ID; +const PROVIDER_NAME: LanguageModelProviderName = language_model::ZED_CLOUD_PROVIDER_NAME; #[derive(Default, Clone, Debug, PartialEq)] pub struct ZedDotDevSettings { @@ -146,7 +146,7 @@ impl State { default_fast_model: None, recommended_models: Vec::new(), _fetch_models_task: cx.spawn(async move |this, cx| { - maybe!(async { + maybe!(async move { let (client, llm_api_token) = this .read_with(cx, |this, _cx| (client.clone(), this.llm_api_token.clone()))?; From ca70f091c219d3fcf654f857286283cc39245156 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 26 Aug 2025 12:55:40 -0300 Subject: [PATCH 87/89] thread view: Refine tool call UI (#36937) Release Notes: - N/A --------- Co-authored-by: Bennet Bo Fenner --- crates/agent_ui/src/acp/entry_view_state.rs | 58 ++- crates/agent_ui/src/acp/thread_view.rs | 467 +++++++++++--------- crates/markdown/src/markdown.rs | 2 +- 3 files changed, 315 insertions(+), 212 deletions(-) diff --git a/crates/agent_ui/src/acp/entry_view_state.rs b/crates/agent_ui/src/acp/entry_view_state.rs index 0e4080d689..becf6953fd 100644 --- a/crates/agent_ui/src/acp/entry_view_state.rs +++ b/crates/agent_ui/src/acp/entry_view_state.rs @@ -6,7 +6,7 @@ use agent2::HistoryStore; use collections::HashMap; use editor::{Editor, EditorMode, MinimapVisibility}; use gpui::{ - AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, Focusable, + AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, Focusable, ScrollHandle, TextStyleRefinement, WeakEntity, Window, }; use language::language_settings::SoftWrap; @@ -154,10 +154,22 @@ impl EntryViewState { }); } } - AgentThreadEntry::AssistantMessage(_) => { - if index == self.entries.len() { - self.entries.push(Entry::empty()) - } + AgentThreadEntry::AssistantMessage(message) => { + let entry = if let Some(Entry::AssistantMessage(entry)) = + self.entries.get_mut(index) + { + entry + } else { + self.set_entry( + index, + Entry::AssistantMessage(AssistantMessageEntry::default()), + ); + let Some(Entry::AssistantMessage(entry)) = self.entries.get_mut(index) else { + unreachable!() + }; + entry + }; + entry.sync(message); } }; } @@ -177,7 +189,7 @@ impl EntryViewState { pub fn settings_changed(&mut self, cx: &mut App) { for entry in self.entries.iter() { match entry { - Entry::UserMessage { .. } => {} + Entry::UserMessage { .. } | Entry::AssistantMessage { .. } => {} Entry::Content(response_views) => { for view in response_views.values() { if let Ok(diff_editor) = view.clone().downcast::() { @@ -208,9 +220,29 @@ pub enum ViewEvent { MessageEditorEvent(Entity, MessageEditorEvent), } +#[derive(Default, Debug)] +pub struct AssistantMessageEntry { + scroll_handles_by_chunk_index: HashMap, +} + +impl AssistantMessageEntry { + pub fn scroll_handle_for_chunk(&self, ix: usize) -> Option { + self.scroll_handles_by_chunk_index.get(&ix).cloned() + } + + pub fn sync(&mut self, message: &acp_thread::AssistantMessage) { + if let Some(acp_thread::AssistantMessageChunk::Thought { .. }) = message.chunks.last() { + let ix = message.chunks.len() - 1; + let handle = self.scroll_handles_by_chunk_index.entry(ix).or_default(); + handle.scroll_to_bottom(); + } + } +} + #[derive(Debug)] pub enum Entry { UserMessage(Entity), + AssistantMessage(AssistantMessageEntry), Content(HashMap), } @@ -218,7 +250,7 @@ impl Entry { pub fn message_editor(&self) -> Option<&Entity> { match self { Self::UserMessage(editor) => Some(editor), - Entry::Content(_) => None, + Self::AssistantMessage(_) | Self::Content(_) => None, } } @@ -239,6 +271,16 @@ impl Entry { .map(|entity| entity.downcast::().unwrap()) } + pub fn scroll_handle_for_assistant_message_chunk( + &self, + chunk_ix: usize, + ) -> Option { + match self { + Self::AssistantMessage(message) => message.scroll_handle_for_chunk(chunk_ix), + Self::UserMessage(_) | Self::Content(_) => None, + } + } + fn content_map(&self) -> Option<&HashMap> { match self { Self::Content(map) => Some(map), @@ -254,7 +296,7 @@ impl Entry { pub fn has_content(&self) -> bool { match self { Self::Content(map) => !map.is_empty(), - Self::UserMessage(_) => false, + Self::UserMessage(_) | Self::AssistantMessage(_) => false, } } } diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 6d8f8fb82e..f3b1e6ce3b 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -20,11 +20,11 @@ use file_icons::FileIcons; use fs::Fs; use gpui::{ Action, Animation, AnimationExt, AnyView, App, BorderStyle, ClickEvent, ClipboardItem, - EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length, ListOffset, - ListState, MouseButton, PlatformDisplay, SharedString, Stateful, StyleRefinement, Subscription, - Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, Window, - WindowHandle, div, ease_in_out, linear_color_stop, linear_gradient, list, percentage, point, - prelude::*, pulsating_between, + CursorStyle, EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length, + ListOffset, ListState, MouseButton, PlatformDisplay, SharedString, Stateful, StyleRefinement, + Subscription, Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, + Window, WindowHandle, div, ease_in_out, linear_color_stop, linear_gradient, list, percentage, + point, prelude::*, pulsating_between, }; use language::Buffer; @@ -66,7 +66,6 @@ use crate::{ KeepAll, OpenAgentDiff, OpenHistory, RejectAll, ToggleBurnMode, ToggleProfileSelector, }; -const RESPONSE_PADDING_X: Pixels = px(19.); pub const MIN_EDITOR_LINES: usize = 4; pub const MAX_EDITOR_LINES: usize = 8; @@ -1334,6 +1333,10 @@ impl AcpThreadView { window: &mut Window, cx: &Context, ) -> AnyElement { + let is_generating = self + .thread() + .is_some_and(|thread| thread.read(cx).status() != ThreadStatus::Idle); + let primary = match &entry { AgentThreadEntry::UserMessage(message) => { let Some(editor) = self @@ -1493,6 +1496,20 @@ impl AcpThreadView { .into_any() } AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => { + let is_last = entry_ix + 1 == total_entries; + let pending_thinking_chunk_ix = if is_generating && is_last { + chunks + .iter() + .enumerate() + .next_back() + .filter(|(_, segment)| { + matches!(segment, AssistantMessageChunk::Thought { .. }) + }) + .map(|(index, _)| index) + } else { + None + }; + let style = default_markdown_style(false, false, window, cx); let message_body = v_flex() .w_full() @@ -1511,6 +1528,7 @@ impl AcpThreadView { entry_ix, chunk_ix, md.clone(), + Some(chunk_ix) == pending_thinking_chunk_ix, window, cx, ) @@ -1524,7 +1542,7 @@ impl AcpThreadView { v_flex() .px_5() .py_1() - .when(entry_ix + 1 == total_entries, |this| this.pb_4()) + .when(is_last, |this| this.pb_4()) .w_full() .text_ui(cx) .child(message_body) @@ -1533,7 +1551,7 @@ impl AcpThreadView { AgentThreadEntry::ToolCall(tool_call) => { let has_terminals = tool_call.terminals().next().is_some(); - div().w_full().py_1().px_5().map(|this| { + div().w_full().map(|this| { if has_terminals { this.children(tool_call.terminals().map(|terminal| { self.render_terminal_tool_call( @@ -1609,64 +1627,90 @@ impl AcpThreadView { entry_ix: usize, chunk_ix: usize, chunk: Entity, + pending: bool, window: &Window, cx: &Context, ) -> AnyElement { let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix)); let card_header_id = SharedString::from("inner-card-header"); + let key = (entry_ix, chunk_ix); + let is_open = self.expanded_thinking_blocks.contains(&key); + let editor_bg = cx.theme().colors().editor_background; + let gradient_overlay = div() + .rounded_b_lg() + .h_full() + .absolute() + .w_full() + .bottom_0() + .left_0() + .bg(linear_gradient( + 180., + linear_color_stop(editor_bg, 1.), + linear_color_stop(editor_bg.opacity(0.2), 0.), + )); + + let scroll_handle = self + .entry_view_state + .read(cx) + .entry(entry_ix) + .and_then(|entry| entry.scroll_handle_for_assistant_message_chunk(chunk_ix)); v_flex() + .rounded_md() + .border_1() + .border_color(self.tool_card_border_color(cx)) .child( h_flex() .id(header_id) .group(&card_header_id) .relative() .w_full() - .gap_1p5() + .py_0p5() + .px_1p5() + .rounded_t_md() + .bg(self.tool_card_header_bg(cx)) + .justify_between() + .border_b_1() + .border_color(self.tool_card_border_color(cx)) .child( h_flex() - .size_4() - .justify_center() + .h(window.line_height()) + .gap_1p5() .child( - div() - .group_hover(&card_header_id, |s| s.invisible().w_0()) - .child( - Icon::new(IconName::ToolThink) - .size(IconSize::Small) - .color(Color::Muted), - ), + Icon::new(IconName::ToolThink) + .size(IconSize::Small) + .color(Color::Muted), ) .child( - h_flex() - .absolute() - .inset_0() - .invisible() - .justify_center() - .group_hover(&card_header_id, |s| s.visible()) - .child( - Disclosure::new(("expand", entry_ix), is_open) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronRight) - .on_click(cx.listener({ - move |this, _event, _window, cx| { - if is_open { - this.expanded_thinking_blocks.remove(&key); - } else { - this.expanded_thinking_blocks.insert(key); - } - cx.notify(); - } - })), - ), + div() + .text_size(self.tool_name_font_size()) + .text_color(cx.theme().colors().text_muted) + .map(|this| { + if pending { + this.child("Thinking") + } else { + this.child("Thought Process") + } + }), ), ) .child( - div() - .text_size(self.tool_name_font_size()) - .text_color(cx.theme().colors().text_muted) - .child("Thinking"), + Disclosure::new(("expand", entry_ix), is_open) + .opened_icon(IconName::ChevronUp) + .closed_icon(IconName::ChevronDown) + .visible_on_hover(&card_header_id) + .on_click(cx.listener({ + move |this, _event, _window, cx| { + if is_open { + this.expanded_thinking_blocks.remove(&key); + } else { + this.expanded_thinking_blocks.insert(key); + } + cx.notify(); + } + })), ) .on_click(cx.listener({ move |this, _event, _window, cx| { @@ -1679,22 +1723,28 @@ impl AcpThreadView { } })), ) - .when(is_open, |this| { - this.child( - div() - .relative() - .mt_1p5() - .ml(rems(0.4)) - .pl_4() - .border_l_1() - .border_color(self.tool_card_border_color(cx)) - .text_ui_sm(cx) - .child(self.render_markdown( - chunk, - default_markdown_style(false, false, window, cx), - )), - ) - }) + .child( + div() + .relative() + .bg(editor_bg) + .rounded_b_lg() + .child( + div() + .id(("thinking-content", chunk_ix)) + .when_some(scroll_handle, |this, scroll_handle| { + this.track_scroll(&scroll_handle) + }) + .p_2() + .when(!is_open, |this| this.max_h_20()) + .text_ui_sm(cx) + .overflow_hidden() + .child(self.render_markdown( + chunk, + default_markdown_style(false, false, window, cx), + )), + ) + .when(!is_open && pending, |this| this.child(gradient_overlay)), + ) .into_any_element() } @@ -1705,7 +1755,6 @@ impl AcpThreadView { window: &Window, cx: &Context, ) -> Div { - let header_id = SharedString::from(format!("outer-tool-call-header-{}", entry_ix)); let card_header_id = SharedString::from("inner-tool-call-header"); let tool_icon = @@ -1734,11 +1783,7 @@ impl AcpThreadView { _ => false, }; - let failed_tool_call = matches!( - tool_call.status, - ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed - ); - + let has_location = tool_call.locations.len() == 1; let needs_confirmation = matches!( tool_call.status, ToolCallStatus::WaitingForConfirmation { .. } @@ -1751,23 +1796,31 @@ impl AcpThreadView { let is_open = needs_confirmation || self.expanded_tool_calls.contains(&tool_call.id); - let gradient_overlay = |color: Hsla| { + let gradient_overlay = { div() .absolute() .top_0() .right_0() .w_12() .h_full() - .bg(linear_gradient( - 90., - linear_color_stop(color, 1.), - linear_color_stop(color.opacity(0.2), 0.), - )) - }; - let gradient_color = if use_card_layout { - self.tool_card_header_bg(cx) - } else { - cx.theme().colors().panel_background + .map(|this| { + if use_card_layout { + this.bg(linear_gradient( + 90., + linear_color_stop(self.tool_card_header_bg(cx), 1.), + linear_color_stop(self.tool_card_header_bg(cx).opacity(0.2), 0.), + )) + } else { + this.bg(linear_gradient( + 90., + linear_color_stop(cx.theme().colors().panel_background, 1.), + linear_color_stop( + cx.theme().colors().panel_background.opacity(0.2), + 0., + ), + )) + } + }) }; let tool_output_display = if is_open { @@ -1818,41 +1871,58 @@ impl AcpThreadView { }; v_flex() - .when(use_card_layout, |this| { - this.rounded_md() - .border_1() - .border_color(self.tool_card_border_color(cx)) - .bg(cx.theme().colors().editor_background) - .overflow_hidden() + .map(|this| { + if use_card_layout { + this.my_2() + .rounded_md() + .border_1() + .border_color(self.tool_card_border_color(cx)) + .bg(cx.theme().colors().editor_background) + .overflow_hidden() + } else { + this.my_1() + } }) + .map(|this| { + if has_location && !use_card_layout { + this.ml_4() + } else { + this.ml_5() + } + }) + .mr_5() .child( h_flex() - .id(header_id) .group(&card_header_id) .relative() .w_full() - .max_w_full() .gap_1() + .justify_between() .when(use_card_layout, |this| { - this.pl_1p5() - .pr_1() - .py_0p5() + this.p_0p5() .rounded_t_md() - .when(is_open && !failed_tool_call, |this| { + .bg(self.tool_card_header_bg(cx)) + .when(is_open && !failed_or_canceled, |this| { this.border_b_1() .border_color(self.tool_card_border_color(cx)) }) - .bg(self.tool_card_header_bg(cx)) }) .child( h_flex() .relative() .w_full() - .h(window.line_height() - px(2.)) + .h(window.line_height()) .text_size(self.tool_name_font_size()) - .gap_0p5() + .gap_1p5() + .when(has_location || use_card_layout, |this| this.px_1()) + .when(has_location, |this| { + this.cursor(CursorStyle::PointingHand) + .rounded_sm() + .hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.5))) + }) + .overflow_hidden() .child(tool_icon) - .child(if tool_call.locations.len() == 1 { + .child(if has_location { let name = tool_call.locations[0] .path .file_name() @@ -1863,13 +1933,6 @@ impl AcpThreadView { h_flex() .id(("open-tool-call-location", entry_ix)) .w_full() - .max_w_full() - .px_1p5() - .rounded_sm() - .overflow_x_scroll() - .hover(|label| { - label.bg(cx.theme().colors().element_hover.opacity(0.5)) - }) .map(|this| { if use_card_layout { this.text_color(cx.theme().colors().text) @@ -1879,31 +1942,28 @@ impl AcpThreadView { }) .child(name) .tooltip(Tooltip::text("Jump to File")) - .cursor(gpui::CursorStyle::PointingHand) .on_click(cx.listener(move |this, _, window, cx| { this.open_tool_call_location(entry_ix, 0, window, cx); })) .into_any_element() } else { h_flex() - .relative() .w_full() - .max_w_full() - .ml_1p5() - .overflow_hidden() - .child(h_flex().pr_8().child(self.render_markdown( + .child(self.render_markdown( tool_call.label.clone(), default_markdown_style(false, true, window, cx), - ))) - .child(gradient_overlay(gradient_color)) + )) .into_any() - }), + }) + .when(!has_location, |this| this.child(gradient_overlay)), ) - .child( - h_flex() - .gap_px() - .when(is_collapsible, |this| { - this.child( + .when(is_collapsible || failed_or_canceled, |this| { + this.child( + h_flex() + .px_1() + .gap_px() + .when(is_collapsible, |this| { + this.child( Disclosure::new(("expand", entry_ix), is_open) .opened_icon(IconName::ChevronUp) .closed_icon(IconName::ChevronDown) @@ -1920,15 +1980,16 @@ impl AcpThreadView { } })), ) - }) - .when(failed_or_canceled, |this| { - this.child( - Icon::new(IconName::Close) - .color(Color::Error) - .size(IconSize::Small), - ) - }), - ), + }) + .when(failed_or_canceled, |this| { + this.child( + Icon::new(IconName::Close) + .color(Color::Error) + .size(IconSize::Small), + ) + }), + ) + }), ) .children(tool_output_display) } @@ -2214,6 +2275,12 @@ impl AcpThreadView { started_at.elapsed() }; + let header_id = + SharedString::from(format!("terminal-tool-header-{}", terminal.entity_id())); + let header_group = SharedString::from(format!( + "terminal-tool-header-group-{}", + terminal.entity_id() + )); let header_bg = cx .theme() .colors() @@ -2229,10 +2296,7 @@ impl AcpThreadView { let is_expanded = self.expanded_tool_calls.contains(&tool_call.id); let header = h_flex() - .id(SharedString::from(format!( - "terminal-tool-header-{}", - terminal.entity_id() - ))) + .id(header_id) .flex_none() .gap_1() .justify_between() @@ -2296,23 +2360,6 @@ impl AcpThreadView { ), ) }) - .when(tool_failed || command_failed, |header| { - header.child( - div() - .id(("terminal-tool-error-code-indicator", terminal.entity_id())) - .child( - Icon::new(IconName::Close) - .size(IconSize::Small) - .color(Color::Error), - ) - .when_some(output.and_then(|o| o.exit_status), |this, status| { - this.tooltip(Tooltip::text(format!( - "Exited with code {}", - status.code().unwrap_or(-1), - ))) - }), - ) - }) .when(truncated_output, |header| { let tooltip = if let Some(output) = output { if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES { @@ -2365,6 +2412,7 @@ impl AcpThreadView { ) .opened_icon(IconName::ChevronUp) .closed_icon(IconName::ChevronDown) + .visible_on_hover(&header_group) .on_click(cx.listener({ let id = tool_call.id.clone(); move |this, _event, _window, _cx| { @@ -2373,8 +2421,26 @@ impl AcpThreadView { } else { this.expanded_tool_calls.insert(id.clone()); } - }})), - ); + } + })), + ) + .when(tool_failed || command_failed, |header| { + header.child( + div() + .id(("terminal-tool-error-code-indicator", terminal.entity_id())) + .child( + Icon::new(IconName::Close) + .size(IconSize::Small) + .color(Color::Error), + ) + .when_some(output.and_then(|o| o.exit_status), |this, status| { + this.tooltip(Tooltip::text(format!( + "Exited with code {}", + status.code().unwrap_or(-1), + ))) + }), + ) + }); let terminal_view = self .entry_view_state @@ -2384,7 +2450,8 @@ impl AcpThreadView { let show_output = is_expanded && terminal_view.is_some(); v_flex() - .mb_2() + .my_2() + .mx_5() .border_1() .when(tool_failed || command_failed, |card| card.border_dashed()) .border_color(border_color) @@ -2392,9 +2459,10 @@ impl AcpThreadView { .overflow_hidden() .child( v_flex() + .group(&header_group) .py_1p5() - .pl_2() .pr_1p5() + .pl_2() .gap_0p5() .bg(header_bg) .text_xs() @@ -4153,13 +4221,14 @@ impl AcpThreadView { ) -> impl IntoElement { let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); if is_generating { - return h_flex().id("thread-controls-container").ml_1().child( + return h_flex().id("thread-controls-container").child( div() .py_2() - .px(rems_from_px(22.)) + .px_5() .child(SpinnerLabel::new().size(LabelSize::Small)), ); } + let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown) .shape(ui::IconButtonShape::Square) .icon_size(IconSize::Small) @@ -4185,12 +4254,10 @@ impl AcpThreadView { .id("thread-controls-container") .group("thread-controls-container") .w_full() - .mr_1() - .pt_1() - .pb_2() - .px(RESPONSE_PADDING_X) + .py_2() + .px_5() .gap_px() - .opacity(0.4) + .opacity(0.6) .hover(|style| style.opacity(1.)) .flex_wrap() .justify_end(); @@ -4201,56 +4268,50 @@ impl AcpThreadView { .is_some_and(|thread| thread.read(cx).connection().telemetry().is_some()) { let feedback = self.thread_feedback.feedback; - container = container.child( - div().visible_on_hover("thread-controls-container").child( - Label::new( - match feedback { + + container = container + .child( + div().visible_on_hover("thread-controls-container").child( + Label::new(match feedback { Some(ThreadFeedback::Positive) => "Thanks for your feedback!", - Some(ThreadFeedback::Negative) => "We appreciate your feedback and will use it to improve.", - None => "Rating the thread sends all of your current conversation to the Zed team.", - } - ) - .color(Color::Muted) - .size(LabelSize::XSmall) - .truncate(), - ), - ).child( - h_flex() - .child( - IconButton::new("feedback-thumbs-up", IconName::ThumbsUp) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(match feedback { - Some(ThreadFeedback::Positive) => Color::Accent, - _ => Color::Ignored, - }) - .tooltip(Tooltip::text("Helpful Response")) - .on_click(cx.listener(move |this, _, window, cx| { - this.handle_feedback_click( - ThreadFeedback::Positive, - window, - cx, - ); - })), - ) - .child( - IconButton::new("feedback-thumbs-down", IconName::ThumbsDown) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(match feedback { - Some(ThreadFeedback::Negative) => Color::Accent, - _ => Color::Ignored, - }) - .tooltip(Tooltip::text("Not Helpful")) - .on_click(cx.listener(move |this, _, window, cx| { - this.handle_feedback_click( - ThreadFeedback::Negative, - window, - cx, - ); - })), - ) - ) + Some(ThreadFeedback::Negative) => { + "We appreciate your feedback and will use it to improve." + } + None => { + "Rating the thread sends all of your current conversation to the Zed team." + } + }) + .color(Color::Muted) + .size(LabelSize::XSmall) + .truncate(), + ), + ) + .child( + IconButton::new("feedback-thumbs-up", IconName::ThumbsUp) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .icon_color(match feedback { + Some(ThreadFeedback::Positive) => Color::Accent, + _ => Color::Ignored, + }) + .tooltip(Tooltip::text("Helpful Response")) + .on_click(cx.listener(move |this, _, window, cx| { + this.handle_feedback_click(ThreadFeedback::Positive, window, cx); + })), + ) + .child( + IconButton::new("feedback-thumbs-down", IconName::ThumbsDown) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .icon_color(match feedback { + Some(ThreadFeedback::Negative) => Color::Accent, + _ => Color::Ignored, + }) + .tooltip(Tooltip::text("Not Helpful")) + .on_click(cx.listener(move |this, _, window, cx| { + this.handle_feedback_click(ThreadFeedback::Negative, window, cx); + })), + ); } container.child(open_as_markdown).child(scroll_to_top) diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index f16da45d79..1f607a033a 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -1323,7 +1323,7 @@ fn render_copy_code_block_button( .icon_size(IconSize::Small) .style(ButtonStyle::Filled) .shape(ui::IconButtonShape::Square) - .tooltip(Tooltip::text("Copy Code")) + .tooltip(Tooltip::text("Copy")) .on_click({ let markdown = markdown; move |_event, _window, cx| { From b5b66b76d8e848e70ebc9bc3ceb10caccb3885e8 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 26 Aug 2025 16:58:23 -0300 Subject: [PATCH 88/89] thread view: Improve agent installation UI (#36957) Release Notes: - N/A --------- Co-authored-by: Conrad Irwin --- assets/icons/terminal_ghost.svg | 4 + crates/agent_ui/src/acp/thread_view.rs | 253 ++++++++++++++----------- crates/icons/src/icons.rs | 1 + 3 files changed, 143 insertions(+), 115 deletions(-) create mode 100644 assets/icons/terminal_ghost.svg diff --git a/assets/icons/terminal_ghost.svg b/assets/icons/terminal_ghost.svg new file mode 100644 index 0000000000..7d0d0e068e --- /dev/null +++ b/assets/icons/terminal_ghost.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index f3b1e6ce3b..c68c3a3e93 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -43,7 +43,7 @@ use text::Anchor; use theme::ThemeSettings; use ui::{ Callout, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding, PopoverMenuHandle, - Scrollbar, ScrollbarState, SpinnerLabel, Tooltip, prelude::*, + Scrollbar, ScrollbarState, SpinnerLabel, TintColor, Tooltip, prelude::*, }; use util::{ResultExt, size::format_file_size, time::duration_alt_display}; use workspace::{CollaboratorId, Workspace}; @@ -278,6 +278,7 @@ pub struct AcpThreadView { editing_message: Option, prompt_capabilities: Rc>, is_loading_contents: bool, + install_command_markdown: Entity, _cancel_task: Option>, _subscriptions: [Subscription; 3], } @@ -391,6 +392,7 @@ impl AcpThreadView { hovered_recent_history_item: None, prompt_capabilities, is_loading_contents: false, + install_command_markdown: cx.new(|cx| Markdown::new("".into(), None, None, cx)), _subscriptions: subscriptions, _cancel_task: None, focus_handle: cx.focus_handle(), @@ -666,7 +668,12 @@ impl AcpThreadView { match &self.thread_state { ThreadState::Ready { .. } | ThreadState::Unauthenticated { .. } => "New Thread".into(), ThreadState::Loading { .. } => "Loading…".into(), - ThreadState::LoadError(_) => "Failed to load".into(), + ThreadState::LoadError(error) => match error { + LoadError::NotInstalled { .. } => format!("Install {}", self.agent.name()).into(), + LoadError::Unsupported { .. } => format!("Upgrade {}", self.agent.name()).into(), + LoadError::Exited { .. } => format!("{} Exited", self.agent.name()).into(), + LoadError::Other(_) => format!("Error Loading {}", self.agent.name()).into(), + }, } } @@ -2834,125 +2841,26 @@ impl AcpThreadView { ) } - fn render_load_error(&self, e: &LoadError, cx: &Context) -> AnyElement { - let (message, action_slot) = match e { + fn render_load_error( + &self, + e: &LoadError, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let (message, action_slot): (SharedString, _) = match e { LoadError::NotInstalled { - error_message, - install_message, + error_message: _, + install_message: _, install_command, } => { - let install_command = install_command.clone(); - let button = Button::new("install", install_message) - .tooltip(Tooltip::text(install_command.clone())) - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .icon(IconName::Download) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .on_click(cx.listener(move |this, _, window, cx| { - telemetry::event!("Agent Install CLI", agent = this.agent.telemetry_id()); - - let task = this - .workspace - .update(cx, |workspace, cx| { - let project = workspace.project().read(cx); - let cwd = project.first_project_directory(cx); - let shell = project.terminal_settings(&cwd, cx).shell.clone(); - let spawn_in_terminal = task::SpawnInTerminal { - id: task::TaskId(install_command.clone()), - full_label: install_command.clone(), - label: install_command.clone(), - command: Some(install_command.clone()), - args: Vec::new(), - command_label: install_command.clone(), - cwd, - env: Default::default(), - use_new_terminal: true, - allow_concurrent_runs: true, - reveal: Default::default(), - reveal_target: Default::default(), - hide: Default::default(), - shell, - show_summary: true, - show_command: true, - show_rerun: false, - }; - workspace.spawn_in_terminal(spawn_in_terminal, window, cx) - }) - .ok(); - let Some(task) = task else { return }; - cx.spawn_in(window, async move |this, cx| { - if let Some(Ok(_)) = task.await { - this.update_in(cx, |this, window, cx| { - this.reset(window, cx); - }) - .ok(); - } - }) - .detach() - })); - - (error_message.clone(), Some(button.into_any_element())) + return self.render_not_installed(install_command.clone(), false, window, cx); } LoadError::Unsupported { - error_message, - upgrade_message, + error_message: _, + upgrade_message: _, upgrade_command, } => { - let upgrade_command = upgrade_command.clone(); - let button = Button::new("upgrade", upgrade_message) - .tooltip(Tooltip::text(upgrade_command.clone())) - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .icon(IconName::Download) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .on_click(cx.listener(move |this, _, window, cx| { - telemetry::event!("Agent Upgrade CLI", agent = this.agent.telemetry_id()); - - let task = this - .workspace - .update(cx, |workspace, cx| { - let project = workspace.project().read(cx); - let cwd = project.first_project_directory(cx); - let shell = project.terminal_settings(&cwd, cx).shell.clone(); - let spawn_in_terminal = task::SpawnInTerminal { - id: task::TaskId(upgrade_command.to_string()), - full_label: upgrade_command.clone(), - label: upgrade_command.clone(), - command: Some(upgrade_command.clone()), - args: Vec::new(), - command_label: upgrade_command.clone(), - cwd, - env: Default::default(), - use_new_terminal: true, - allow_concurrent_runs: true, - reveal: Default::default(), - reveal_target: Default::default(), - hide: Default::default(), - shell, - show_summary: true, - show_command: true, - show_rerun: false, - }; - workspace.spawn_in_terminal(spawn_in_terminal, window, cx) - }) - .ok(); - let Some(task) = task else { return }; - cx.spawn_in(window, async move |this, cx| { - if let Some(Ok(_)) = task.await { - this.update_in(cx, |this, window, cx| { - this.reset(window, cx); - }) - .ok(); - } - }) - .detach() - })); - - (error_message.clone(), Some(button.into_any_element())) + return self.render_not_installed(upgrade_command.clone(), true, window, cx); } LoadError::Exited { .. } => ("Server exited with status {status}".into(), None), LoadError::Other(msg) => ( @@ -2970,6 +2878,121 @@ impl AcpThreadView { .into_any_element() } + fn install_agent(&self, install_command: String, window: &mut Window, cx: &mut Context) { + telemetry::event!("Agent Install CLI", agent = self.agent.telemetry_id()); + let task = self + .workspace + .update(cx, |workspace, cx| { + let project = workspace.project().read(cx); + let cwd = project.first_project_directory(cx); + let shell = project.terminal_settings(&cwd, cx).shell.clone(); + let spawn_in_terminal = task::SpawnInTerminal { + id: task::TaskId(install_command.clone()), + full_label: install_command.clone(), + label: install_command.clone(), + command: Some(install_command.clone()), + args: Vec::new(), + command_label: install_command.clone(), + cwd, + env: Default::default(), + use_new_terminal: true, + allow_concurrent_runs: true, + reveal: Default::default(), + reveal_target: Default::default(), + hide: Default::default(), + shell, + show_summary: true, + show_command: true, + show_rerun: false, + }; + workspace.spawn_in_terminal(spawn_in_terminal, window, cx) + }) + .ok(); + let Some(task) = task else { return }; + cx.spawn_in(window, async move |this, cx| { + if let Some(Ok(_)) = task.await { + this.update_in(cx, |this, window, cx| { + this.reset(window, cx); + }) + .ok(); + } + }) + .detach() + } + + fn render_not_installed( + &self, + install_command: String, + is_upgrade: bool, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + self.install_command_markdown.update(cx, |markdown, cx| { + if !markdown.source().contains(&install_command) { + markdown.replace(format!("```\n{}\n```", install_command), cx); + } + }); + + let (heading_label, description_label, button_label, or_label) = if is_upgrade { + ( + "Upgrade Gemini CLI in Zed", + "Get access to the latest version with support for Zed.", + "Upgrade Gemini CLI", + "Or, to upgrade it manually:", + ) + } else { + ( + "Get Started with Gemini CLI in Zed", + "Use Google's new coding agent directly in Zed.", + "Install Gemini CLI", + "Or, to install it manually:", + ) + }; + + v_flex() + .w_full() + .p_3p5() + .gap_2p5() + .border_t_1() + .border_color(cx.theme().colors().border) + .bg(linear_gradient( + 180., + linear_color_stop(cx.theme().colors().editor_background.opacity(0.4), 4.), + linear_color_stop(cx.theme().status().info_background.opacity(0.), 0.), + )) + .child( + v_flex().gap_0p5().child(Label::new(heading_label)).child( + Label::new(description_label) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child( + Button::new("install_gemini", button_label) + .full_width() + .size(ButtonSize::Medium) + .style(ButtonStyle::Tinted(TintColor::Accent)) + .label_size(LabelSize::Small) + .icon(IconName::TerminalGhost) + .icon_color(Color::Muted) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .on_click(cx.listener(move |this, _, window, cx| { + this.install_agent(install_command.clone(), window, cx) + })), + ) + .child( + Label::new(or_label) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child(MarkdownElement::new( + self.install_command_markdown.clone(), + default_markdown_style(false, false, window, cx), + )) + .into_any_element() + } + fn render_activity_bar( &self, thread_entity: &Entity, @@ -4943,7 +4966,7 @@ impl Render for AcpThreadView { .size_full() .items_center() .justify_end() - .child(self.render_load_error(e, cx)), + .child(self.render_load_error(e, window, cx)), ThreadState::Ready { .. } => v_flex().flex_1().map(|this| { if has_messages { this.child( diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 4fc6039fd7..f7363395ae 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -215,6 +215,7 @@ pub enum IconName { Tab, Terminal, TerminalAlt, + TerminalGhost, TextSnippet, TextThread, Thread, From de81615820bf75bbe0251f41ef98b1417ba137bb Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 26 Aug 2025 16:59:12 -0300 Subject: [PATCH 89/89] acp: Add onboarding modal & title bar banner (#36784) Release Notes: - N/A --------- Co-authored-by: Bennet Bo Fenner --- assets/images/acp_grid.svg | 1257 +++++++++++++++++ assets/images/acp_logo.svg | 1 + assets/images/acp_logo_serif.svg | 2 + crates/agent_ui/src/agent_configuration.rs | 2 +- crates/agent_ui/src/agent_panel.rs | 41 +- crates/agent_ui/src/ui.rs | 2 + .../agent_ui/src/ui/acp_onboarding_modal.rs | 254 ++++ crates/client/src/zed_urls.rs | 8 + crates/title_bar/src/onboarding_banner.rs | 2 +- crates/title_bar/src/title_bar.rs | 10 +- crates/ui/src/components/image.rs | 3 + crates/zed_actions/src/lib.rs | 2 + 12 files changed, 1556 insertions(+), 28 deletions(-) create mode 100644 assets/images/acp_grid.svg create mode 100644 assets/images/acp_logo.svg create mode 100644 assets/images/acp_logo_serif.svg create mode 100644 crates/agent_ui/src/ui/acp_onboarding_modal.rs diff --git a/assets/images/acp_grid.svg b/assets/images/acp_grid.svg new file mode 100644 index 0000000000..8ebff8e1bc --- /dev/null +++ b/assets/images/acp_grid.svg @@ -0,0 +1,1257 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/acp_logo.svg b/assets/images/acp_logo.svg new file mode 100644 index 0000000000..efaa46707b --- /dev/null +++ b/assets/images/acp_logo.svg @@ -0,0 +1 @@ + diff --git a/assets/images/acp_logo_serif.svg b/assets/images/acp_logo_serif.svg new file mode 100644 index 0000000000..6bc359cf82 --- /dev/null +++ b/assets/images/acp_logo_serif.svg @@ -0,0 +1,2 @@ + + diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 8cc680b377..ba7f8065d7 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -1093,7 +1093,7 @@ impl AgentConfiguration { ) .child( Label::new( - "Use the full power of Zed's UI with your favorite agent, connected via the Agent Client Protocol.", + "Bring the agent of your choice to Zed via our new Agent Client Protocol.", ) .color(Color::Muted), ), diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 269aec3365..414ad27b71 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -14,6 +14,7 @@ use zed_actions::agent::ReauthenticateAgent; use crate::acp::{AcpThreadHistory, ThreadHistoryEvent}; use crate::agent_diff::AgentDiffThread; +use crate::ui::AcpOnboardingModal; use crate::{ AddContextServer, AgentDiffPane, ContinueThread, ContinueWithBurnMode, DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread, @@ -77,7 +78,10 @@ use workspace::{ }; use zed_actions::{ DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize, - agent::{OpenOnboardingModal, OpenSettings, ResetOnboarding, ToggleModelSelector}, + agent::{ + OpenAcpOnboardingModal, OpenOnboardingModal, OpenSettings, ResetOnboarding, + ToggleModelSelector, + }, assistant::{OpenRulesLibrary, ToggleFocus}, }; @@ -201,6 +205,9 @@ pub fn init(cx: &mut App) { .register_action(|workspace, _: &OpenOnboardingModal, window, cx| { AgentOnboardingModal::toggle(workspace, window, cx) }) + .register_action(|workspace, _: &OpenAcpOnboardingModal, window, cx| { + AcpOnboardingModal::toggle(workspace, window, cx) + }) .register_action(|_workspace, _: &ResetOnboarding, window, cx| { window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx); window.refresh(); @@ -1852,19 +1859,6 @@ impl AgentPanel { menu } - pub fn set_selected_agent( - &mut self, - agent: AgentType, - window: &mut Window, - cx: &mut Context, - ) { - if self.selected_agent != agent { - self.selected_agent = agent.clone(); - self.serialize(cx); - } - self.new_agent_thread(agent, window, cx); - } - pub fn selected_agent(&self) -> AgentType { self.selected_agent.clone() } @@ -1875,6 +1869,11 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { + if self.selected_agent != agent { + self.selected_agent = agent.clone(); + self.serialize(cx); + } + match agent { AgentType::Zed => { window.dispatch_action( @@ -2555,7 +2554,7 @@ impl AgentPanel { workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.set_selected_agent( + panel.new_agent_thread( AgentType::NativeAgent, window, cx, @@ -2581,7 +2580,7 @@ impl AgentPanel { workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.set_selected_agent( + panel.new_agent_thread( AgentType::TextThread, window, cx, @@ -2609,7 +2608,7 @@ impl AgentPanel { workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.set_selected_agent( + panel.new_agent_thread( AgentType::Gemini, window, cx, @@ -2636,7 +2635,7 @@ impl AgentPanel { workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.set_selected_agent( + panel.new_agent_thread( AgentType::ClaudeCode, window, cx, @@ -2669,7 +2668,7 @@ impl AgentPanel { workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.set_selected_agent( + panel.new_agent_thread( AgentType::Custom { name: agent_name .clone(), @@ -2693,9 +2692,9 @@ impl AgentPanel { }) .when(cx.has_flag::(), |menu| { menu.separator().link( - "Add Your Own Agent", + "Add Other Agents", OpenBrowser { - url: "https://agentclientprotocol.com/".into(), + url: zed_urls::external_agents_docs(cx), } .boxed_clone(), ) diff --git a/crates/agent_ui/src/ui.rs b/crates/agent_ui/src/ui.rs index ada973cddf..600698b07e 100644 --- a/crates/agent_ui/src/ui.rs +++ b/crates/agent_ui/src/ui.rs @@ -1,3 +1,4 @@ +mod acp_onboarding_modal; mod agent_notification; mod burn_mode_tooltip; mod context_pill; @@ -6,6 +7,7 @@ mod onboarding_modal; pub mod preview; mod unavailable_editing_tooltip; +pub use acp_onboarding_modal::*; pub use agent_notification::*; pub use burn_mode_tooltip::*; pub use context_pill::*; diff --git a/crates/agent_ui/src/ui/acp_onboarding_modal.rs b/crates/agent_ui/src/ui/acp_onboarding_modal.rs new file mode 100644 index 0000000000..0ed9de7221 --- /dev/null +++ b/crates/agent_ui/src/ui/acp_onboarding_modal.rs @@ -0,0 +1,254 @@ +use client::zed_urls; +use gpui::{ + ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render, + linear_color_stop, linear_gradient, +}; +use ui::{TintColor, Vector, VectorName, prelude::*}; +use workspace::{ModalView, Workspace}; + +use crate::agent_panel::{AgentPanel, AgentType}; + +macro_rules! acp_onboarding_event { + ($name:expr) => { + telemetry::event!($name, source = "ACP Onboarding"); + }; + ($name:expr, $($key:ident $(= $value:expr)?),+ $(,)?) => { + telemetry::event!($name, source = "ACP Onboarding", $($key $(= $value)?),+); + }; +} + +pub struct AcpOnboardingModal { + focus_handle: FocusHandle, + workspace: Entity, +} + +impl AcpOnboardingModal { + pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context) { + let workspace_entity = cx.entity(); + workspace.toggle_modal(window, cx, |_window, cx| Self { + workspace: workspace_entity, + focus_handle: cx.focus_handle(), + }); + } + + fn open_panel(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { + self.workspace.update(cx, |workspace, cx| { + workspace.focus_panel::(window, cx); + + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.new_agent_thread(AgentType::Gemini, window, cx); + }); + } + }); + + cx.emit(DismissEvent); + + acp_onboarding_event!("Open Panel Clicked"); + } + + fn view_docs(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context) { + cx.open_url(&zed_urls::external_agents_docs(cx)); + cx.notify(); + + acp_onboarding_event!("Documentation Link Clicked"); + } + + fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } +} + +impl EventEmitter for AcpOnboardingModal {} + +impl Focusable for AcpOnboardingModal { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl ModalView for AcpOnboardingModal {} + +impl Render for AcpOnboardingModal { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let illustration_element = |label: bool, opacity: f32| { + h_flex() + .px_1() + .py_0p5() + .gap_1() + .rounded_sm() + .bg(cx.theme().colors().element_active.opacity(0.05)) + .border_1() + .border_color(cx.theme().colors().border) + .border_dashed() + .child( + Icon::new(IconName::Stop) + .size(IconSize::Small) + .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.15))), + ) + .map(|this| { + if label { + this.child( + Label::new("Your Agent Here") + .size(LabelSize::Small) + .color(Color::Muted), + ) + } else { + this.child( + div().w_16().h_1().rounded_full().bg(cx + .theme() + .colors() + .element_active + .opacity(0.6)), + ) + } + }) + .opacity(opacity) + }; + + let illustration = h_flex() + .relative() + .h(rems_from_px(126.)) + .bg(cx.theme().colors().editor_background) + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .justify_center() + .gap_8() + .rounded_t_md() + .overflow_hidden() + .child( + div().absolute().inset_0().w(px(515.)).h(px(126.)).child( + Vector::new(VectorName::AcpGrid, rems_from_px(515.), rems_from_px(126.)) + .color(ui::Color::Custom(cx.theme().colors().text.opacity(0.02))), + ), + ) + .child(div().absolute().inset_0().size_full().bg(linear_gradient( + 0., + linear_color_stop( + cx.theme().colors().elevated_surface_background.opacity(0.1), + 0.9, + ), + linear_color_stop( + cx.theme().colors().elevated_surface_background.opacity(0.), + 0., + ), + ))) + .child( + div() + .absolute() + .inset_0() + .size_full() + .bg(gpui::black().opacity(0.15)), + ) + .child( + h_flex() + .gap_4() + .child( + Vector::new(VectorName::AcpLogo, rems_from_px(106.), rems_from_px(40.)) + .color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))), + ) + .child( + Vector::new( + VectorName::AcpLogoSerif, + rems_from_px(111.), + rems_from_px(41.), + ) + .color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))), + ), + ) + .child( + v_flex() + .gap_1p5() + .child(illustration_element(false, 0.15)) + .child(illustration_element(true, 0.3)) + .child( + h_flex() + .pl_1() + .pr_2() + .py_0p5() + .gap_1() + .rounded_sm() + .bg(cx.theme().colors().element_active.opacity(0.2)) + .border_1() + .border_color(cx.theme().colors().border) + .child( + Icon::new(IconName::AiGemini) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child(Label::new("New Gemini CLI Thread").size(LabelSize::Small)), + ) + .child(illustration_element(true, 0.3)) + .child(illustration_element(false, 0.15)), + ); + + let heading = v_flex() + .w_full() + .gap_1() + .child( + Label::new("Now Available") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child(Headline::new("Bring Your Own Agent to Zed").size(HeadlineSize::Large)); + + let copy = "Bring the agent of your choice to Zed via our new Agent Client Protocol (ACP), starting with Google's Gemini CLI integration."; + + let open_panel_button = Button::new("open-panel", "Start with Gemini CLI") + .icon_size(IconSize::Indicator) + .style(ButtonStyle::Tinted(TintColor::Accent)) + .full_width() + .on_click(cx.listener(Self::open_panel)); + + let docs_button = Button::new("add-other-agents", "Add Other Agents") + .icon(IconName::ArrowUpRight) + .icon_size(IconSize::Indicator) + .icon_color(Color::Muted) + .full_width() + .on_click(cx.listener(Self::view_docs)); + + let close_button = h_flex().absolute().top_2().right_2().child( + IconButton::new("cancel", IconName::Close).on_click(cx.listener( + |_, _: &ClickEvent, _window, cx| { + acp_onboarding_event!("Canceled", trigger = "X click"); + cx.emit(DismissEvent); + }, + )), + ); + + v_flex() + .id("acp-onboarding") + .key_context("AcpOnboardingModal") + .relative() + .w(rems(34.)) + .h_full() + .elevation_3(cx) + .track_focus(&self.focus_handle(cx)) + .overflow_hidden() + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(|_, _: &menu::Cancel, _window, cx| { + acp_onboarding_event!("Canceled", trigger = "Action"); + cx.emit(DismissEvent); + })) + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| { + this.focus_handle.focus(window); + })) + .child(illustration) + .child( + v_flex() + .p_4() + .gap_2() + .child(heading) + .child(Label::new(copy).color(Color::Muted)) + .child( + v_flex() + .w_full() + .mt_2() + .gap_1() + .child(open_panel_button) + .child(docs_button), + ), + ) + .child(close_button) + } +} diff --git a/crates/client/src/zed_urls.rs b/crates/client/src/zed_urls.rs index 9df41906d7..7193c09947 100644 --- a/crates/client/src/zed_urls.rs +++ b/crates/client/src/zed_urls.rs @@ -43,3 +43,11 @@ pub fn ai_privacy_and_security(cx: &App) -> String { server_url = server_url(cx) ) } + +/// Returns the URL to Zed AI's external agents documentation. +pub fn external_agents_docs(cx: &App) -> String { + format!( + "{server_url}/docs/ai/external-agents", + server_url = server_url(cx) + ) +} diff --git a/crates/title_bar/src/onboarding_banner.rs b/crates/title_bar/src/onboarding_banner.rs index ed43c5277a..1c28942490 100644 --- a/crates/title_bar/src/onboarding_banner.rs +++ b/crates/title_bar/src/onboarding_banner.rs @@ -119,7 +119,7 @@ impl Render for OnboardingBanner { h_flex() .h_full() .gap_1() - .child(Icon::new(self.details.icon_name).size(IconSize::Small)) + .child(Icon::new(self.details.icon_name).size(IconSize::XSmall)) .child( h_flex() .gap_0p5() diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index b84a2800b6..ad64dac9c6 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -275,11 +275,11 @@ impl TitleBar { let banner = cx.new(|cx| { OnboardingBanner::new( - "Debugger Onboarding", - IconName::Debug, - "The Debugger", - None, - zed_actions::debugger::OpenOnboardingModal.boxed_clone(), + "ACP Onboarding", + IconName::Sparkle, + "Bring Your Own Agent", + Some("Introducing:".into()), + zed_actions::agent::OpenAcpOnboardingModal.boxed_clone(), cx, ) }); diff --git a/crates/ui/src/components/image.rs b/crates/ui/src/components/image.rs index 09c3bbeb94..6e552ddcee 100644 --- a/crates/ui/src/components/image.rs +++ b/crates/ui/src/components/image.rs @@ -13,6 +13,9 @@ use crate::prelude::*; )] #[strum(serialize_all = "snake_case")] pub enum VectorName { + AcpGrid, + AcpLogo, + AcpLogoSerif, AiGrid, DebuggerGrid, Grid, diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 2e8b3dd168..74f0a9779f 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -281,6 +281,8 @@ pub mod agent { OpenSettings, /// Opens the agent onboarding modal. OpenOnboardingModal, + /// Opens the ACP onboarding modal. + OpenAcpOnboardingModal, /// Resets the agent onboarding state. ResetOnboarding, /// Starts a chat conversation with the agent.