From 2526dcb5a54019977ba69a86f4b1f8e214c58399 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Fri, 8 Aug 2025 09:43:53 -0300 Subject: [PATCH 001/109] agent2: Port `edit_file` tool (#35844) TODO: - [x] Authorization - [x] Restore tests Release Notes: - N/A --------- Co-authored-by: Antonio Scandurra Co-authored-by: Ben Brandt --- Cargo.lock | 4 + Cargo.toml | 2 +- crates/acp_thread/src/acp_thread.rs | 133 +- crates/acp_thread/src/diff.rs | 388 +++++ crates/agent2/Cargo.toml | 4 + crates/agent2/src/agent.rs | 14 +- crates/agent2/src/agent2.rs | 1 + crates/agent2/src/tests/mod.rs | 3 +- crates/agent2/src/tests/test_tools.rs | 22 +- crates/agent2/src/thread.rs | 212 ++- crates/agent2/src/tools.rs | 2 + crates/agent2/src/tools/edit_file_tool.rs | 1361 +++++++++++++++++ crates/agent2/src/tools/find_path_tool.rs | 63 +- crates/agent2/src/tools/read_file_tool.rs | 163 +- crates/agent2/src/tools/thinking_tool.rs | 1 + crates/agent_ui/src/acp/thread_view.rs | 15 +- crates/assistant_tools/src/assistant_tools.rs | 4 +- crates/assistant_tools/src/edit_agent.rs | 6 - crates/assistant_tools/src/edit_file_tool.rs | 85 - crates/language_model/src/request.rs | 6 + 20 files changed, 2075 insertions(+), 414 deletions(-) create mode 100644 crates/acp_thread/src/diff.rs create mode 100644 crates/agent2/src/tools/edit_file_tool.rs diff --git a/Cargo.lock b/Cargo.lock index 0f0e78bb48..6f434e8685 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -158,8 +158,10 @@ dependencies = [ "acp_thread", "agent-client-protocol", "agent_servers", + "agent_settings", "anyhow", "assistant_tool", + "assistant_tools", "client", "clock", "cloud_llm_client", @@ -177,6 +179,8 @@ dependencies = [ "language_model", "language_models", "log", + "lsp", + "paths", "pretty_assertions", "project", "prompt_store", diff --git a/Cargo.toml b/Cargo.toml index d547110bb4..998e727602 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -425,7 +425,7 @@ zlog_settings = { path = "crates/zlog_settings" } # agentic-coding-protocol = "0.0.10" -agent-client-protocol = { version = "0.0.23" } +agent-client-protocol = "0.0.23" 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 443375a51b..54bfe56a15 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -1,18 +1,17 @@ mod connection; +mod diff; + pub use connection::*; +pub use diff::*; use agent_client_protocol as acp; use anyhow::{Context as _, Result}; use assistant_tool::ActionLog; -use buffer_diff::BufferDiff; -use editor::{Bias, MultiBuffer, PathKey}; +use editor::Bias; use futures::{FutureExt, channel::oneshot, future::BoxFuture}; use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Task}; use itertools::Itertools; -use language::{ - Anchor, Buffer, BufferSnapshot, Capability, LanguageRegistry, OffsetRangeExt as _, Point, - text_diff, -}; +use language::{Anchor, Buffer, BufferSnapshot, LanguageRegistry, Point, text_diff}; use markdown::Markdown; use project::{AgentLocation, Project}; use std::collections::HashMap; @@ -140,7 +139,7 @@ impl AgentThreadEntry { } } - pub fn diffs(&self) -> impl Iterator { + pub fn diffs(&self) -> impl Iterator> { if let AgentThreadEntry::ToolCall(call) = self { itertools::Either::Left(call.diffs()) } else { @@ -249,7 +248,7 @@ impl ToolCall { } } - pub fn diffs(&self) -> impl Iterator { + pub fn diffs(&self) -> impl Iterator> { self.content.iter().filter_map(|content| match content { ToolCallContent::ContentBlock { .. } => None, ToolCallContent::Diff { diff } => Some(diff), @@ -389,7 +388,7 @@ impl ContentBlock { #[derive(Debug)] pub enum ToolCallContent { ContentBlock { content: ContentBlock }, - Diff { diff: Diff }, + Diff { diff: Entity }, } impl ToolCallContent { @@ -403,7 +402,7 @@ impl ToolCallContent { content: ContentBlock::new(content, &language_registry, cx), }, acp::ToolCallContent::Diff { diff } => Self::Diff { - diff: Diff::from_acp(diff, language_registry, cx), + diff: cx.new(|cx| Diff::from_acp(diff, language_registry, cx)), }, } } @@ -411,108 +410,11 @@ impl ToolCallContent { pub fn to_markdown(&self, cx: &App) -> String { match self { Self::ContentBlock { content } => content.to_markdown(cx).to_string(), - Self::Diff { diff } => diff.to_markdown(cx), + Self::Diff { diff } => diff.read(cx).to_markdown(cx), } } } -#[derive(Debug)] -pub struct Diff { - pub multibuffer: Entity, - pub path: PathBuf, - _task: Task>, -} - -impl Diff { - pub fn from_acp( - diff: acp::Diff, - language_registry: Arc, - cx: &mut App, - ) -> Self { - let acp::Diff { - path, - old_text, - new_text, - } = diff; - - 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 task = cx.spawn({ - let multibuffer = multibuffer.clone(); - let path = path.clone(); - async move |cx| { - let language = language_registry - .language_for_file_path(&path) - .await - .log_err(); - - new_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?; - - multibuffer - .update(cx, |multibuffer, cx| { - let hunk_ranges = { - let buffer = new_buffer.read(cx); - let diff = buffer_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(), - hunk_ranges, - editor::DEFAULT_MULTIBUFFER_CONTEXT, - cx, - ); - multibuffer.add_diff(buffer_diff, cx); - }) - .log_err(); - - anyhow::Ok(()) - } - }); - - Self { - multibuffer, - path, - _task: task, - } - } - - fn to_markdown(&self, cx: &App) -> String { - let buffer_text = self - .multibuffer - .read(cx) - .all_buffers() - .iter() - .map(|buffer| buffer.read(cx).text()) - .join("\n"); - format!("Diff: {}\n```\n{}\n```\n", self.path.display(), buffer_text) - } -} - #[derive(Debug, Default)] pub struct Plan { pub entries: Vec, @@ -823,6 +725,21 @@ impl AcpThread { Ok(()) } + pub fn set_tool_call_diff( + &mut self, + tool_call_id: &acp::ToolCallId, + diff: Entity, + cx: &mut Context, + ) -> Result<()> { + let (ix, current_call) = self + .tool_call_mut(tool_call_id) + .context("Tool call not found")?; + current_call.content.clear(); + current_call.content.push(ToolCallContent::Diff { diff }); + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + Ok(()) + } + /// Updates a tool call if id matches an existing entry, otherwise inserts a new one. pub fn upsert_tool_call(&mut self, tool_call: acp::ToolCall, cx: &mut Context) { let status = ToolCallStatus::Allowed { diff --git a/crates/acp_thread/src/diff.rs b/crates/acp_thread/src/diff.rs new file mode 100644 index 0000000000..9cc6271360 --- /dev/null +++ b/crates/acp_thread/src/diff.rs @@ -0,0 +1,388 @@ +use agent_client_protocol as acp; +use anyhow::Result; +use buffer_diff::{BufferDiff, BufferDiffSnapshot}; +use editor::{MultiBuffer, PathKey}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, Subscription, Task}; +use itertools::Itertools; +use language::{ + Anchor, Buffer, Capability, LanguageRegistry, OffsetRangeExt as _, Point, Rope, TextBuffer, +}; +use std::{ + cmp::Reverse, + ops::Range, + path::{Path, PathBuf}, + sync::Arc, +}; +use util::ResultExt; + +pub enum Diff { + Pending(PendingDiff), + Finalized(FinalizedDiff), +} + +impl Diff { + pub fn from_acp( + diff: acp::Diff, + language_registry: Arc, + cx: &mut Context, + ) -> Self { + let acp::Diff { + path, + old_text, + new_text, + } = diff; + + 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 task = cx.spawn({ + let multibuffer = multibuffer.clone(); + let path = path.clone(); + async move |_, cx| { + let language = language_registry + .language_for_file_path(&path) + .await + .log_err(); + + new_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?; + + multibuffer + .update(cx, |multibuffer, cx| { + let hunk_ranges = { + let buffer = new_buffer.read(cx); + let diff = buffer_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(), + hunk_ranges, + editor::DEFAULT_MULTIBUFFER_CONTEXT, + cx, + ); + multibuffer.add_diff(buffer_diff, cx); + }) + .log_err(); + + anyhow::Ok(()) + } + }); + + Self::Finalized(FinalizedDiff { + multibuffer, + path, + _update_diff: task, + }) + } + + 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_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, + ); + diff + }); + + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::without_headers(Capability::ReadOnly); + multibuffer.add_diff(buffer_diff.clone(), cx); + multibuffer + }); + + Self::Pending(PendingDiff { + multibuffer, + base_text: Arc::new(base_text), + _subscription: cx.observe(&buffer, |this, _, cx| { + if let Diff::Pending(diff) = this { + diff.update(cx); + } + }), + buffer, + diff: buffer_diff, + revealed_ranges: Vec::new(), + update_diff: Task::ready(Ok(())), + }) + } + + pub fn reveal_range(&mut self, range: Range, cx: &mut Context) { + if let Self::Pending(diff) = self { + diff.reveal_range(range, cx); + } + } + + pub fn finalize(&mut self, cx: &mut Context) { + if let Self::Pending(diff) = self { + *self = Self::Finalized(diff.finalize(cx)); + } + } + + pub fn multibuffer(&self) -> &Entity { + match self { + Self::Pending(PendingDiff { multibuffer, .. }) => multibuffer, + Self::Finalized(FinalizedDiff { multibuffer, .. }) => multibuffer, + } + } + + pub fn to_markdown(&self, cx: &App) -> String { + let buffer_text = self + .multibuffer() + .read(cx) + .all_buffers() + .iter() + .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::Finalized(FinalizedDiff { path, .. }) => Some(path.as_path()), + }; + format!( + "Diff: {}\n```\n{}\n```\n", + path.unwrap_or(Path::new("untitled")).display(), + buffer_text + ) + } +} + +pub struct PendingDiff { + multibuffer: Entity, + base_text: Arc, + buffer: Entity, + diff: Entity, + revealed_ranges: Vec>, + _subscription: Subscription, + update_diff: Task>, +} + +impl PendingDiff { + pub fn update(&mut self, cx: &mut Context) { + let buffer = self.buffer.clone(); + let buffer_diff = self.diff.clone(); + let base_text = self.base_text.clone(); + self.update_diff = cx.spawn(async move |diff, cx| { + let text_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot())?; + let diff_snapshot = BufferDiff::update_diff( + buffer_diff.clone(), + text_snapshot.clone(), + Some(base_text), + false, + false, + None, + None, + cx, + ) + .await?; + buffer_diff.update(cx, |diff, cx| { + diff.set_snapshot(diff_snapshot, &text_snapshot, cx) + })?; + diff.update(cx, |diff, cx| { + if let Diff::Pending(diff) = diff { + diff.update_visible_ranges(cx); + } + }) + }); + } + + pub fn reveal_range(&mut self, range: Range, cx: &mut Context) { + self.revealed_ranges.push(range); + self.update_visible_ranges(cx); + } + + 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().clone(); + + let path = self + .buffer + .read(cx) + .file() + .map(|file| file.path().as_ref()) + .unwrap_or(Path::new("untitled")) + .into(); + + // Replace the buffer in the multibuffer with the snapshot + let buffer = cx.new(|cx| { + let language = self.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(), + ); + let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite); + buffer.set_language(language, cx); + buffer + }); + + let buffer_diff = cx.spawn({ + let buffer = buffer.clone(); + let language_registry = language_registry.clone(); + async move |_this, cx| { + build_buffer_diff(base_text, &buffer, language_registry, cx).await + } + }); + + let update_diff = cx.spawn(async move |this, cx| { + let buffer_diff = buffer_diff.await?; + this.update(cx, |this, cx| { + this.multibuffer().update(cx, |multibuffer, cx| { + let path_key = PathKey::for_buffer(&buffer, cx); + multibuffer.clear(cx); + multibuffer.set_excerpts_for_path( + path_key, + buffer, + ranges, + editor::DEFAULT_MULTIBUFFER_CONTEXT, + cx, + ); + multibuffer.add_diff(buffer_diff.clone(), cx); + }); + + cx.notify(); + }) + }); + + FinalizedDiff { + path, + multibuffer: self.multibuffer.clone(), + _update_diff: update_diff, + } + } + + fn update_visible_ranges(&mut self, cx: &mut Context) { + 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(), + ranges, + editor::DEFAULT_MULTIBUFFER_CONTEXT, + cx, + ); + let end = multibuffer.len(cx); + Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1) + }); + cx.notify(); + } + + fn excerpt_ranges(&self, cx: &App) -> Vec> { + let buffer = self.buffer.read(cx); + let diff = self.diff.read(cx); + let mut ranges = diff + .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, cx) + .map(|diff_hunk| diff_hunk.buffer_range.to_point(&buffer)) + .collect::>(); + ranges.extend( + self.revealed_ranges + .iter() + .map(|range| range.to_point(&buffer)), + ); + ranges.sort_unstable_by_key(|range| (range.start, Reverse(range.end))); + + // Merge adjacent ranges + let mut ranges = ranges.into_iter().peekable(); + let mut merged_ranges = Vec::new(); + while let Some(mut range) = ranges.next() { + while let Some(next_range) = ranges.peek() { + if range.end >= next_range.start { + range.end = range.end.max(next_range.end); + ranges.next(); + } else { + break; + } + } + + merged_ranges.push(range); + } + merged_ranges + } +} + +pub struct FinalizedDiff { + path: PathBuf, + multibuffer: Entity, + _update_diff: Task>, +} + +async fn build_buffer_diff( + old_text: Arc, + buffer: &Entity, + language_registry: Option>, + cx: &mut AsyncApp, +) -> Result> { + let buffer = cx.update(|cx| buffer.read(cx).snapshot())?; + + let old_text_rope = cx + .background_spawn({ + let old_text = old_text.clone(); + async move { Rope::from(old_text.as_str()) } + }) + .await; + let base_buffer = cx + .update(|cx| { + Buffer::build_snapshot( + old_text_rope, + buffer.language().cloned(), + language_registry, + cx, + ) + })? + .await; + + let diff_snapshot = cx + .update(|cx| { + BufferDiffSnapshot::new_with_base_buffer( + buffer.text.clone(), + Some(old_text), + base_buffer, + cx, + ) + })? + .await; + + let secondary_diff = cx.new(|cx| { + let mut diff = BufferDiff::new(&buffer, cx); + diff.set_snapshot(diff_snapshot.clone(), &buffer, cx); + diff + })?; + + cx.new(|cx| { + let mut diff = BufferDiff::new(&buffer.text, cx); + diff.set_snapshot(diff_snapshot, &buffer, cx); + diff.set_secondary_diff(secondary_diff); + diff + }) +} diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml index a75011a671..3e19895a31 100644 --- a/crates/agent2/Cargo.toml +++ b/crates/agent2/Cargo.toml @@ -15,8 +15,10 @@ workspace = true acp_thread.workspace = true agent-client-protocol.workspace = true agent_servers.workspace = true +agent_settings.workspace = true anyhow.workspace = true assistant_tool.workspace = true +assistant_tools.workspace = true cloud_llm_client.workspace = true collections.workspace = true fs.workspace = true @@ -29,6 +31,7 @@ language.workspace = true language_model.workspace = true language_models.workspace = true log.workspace = true +paths.workspace = true project.workspace = true prompt_store.workspace = true rust-embed.workspace = true @@ -53,6 +56,7 @@ gpui = { workspace = true, "features" = ["test-support"] } gpui_tokio.workspace = true language = { workspace = true, "features" = ["test-support"] } language_model = { workspace = true, "features" = ["test-support"] } +lsp = { workspace = true, "features" = ["test-support"] } project = { workspace = true, "features" = ["test-support"] } reqwest_client.workspace = true settings = { workspace = true, "features" = ["test-support"] } diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index 2014d86fb7..e7920e7891 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -1,5 +1,5 @@ use crate::{templates::Templates, AgentResponseEvent, Thread}; -use crate::{FindPathTool, ReadFileTool, ThinkingTool, ToolCallAuthorization}; +use crate::{EditFileTool, FindPathTool, ReadFileTool, ThinkingTool, ToolCallAuthorization}; use acp_thread::ModelSelector; use agent_client_protocol as acp; use anyhow::{anyhow, Context as _, Result}; @@ -412,11 +412,12 @@ impl acp_thread::AgentConnection for NativeAgentConnection { anyhow!("No default model configured. Please configure a default model in settings.") })?; - let thread = cx.new(|_| { + let thread = cx.new(|cx| { let mut thread = Thread::new(project.clone(), agent.project_context.clone(), action_log.clone(), agent.templates.clone(), default_model); thread.add_tool(ThinkingTool); thread.add_tool(FindPathTool::new(project.clone())); thread.add_tool(ReadFileTool::new(project.clone(), action_log)); + thread.add_tool(EditFileTool::new(cx.entity())); thread }); @@ -564,6 +565,15 @@ impl acp_thread::AgentConnection for NativeAgentConnection { ) })??; } + AgentResponseEvent::ToolCallDiff(tool_call_diff) => { + acp_thread.update(cx, |thread, cx| { + thread.set_tool_call_diff( + &tool_call_diff.tool_call_id, + tool_call_diff.diff, + cx, + ) + })??; + } AgentResponseEvent::Stop(stop_reason) => { log::debug!("Assistant message complete: {:?}", stop_reason); return Ok(acp::PromptResponse { stop_reason }); diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs index db743c8429..f13cd1bd67 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/agent2/src/agent2.rs @@ -9,5 +9,6 @@ mod tests; pub use agent::*; pub use native_agent_server::NativeAgentServer; +pub use templates::*; pub use thread::*; pub use tools::*; diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index 7913f9a24c..b70f54ac0a 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -1,5 +1,4 @@ use super::*; -use crate::templates::Templates; use acp_thread::AgentConnection; use agent_client_protocol::{self as acp}; use anyhow::Result; @@ -273,7 +272,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { tool_name: ToolRequiringPermission.name().into(), is_error: false, content: "Allowed".into(), - output: None + output: Some("Allowed".into()) }), MessageContent::ToolResult(LanguageModelToolResult { tool_use_id: tool_call_auth_2.tool_call.id.0.to_string().into(), diff --git a/crates/agent2/src/tests/test_tools.rs b/crates/agent2/src/tests/test_tools.rs index fd6e7e941f..d22ff6ace8 100644 --- a/crates/agent2/src/tests/test_tools.rs +++ b/crates/agent2/src/tests/test_tools.rs @@ -14,6 +14,7 @@ pub struct EchoTool; impl AgentTool for EchoTool { type Input = EchoToolInput; + type Output = String; fn name(&self) -> SharedString { "echo".into() @@ -48,6 +49,7 @@ pub struct DelayTool; impl AgentTool for DelayTool { type Input = DelayToolInput; + type Output = String; fn name(&self) -> SharedString { "delay".into() @@ -84,6 +86,7 @@ pub struct ToolRequiringPermission; impl AgentTool for ToolRequiringPermission { type Input = ToolRequiringPermissionInput; + type Output = String; fn name(&self) -> SharedString { "tool_requiring_permission".into() @@ -99,14 +102,11 @@ impl AgentTool for ToolRequiringPermission { fn run( self: Arc, - input: Self::Input, + _input: Self::Input, event_stream: ToolCallEventStream, cx: &mut App, - ) -> Task> - where - Self: Sized, - { - let auth_check = self.authorize(input, event_stream); + ) -> Task> { + let auth_check = event_stream.authorize("Authorize?".into()); cx.foreground_executor().spawn(async move { auth_check.await?; Ok("Allowed".to_string()) @@ -121,6 +121,7 @@ pub struct InfiniteTool; impl AgentTool for InfiniteTool { type Input = InfiniteToolInput; + type Output = String; fn name(&self) -> SharedString { "infinite".into() @@ -171,19 +172,20 @@ pub struct WordListTool; impl AgentTool for WordListTool { type Input = WordListInput; + type Output = String; fn name(&self) -> SharedString { "word_list".into() } - fn initial_title(&self, _input: Self::Input) -> SharedString { - "List of random words".into() - } - fn kind(&self) -> acp::ToolKind { acp::ToolKind::Other } + fn initial_title(&self, _input: Self::Input) -> SharedString { + "List of random words".into() + } + fn run( self: Arc, _input: Self::Input, diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 4b8a65655f..98f2d0651d 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -1,4 +1,5 @@ -use crate::templates::{SystemPromptTemplate, Template, Templates}; +use crate::{SystemPromptTemplate, Template, Templates}; +use acp_thread::Diff; use agent_client_protocol as acp; use anyhow::{anyhow, Context as _, Result}; use assistant_tool::{adapt_schema_to_format, ActionLog}; @@ -103,6 +104,7 @@ pub enum AgentResponseEvent { ToolCall(acp::ToolCall), ToolCallUpdate(acp::ToolCallUpdate), ToolCallAuthorization(ToolCallAuthorization), + ToolCallDiff(ToolCallDiff), Stop(acp::StopReason), } @@ -113,6 +115,12 @@ pub struct ToolCallAuthorization { pub response: oneshot::Sender, } +#[derive(Debug)] +pub struct ToolCallDiff { + pub tool_call_id: acp::ToolCallId, + pub diff: Entity, +} + pub struct Thread { messages: Vec, completion_mode: CompletionMode, @@ -125,12 +133,13 @@ pub struct Thread { project_context: Rc>, templates: Arc, pub selected_model: Arc, + project: Entity, action_log: Entity, } impl Thread { pub fn new( - _project: Entity, + project: Entity, project_context: Rc>, action_log: Entity, templates: Arc, @@ -145,10 +154,19 @@ impl Thread { project_context, templates, selected_model: default_model, + project, action_log, } } + pub fn project(&self) -> &Entity { + &self.project + } + + pub fn action_log(&self) -> &Entity { + &self.action_log + } + pub fn set_mode(&mut self, mode: CompletionMode) { self.completion_mode = mode; } @@ -315,10 +333,6 @@ impl Thread { events_rx } - pub fn action_log(&self) -> &Entity { - &self.action_log - } - pub fn build_system_message(&self) -> AgentMessage { log::debug!("Building system message"); let prompt = SystemPromptTemplate { @@ -490,15 +504,33 @@ impl Thread { })); }; - let tool_result = self.run_tool(tool, tool_use.clone(), event_stream.clone(), cx); + let tool_event_stream = + ToolCallEventStream::new(&tool_use, tool.kind(), event_stream.clone()); + tool_event_stream.send_update(acp::ToolCallUpdateFields { + status: Some(acp::ToolCallStatus::InProgress), + ..Default::default() + }); + let supports_images = self.selected_model.supports_images(); + let tool_result = tool.run(tool_use.input, tool_event_stream, cx); Some(cx.foreground_executor().spawn(async move { - match tool_result.await { - Ok(tool_output) => LanguageModelToolResult { + let tool_result = tool_result.await.and_then(|output| { + if let LanguageModelToolResultContent::Image(_) = &output.llm_output { + if !supports_images { + return Err(anyhow!( + "Attempted to read an image, but this model doesn't support it.", + )); + } + } + Ok(output) + }); + + match tool_result { + Ok(output) => LanguageModelToolResult { tool_use_id: tool_use.id, tool_name: tool_use.name, is_error: false, - content: LanguageModelToolResultContent::Text(Arc::from(tool_output)), - output: None, + content: output.llm_output, + output: Some(output.raw_output), }, Err(error) => LanguageModelToolResult { tool_use_id: tool_use.id, @@ -511,24 +543,6 @@ impl Thread { })) } - fn run_tool( - &self, - tool: Arc, - tool_use: LanguageModelToolUse, - event_stream: AgentResponseEventStream, - cx: &mut Context, - ) -> Task> { - cx.spawn(async move |_this, cx| { - let tool_event_stream = ToolCallEventStream::new(tool_use.id, event_stream); - tool_event_stream.send_update(acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::InProgress), - ..Default::default() - }); - cx.update(|cx| tool.run(tool_use.input, tool_event_stream, cx))? - .await - }) - } - fn handle_tool_use_json_parse_error_event( &mut self, tool_use_id: LanguageModelToolUseId, @@ -572,7 +586,7 @@ impl Thread { self.messages.last_mut().unwrap() } - fn build_completion_request( + pub(crate) fn build_completion_request( &self, completion_intent: CompletionIntent, cx: &mut App, @@ -662,6 +676,7 @@ where Self: 'static + Sized, { type Input: for<'de> Deserialize<'de> + Serialize + JsonSchema; + type Output: for<'de> Deserialize<'de> + Serialize + Into; fn name(&self) -> SharedString; @@ -685,23 +700,13 @@ where schemars::schema_for!(Self::Input) } - /// Allows the tool to authorize a given tool call with the user if necessary - fn authorize( - &self, - input: Self::Input, - event_stream: ToolCallEventStream, - ) -> impl use + Future> { - let json_input = serde_json::json!(&input); - event_stream.authorize(self.initial_title(input).into(), self.kind(), json_input) - } - /// Runs the tool with the provided input. fn run( self: Arc, input: Self::Input, event_stream: ToolCallEventStream, cx: &mut App, - ) -> Task>; + ) -> Task>; fn erase(self) -> Arc { Arc::new(Erased(Arc::new(self))) @@ -710,6 +715,11 @@ where pub struct Erased(T); +pub struct AgentToolOutput { + llm_output: LanguageModelToolResultContent, + raw_output: serde_json::Value, +} + pub trait AnyAgentTool { fn name(&self) -> SharedString; fn description(&self, cx: &mut App) -> SharedString; @@ -721,7 +731,7 @@ pub trait AnyAgentTool { input: serde_json::Value, event_stream: ToolCallEventStream, cx: &mut App, - ) -> Task>; + ) -> Task>; } impl AnyAgentTool for Erased> @@ -756,12 +766,18 @@ where input: serde_json::Value, event_stream: ToolCallEventStream, cx: &mut App, - ) -> Task> { - let parsed_input: Result = serde_json::from_value(input).map_err(Into::into); - match parsed_input { - Ok(input) => self.0.clone().run(input, event_stream, cx), - Err(error) => Task::ready(Err(anyhow!(error))), - } + ) -> Task> { + cx.spawn(async move |cx| { + let input = serde_json::from_value(input)?; + let output = cx + .update(|cx| self.0.clone().run(input, event_stream, cx))? + .await?; + let raw_output = serde_json::to_value(&output)?; + Ok(AgentToolOutput { + llm_output: output.into(), + raw_output, + }) + }) } } @@ -874,6 +890,12 @@ impl AgentResponseEventStream { .ok(); } + fn send_tool_call_diff(&self, tool_call_diff: ToolCallDiff) { + self.0 + .unbounded_send(Ok(AgentResponseEvent::ToolCallDiff(tool_call_diff))) + .ok(); + } + fn send_stop(&self, reason: StopReason) { match reason { StopReason::EndTurn => { @@ -903,13 +925,41 @@ impl AgentResponseEventStream { #[derive(Clone)] pub struct ToolCallEventStream { tool_use_id: LanguageModelToolUseId, + kind: acp::ToolKind, + input: serde_json::Value, stream: AgentResponseEventStream, } impl ToolCallEventStream { - fn new(tool_use_id: LanguageModelToolUseId, stream: AgentResponseEventStream) -> Self { + #[cfg(test)] + pub fn test() -> (Self, ToolCallEventStreamReceiver) { + let (events_tx, events_rx) = + mpsc::unbounded::>(); + + let stream = ToolCallEventStream::new( + &LanguageModelToolUse { + id: "test_id".into(), + name: "test_tool".into(), + raw_input: String::new(), + input: serde_json::Value::Null, + is_input_complete: true, + }, + acp::ToolKind::Other, + AgentResponseEventStream(events_tx), + ); + + (stream, ToolCallEventStreamReceiver(events_rx)) + } + + fn new( + tool_use: &LanguageModelToolUse, + kind: acp::ToolKind, + stream: AgentResponseEventStream, + ) -> Self { Self { - tool_use_id, + tool_use_id: tool_use.id.clone(), + kind, + input: tool_use.input.clone(), stream, } } @@ -918,38 +968,52 @@ impl ToolCallEventStream { self.stream.send_tool_call_update(&self.tool_use_id, fields); } - pub fn authorize( - &self, - title: String, - kind: acp::ToolKind, - input: serde_json::Value, - ) -> impl use<> + Future> { - self.stream - .authorize_tool_call(&self.tool_use_id, title, kind, input) + pub fn send_diff(&self, diff: Entity) { + self.stream.send_tool_call_diff(ToolCallDiff { + tool_call_id: acp::ToolCallId(self.tool_use_id.to_string().into()), + diff, + }); + } + + pub fn authorize(&self, title: String) -> impl use<> + Future> { + self.stream.authorize_tool_call( + &self.tool_use_id, + title, + self.kind.clone(), + self.input.clone(), + ) } } #[cfg(test)] -pub struct TestToolCallEventStream { - stream: ToolCallEventStream, - _events_rx: mpsc::UnboundedReceiver>, -} +pub struct ToolCallEventStreamReceiver( + mpsc::UnboundedReceiver>, +); #[cfg(test)] -impl TestToolCallEventStream { - pub fn new() -> Self { - let (events_tx, events_rx) = - mpsc::unbounded::>(); - - let stream = ToolCallEventStream::new("test".into(), AgentResponseEventStream(events_tx)); - - Self { - stream, - _events_rx: events_rx, +impl ToolCallEventStreamReceiver { + pub async fn expect_tool_authorization(&mut self) -> ToolCallAuthorization { + let event = self.0.next().await; + if let Some(Ok(AgentResponseEvent::ToolCallAuthorization(auth))) = event { + auth + } else { + panic!("Expected ToolCallAuthorization but got: {:?}", event); } } +} - pub fn stream(&self) -> ToolCallEventStream { - self.stream.clone() +#[cfg(test)] +impl std::ops::Deref for ToolCallEventStreamReceiver { + type Target = mpsc::UnboundedReceiver>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[cfg(test)] +impl std::ops::DerefMut for ToolCallEventStreamReceiver { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 } } diff --git a/crates/agent2/src/tools.rs b/crates/agent2/src/tools.rs index 240614c263..5fe13db854 100644 --- a/crates/agent2/src/tools.rs +++ b/crates/agent2/src/tools.rs @@ -1,7 +1,9 @@ +mod edit_file_tool; mod find_path_tool; mod read_file_tool; mod thinking_tool; +pub use edit_file_tool::*; pub use find_path_tool::*; pub use read_file_tool::*; pub use thinking_tool::*; diff --git a/crates/agent2/src/tools/edit_file_tool.rs b/crates/agent2/src/tools/edit_file_tool.rs new file mode 100644 index 0000000000..0dbe0be217 --- /dev/null +++ b/crates/agent2/src/tools/edit_file_tool.rs @@ -0,0 +1,1361 @@ +use acp_thread::Diff; +use agent_client_protocol as acp; +use anyhow::{anyhow, Context as _, Result}; +use assistant_tools::edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat}; +use cloud_llm_client::CompletionIntent; +use collections::HashSet; +use gpui::{App, AppContext, AsyncApp, Entity, Task}; +use indoc::formatdoc; +use language::language_settings::{self, FormatOnSave}; +use language_model::LanguageModelToolResultContent; +use paths; +use project::lsp_store::{FormatTrigger, LspFormatTarget}; +use project::{Project, ProjectPath}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::Settings; +use smol::stream::StreamExt as _; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use ui::SharedString; +use util::ResultExt; + +use crate::{AgentTool, Thread, ToolCallEventStream}; + +/// This is a tool for creating a new file or editing an existing file. For moving or renaming files, you should generally use the `terminal` tool with the 'mv' command instead. +/// +/// Before using this tool: +/// +/// 1. Use the `read_file` tool to understand the file's contents and context +/// +/// 2. Verify the directory path is correct (only applicable when creating new files): +/// - 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. + /// + /// 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. + 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. + /// + /// The following examples assume we have two root directories in the project: + /// - /a/b/backend + /// - /c/d/frontend + /// + /// + /// `backend/src/main.rs` + /// + /// 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. + pub mode: EditFileMode, +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum EditFileMode { + Edit, + Create, + Overwrite, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct EditFileToolOutput { + input_path: PathBuf, + project_path: PathBuf, + new_text: String, + old_text: Arc, + diff: String, + edit_agent_output: EditAgentOutput, +} + +impl From for LanguageModelToolResultContent { + fn from(output: EditFileToolOutput) -> Self { + if output.diff.is_empty() { + "No edits were made.".into() + } else { + format!( + "Edited {}:\n\n```diff\n{}\n```", + output.input_path.display(), + output.diff + ) + .into() + } + } +} + +pub struct EditFileTool { + thread: Entity, +} + +impl EditFileTool { + pub fn new(thread: Entity) -> Self { + Self { thread } + } + + fn authorize( + &self, + input: &EditFileToolInput, + event_stream: &ToolCallEventStream, + cx: &App, + ) -> Task> { + if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions { + return Task::ready(Ok(())); + } + + // If any path component matches the local settings folder, then this could affect + // the editor in ways beyond the project source, so prompt. + let local_settings_folder = paths::local_settings_folder_relative_path(); + let path = Path::new(&input.path); + if path + .components() + .any(|component| component.as_os_str() == local_settings_folder.as_os_str()) + { + return cx.foreground_executor().spawn( + event_stream.authorize(format!("{} (local settings)", input.display_description)), + ); + } + + // It's also possible that the global config dir is configured to be inside the project, + // so check for that edge case too. + if let Ok(canonical_path) = std::fs::canonicalize(&input.path) { + if canonical_path.starts_with(paths::config_dir()) { + return cx.foreground_executor().spawn( + event_stream + .authorize(format!("{} (global settings)", input.display_description)), + ); + } + } + + // Check if path is inside the global config directory + // First check if it's already inside project - if not, try to canonicalize + let thread = self.thread.read(cx); + let project_path = thread.project().read(cx).find_project_path(&input.path, cx); + + // If the path is inside the project, and it's not one of the above edge cases, + // then no confirmation is necessary. Otherwise, confirmation is necessary. + if project_path.is_some() { + Task::ready(Ok(())) + } else { + cx.foreground_executor() + .spawn(event_stream.authorize(input.display_description.clone())) + } + } +} + +impl AgentTool for EditFileTool { + type Input = EditFileToolInput; + type Output = EditFileToolOutput; + + fn name(&self) -> SharedString { + "edit_file".into() + } + + fn kind(&self) -> acp::ToolKind { + acp::ToolKind::Edit + } + + fn initial_title(&self, input: Self::Input) -> SharedString { + input.display_description.into() + } + + fn run( + self: Arc, + input: Self::Input, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let project = self.thread.read(cx).project().clone(); + let project_path = match resolve_path(&input, project.clone(), cx) { + Ok(path) => path, + Err(err) => return Task::ready(Err(anyhow!(err))), + }; + + let request = self.thread.update(cx, |thread, cx| { + thread.build_completion_request(CompletionIntent::ToolResults, cx) + }); + let thread = self.thread.read(cx); + let model = thread.selected_model.clone(); + let action_log = thread.action_log().clone(); + + let authorize = self.authorize(&input, &event_stream, cx); + cx.spawn(async move |cx: &mut AsyncApp| { + authorize.await?; + + let edit_format = EditFormat::from_model(model.clone())?; + let edit_agent = EditAgent::new( + model, + project.clone(), + action_log.clone(), + // TODO: move edit agent to this crate so we can use our templates + assistant_tools::templates::Templates::new(), + edit_format, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_buffer(project_path.clone(), cx) + })? + .await?; + + let diff = cx.new(|cx| Diff::new(buffer.clone(), cx))?; + event_stream.send_diff(diff.clone()); + + let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; + let old_text = cx + .background_spawn({ + let old_snapshot = old_snapshot.clone(); + async move { Arc::new(old_snapshot.text()) } + }) + .await; + + + let (output, mut events) = if matches!(input.mode, EditFileMode::Edit) { + edit_agent.edit( + buffer.clone(), + input.display_description.clone(), + &request, + cx, + ) + } else { + edit_agent.overwrite( + buffer.clone(), + input.display_description.clone(), + &request, + cx, + ) + }; + + let mut hallucinated_old_text = false; + let mut ambiguous_ranges = Vec::new(); + while let Some(event) = events.next().await { + match event { + EditAgentOutputEvent::Edited => {}, + EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true, + EditAgentOutputEvent::AmbiguousEditRange(ranges) => ambiguous_ranges = ranges, + EditAgentOutputEvent::ResolvingEditRange(range) => { + diff.update(cx, |card, cx| card.reveal_range(range, cx))?; + } + } + } + + // If format_on_save is enabled, format the buffer + let format_on_save_enabled = buffer + .read_with(cx, |buffer, cx| { + let settings = language_settings::language_settings( + buffer.language().map(|l| l.name()), + buffer.file(), + cx, + ); + settings.format_on_save != FormatOnSave::Off + }) + .unwrap_or(false); + + let edit_agent_output = output.await?; + + if format_on_save_enabled { + action_log.update(cx, |log, cx| { + log.buffer_edited(buffer.clone(), cx); + })?; + + let format_task = project.update(cx, |project, cx| { + project.format( + HashSet::from_iter([buffer.clone()]), + LspFormatTarget::Buffers, + false, // Don't push to history since the tool did it. + FormatTrigger::Save, + cx, + ) + })?; + format_task.await.log_err(); + } + + project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))? + .await?; + + action_log.update(cx, |log, cx| { + log.buffer_edited(buffer.clone(), cx); + })?; + + let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; + let (new_text, unified_diff) = cx + .background_spawn({ + let new_snapshot = new_snapshot.clone(); + let old_text = old_text.clone(); + async move { + let new_text = new_snapshot.text(); + let diff = language::unified_diff(&old_text, &new_text); + (new_text, diff) + } + }) + .await; + + diff.update(cx, |diff, cx| diff.finalize(cx)).ok(); + + let input_path = input.path.display(); + if unified_diff.is_empty() { + anyhow::ensure!( + !hallucinated_old_text, + formatdoc! {" + Some edits were produced but none of them could be applied. + Read the relevant sections of {input_path} again so that + I can perform the requested edits. + "} + ); + anyhow::ensure!( + ambiguous_ranges.is_empty(), + { + let line_numbers = ambiguous_ranges + .iter() + .map(|range| range.start.to_string()) + .collect::>() + .join(", "); + formatdoc! {" + matches more than one position in the file (lines: {line_numbers}). Read the + relevant sections of {input_path} again and extend so + that I can perform the requested edits. + "} + } + ); + } + + Ok(EditFileToolOutput { + input_path: input.path, + project_path: project_path.path.to_path_buf(), + new_text: new_text.clone(), + old_text, + diff: unified_diff, + edit_agent_output, + }) + }) + } +} + +/// Validate that the file path is valid, meaning: +/// +/// - For `edit` and `overwrite`, the path must point to an existing file. +/// - For `create`, the file must not already exist, but it's parent dir must exist. +fn resolve_path( + input: &EditFileToolInput, + project: Entity, + cx: &mut App, +) -> Result { + let project = project.read(cx); + + match input.mode { + EditFileMode::Edit | EditFileMode::Overwrite => { + let path = project + .find_project_path(&input.path, cx) + .context("Can't edit file: path not found")?; + + let entry = project + .entry_for_path(&path, cx) + .context("Can't edit file: path not found")?; + + anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory"); + Ok(path) + } + + EditFileMode::Create => { + if let Some(path) = project.find_project_path(&input.path, cx) { + anyhow::ensure!( + project.entry_for_path(&path, cx).is_none(), + "Can't create file: file already exists" + ); + } + + let parent_path = input + .path + .parent() + .context("Can't create file: incorrect path")?; + + let parent_project_path = project.find_project_path(&parent_path, cx); + + let parent_entry = parent_project_path + .as_ref() + .and_then(|path| project.entry_for_path(&path, cx)) + .context("Can't create file: parent directory doesn't exist")?; + + anyhow::ensure!( + parent_entry.is_dir(), + "Can't create file: parent is not a directory" + ); + + let file_name = input + .path + .file_name() + .context("Can't create file: invalid filename")?; + + let new_file_path = parent_project_path.map(|parent| ProjectPath { + path: Arc::from(parent.path.join(file_name)), + ..parent + }); + + new_file_path.context("Can't create file") + } + } +} + +#[cfg(test)] +mod tests { + use crate::Templates; + + use super::*; + use assistant_tool::ActionLog; + use client::TelemetrySettings; + use fs::Fs; + use gpui::{TestAppContext, UpdateGlobal}; + use language_model::fake_provider::FakeLanguageModel; + use serde_json::json; + use settings::SettingsStore; + use std::rc::Rc; + use util::path; + + #[gpui::test] + async fn test_edit_nonexistent_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree("/root", json!({})).await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let model = Arc::new(FakeLanguageModel::default()); + let thread = + cx.new(|_| Thread::new(project, Rc::default(), action_log, Templates::new(), model)); + let result = cx + .update(|cx| { + let input = EditFileToolInput { + display_description: "Some edit".into(), + path: "root/nonexistent_file.txt".into(), + mode: EditFileMode::Edit, + }; + Arc::new(EditFileTool { thread }).run(input, ToolCallEventStream::test().0, cx) + }) + .await; + assert_eq!( + result.unwrap_err().to_string(), + "Can't edit file: path not found" + ); + } + + #[gpui::test] + async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) { + let mode = &EditFileMode::Create; + + let result = test_resolve_path(mode, "root/new.txt", cx); + assert_resolved_path_eq(result.await, "new.txt"); + + let result = test_resolve_path(mode, "new.txt", cx); + assert_resolved_path_eq(result.await, "new.txt"); + + let result = test_resolve_path(mode, "dir/new.txt", cx); + assert_resolved_path_eq(result.await, "dir/new.txt"); + + let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx); + assert_eq!( + result.await.unwrap_err().to_string(), + "Can't create file: file already exists" + ); + + let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx); + assert_eq!( + result.await.unwrap_err().to_string(), + "Can't create file: parent directory doesn't exist" + ); + } + + #[gpui::test] + async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) { + let mode = &EditFileMode::Edit; + + let path_with_root = "root/dir/subdir/existing.txt"; + let path_without_root = "dir/subdir/existing.txt"; + let result = test_resolve_path(mode, path_with_root, cx); + assert_resolved_path_eq(result.await, path_without_root); + + let result = test_resolve_path(mode, path_without_root, cx); + assert_resolved_path_eq(result.await, path_without_root); + + let result = test_resolve_path(mode, "root/nonexistent.txt", cx); + assert_eq!( + result.await.unwrap_err().to_string(), + "Can't edit file: path not found" + ); + + let result = test_resolve_path(mode, "root/dir", cx); + assert_eq!( + result.await.unwrap_err().to_string(), + "Can't edit file: path is a directory" + ); + } + + async fn test_resolve_path( + mode: &EditFileMode, + path: &str, + cx: &mut TestAppContext, + ) -> anyhow::Result { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "dir": { + "subdir": { + "existing.txt": "hello" + } + } + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + + let input = EditFileToolInput { + display_description: "Some edit".into(), + path: path.into(), + mode: mode.clone(), + }; + + let result = cx.update(|cx| resolve_path(&input, project, cx)); + result + } + + fn assert_resolved_path_eq(path: anyhow::Result, expected: &str) { + let actual = path + .expect("Should return valid path") + .path + .to_str() + .unwrap() + .replace("\\", "/"); // Naive Windows paths normalization + assert_eq!(actual, expected); + } + + #[gpui::test] + async fn test_format_on_save(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree("/root", json!({"src": {}})).await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + + // Set up a Rust language with LSP formatting support + let rust_language = Arc::new(language::Language::new( + language::LanguageConfig { + name: "Rust".into(), + matcher: language::LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + ..Default::default() + }, + None, + )); + + // Register the language and fake LSP + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_language); + + let mut fake_language_servers = language_registry.register_fake_lsp( + "Rust", + language::FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + ..Default::default() + }, + ); + + // Create the file + fs.save( + path!("/root/src/main.rs").as_ref(), + &"initial content".into(), + language::LineEnding::Unix, + ) + .await + .unwrap(); + + // Open the buffer to trigger LSP initialization + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/src/main.rs"), cx) + }) + .await + .unwrap(); + + // Register the buffer with language servers + let _handle = project.update(cx, |project, cx| { + project.register_buffer_with_language_servers(&buffer, cx) + }); + + const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n"; + const FORMATTED_CONTENT: &str = + "This file was formatted by the fake formatter in the test.\n"; + + // Get the fake language server and set up formatting handler + let fake_language_server = fake_language_servers.next().await.unwrap(); + fake_language_server.set_request_handler::({ + |_, _| async move { + Ok(Some(vec![lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)), + new_text: FORMATTED_CONTENT.to_string(), + }])) + } + }); + + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|_| { + Thread::new( + project, + Rc::default(), + action_log.clone(), + Templates::new(), + model.clone(), + ) + }); + + // First, test with format_on_save enabled + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings::( + cx, + |settings| { + settings.defaults.format_on_save = Some(FormatOnSave::On); + settings.defaults.formatter = + Some(language::language_settings::SelectedFormatter::Auto); + }, + ); + }); + }); + + // Have the model stream unformatted content + let edit_result = { + let edit_task = cx.update(|cx| { + let input = EditFileToolInput { + display_description: "Create main function".into(), + path: "root/src/main.rs".into(), + mode: EditFileMode::Overwrite, + }; + Arc::new(EditFileTool { + thread: thread.clone(), + }) + .run(input, ToolCallEventStream::test().0, cx) + }); + + // Stream the unformatted content + cx.executor().run_until_parked(); + model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string()); + model.end_last_completion_stream(); + + edit_task.await + }; + assert!(edit_result.is_ok()); + + // Wait for any async operations (e.g. formatting) to complete + cx.executor().run_until_parked(); + + // Read the file to verify it was formatted automatically + let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); + assert_eq!( + // Ignore carriage returns on Windows + new_content.replace("\r\n", "\n"), + FORMATTED_CONTENT, + "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()); + + assert_eq!( + stale_buffer_count, 0, + "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \ + This causes the agent to think the file was modified externally when it was just formatted.", + stale_buffer_count + ); + + // Next, test with format_on_save disabled + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings::( + cx, + |settings| { + settings.defaults.format_on_save = Some(FormatOnSave::Off); + }, + ); + }); + }); + + // Stream unformatted edits again + let edit_result = { + let edit_task = cx.update(|cx| { + let input = EditFileToolInput { + display_description: "Update main function".into(), + path: "root/src/main.rs".into(), + mode: EditFileMode::Overwrite, + }; + Arc::new(EditFileTool { thread }).run(input, ToolCallEventStream::test().0, cx) + }); + + // Stream the unformatted content + cx.executor().run_until_parked(); + model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string()); + model.end_last_completion_stream(); + + edit_task.await + }; + assert!(edit_result.is_ok()); + + // Wait for any async operations (e.g. formatting) to complete + cx.executor().run_until_parked(); + + // Verify the file was not formatted + let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); + assert_eq!( + // Ignore carriage returns on Windows + new_content.replace("\r\n", "\n"), + UNFORMATTED_CONTENT, + "Code should not be formatted when format_on_save is disabled" + ); + } + + #[gpui::test] + async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree("/root", json!({"src": {}})).await; + + // Create a simple file with trailing whitespace + fs.save( + path!("/root/src/main.rs").as_ref(), + &"initial content".into(), + language::LineEnding::Unix, + ) + .await + .unwrap(); + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|_| { + Thread::new( + project, + Rc::default(), + action_log.clone(), + Templates::new(), + model.clone(), + ) + }); + + // First, test with remove_trailing_whitespace_on_save enabled + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings::( + cx, + |settings| { + settings.defaults.remove_trailing_whitespace_on_save = Some(true); + }, + ); + }); + }); + + const CONTENT_WITH_TRAILING_WHITESPACE: &str = + "fn main() { \n println!(\"Hello!\"); \n}\n"; + + // Have the model stream content that contains trailing whitespace + let edit_result = { + let edit_task = cx.update(|cx| { + let input = EditFileToolInput { + display_description: "Create main function".into(), + path: "root/src/main.rs".into(), + mode: EditFileMode::Overwrite, + }; + Arc::new(EditFileTool { + thread: thread.clone(), + }) + .run(input, ToolCallEventStream::test().0, cx) + }); + + // Stream the content with trailing whitespace + cx.executor().run_until_parked(); + model.send_last_completion_stream_text_chunk( + CONTENT_WITH_TRAILING_WHITESPACE.to_string(), + ); + model.end_last_completion_stream(); + + edit_task.await + }; + assert!(edit_result.is_ok()); + + // Wait for any async operations (e.g. formatting) to complete + cx.executor().run_until_parked(); + + // Read the file to verify trailing whitespace was removed automatically + assert_eq!( + // Ignore carriage returns on Windows + fs.load(path!("/root/src/main.rs").as_ref()) + .await + .unwrap() + .replace("\r\n", "\n"), + "fn main() {\n println!(\"Hello!\");\n}\n", + "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled" + ); + + // Next, test with remove_trailing_whitespace_on_save disabled + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings::( + cx, + |settings| { + settings.defaults.remove_trailing_whitespace_on_save = Some(false); + }, + ); + }); + }); + + // Stream edits again with trailing whitespace + let edit_result = { + let edit_task = cx.update(|cx| { + let input = EditFileToolInput { + display_description: "Update main function".into(), + path: "root/src/main.rs".into(), + mode: EditFileMode::Overwrite, + }; + Arc::new(EditFileTool { + thread: thread.clone(), + }) + .run(input, ToolCallEventStream::test().0, cx) + }); + + // Stream the content with trailing whitespace + cx.executor().run_until_parked(); + model.send_last_completion_stream_text_chunk( + CONTENT_WITH_TRAILING_WHITESPACE.to_string(), + ); + model.end_last_completion_stream(); + + edit_task.await + }; + assert!(edit_result.is_ok()); + + // Wait for any async operations (e.g. formatting) to complete + cx.executor().run_until_parked(); + + // Verify the file still has trailing whitespace + // Read the file again - it should still have trailing whitespace + let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); + assert_eq!( + // Ignore carriage returns on Windows + final_content.replace("\r\n", "\n"), + CONTENT_WITH_TRAILING_WHITESPACE, + "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled" + ); + } + + #[gpui::test] + async fn test_authorize(cx: &mut TestAppContext) { + init_test(cx); + let fs = project::FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|_| { + Thread::new( + project, + Rc::default(), + action_log.clone(), + Templates::new(), + model.clone(), + ) + }); + let tool = Arc::new(EditFileTool { thread }); + fs.insert_tree("/root", json!({})).await; + + // Test 1: Path with .zed component should require confirmation + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let _auth = cx.update(|cx| { + tool.authorize( + &EditFileToolInput { + display_description: "test 1".into(), + path: ".zed/settings.json".into(), + mode: EditFileMode::Edit, + }, + &stream_tx, + cx, + ) + }); + + let event = stream_rx.expect_tool_authorization().await; + assert_eq!(event.tool_call.title, "test 1 (local settings)"); + + // Test 2: Path outside project should require confirmation + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let _auth = cx.update(|cx| { + tool.authorize( + &EditFileToolInput { + display_description: "test 2".into(), + path: "/etc/hosts".into(), + mode: EditFileMode::Edit, + }, + &stream_tx, + cx, + ) + }); + + let event = stream_rx.expect_tool_authorization().await; + assert_eq!(event.tool_call.title, "test 2"); + + // Test 3: Relative path without .zed should not require confirmation + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + cx.update(|cx| { + tool.authorize( + &EditFileToolInput { + display_description: "test 3".into(), + path: "root/src/main.rs".into(), + mode: EditFileMode::Edit, + }, + &stream_tx, + cx, + ) + }) + .await + .unwrap(); + assert!(stream_rx.try_next().is_err()); + + // Test 4: Path with .zed in the middle should require confirmation + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let _auth = cx.update(|cx| { + tool.authorize( + &EditFileToolInput { + display_description: "test 4".into(), + path: "root/.zed/tasks.json".into(), + mode: EditFileMode::Edit, + }, + &stream_tx, + cx, + ) + }); + let event = stream_rx.expect_tool_authorization().await; + assert_eq!(event.tool_call.title, "test 4 (local settings)"); + + // Test 5: When always_allow_tool_actions is enabled, no confirmation needed + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.always_allow_tool_actions = true; + agent_settings::AgentSettings::override_global(settings, cx); + }); + + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + cx.update(|cx| { + tool.authorize( + &EditFileToolInput { + display_description: "test 5.1".into(), + path: ".zed/settings.json".into(), + mode: EditFileMode::Edit, + }, + &stream_tx, + cx, + ) + }) + .await + .unwrap(); + assert!(stream_rx.try_next().is_err()); + + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + cx.update(|cx| { + tool.authorize( + &EditFileToolInput { + display_description: "test 5.2".into(), + path: "/etc/hosts".into(), + mode: EditFileMode::Edit, + }, + &stream_tx, + cx, + ) + }) + .await + .unwrap(); + assert!(stream_rx.try_next().is_err()); + } + + #[gpui::test] + async fn test_authorize_global_config(cx: &mut TestAppContext) { + init_test(cx); + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree("/project", json!({})).await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|_| { + Thread::new( + project, + Rc::default(), + action_log.clone(), + Templates::new(), + model.clone(), + ) + }); + let tool = Arc::new(EditFileTool { thread }); + + // Test global config paths - these should require confirmation if they exist and are outside the project + let test_cases = vec![ + ( + "/etc/hosts", + true, + "System file should require confirmation", + ), + ( + "/usr/local/bin/script", + true, + "System bin file should require confirmation", + ), + ( + "project/normal_file.rs", + false, + "Normal project file should not require confirmation", + ), + ]; + + for (path, should_confirm, description) in test_cases { + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let auth = cx.update(|cx| { + tool.authorize( + &EditFileToolInput { + display_description: "Edit file".into(), + path: path.into(), + mode: EditFileMode::Edit, + }, + &stream_tx, + cx, + ) + }); + + if should_confirm { + stream_rx.expect_tool_authorization().await; + } else { + auth.await.unwrap(); + assert!( + stream_rx.try_next().is_err(), + "Failed for case: {} - path: {} - expected no confirmation but got one", + description, + path + ); + } + } + } + + #[gpui::test] + async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) { + init_test(cx); + let fs = project::FakeFs::new(cx.executor()); + + // Create multiple worktree directories + fs.insert_tree( + "/workspace/frontend", + json!({ + "src": { + "main.js": "console.log('frontend');" + } + }), + ) + .await; + fs.insert_tree( + "/workspace/backend", + json!({ + "src": { + "main.rs": "fn main() {}" + } + }), + ) + .await; + fs.insert_tree( + "/workspace/shared", + json!({ + ".zed": { + "settings.json": "{}" + } + }), + ) + .await; + + // Create project with multiple worktrees + let project = Project::test( + fs.clone(), + [ + path!("/workspace/frontend").as_ref(), + path!("/workspace/backend").as_ref(), + path!("/workspace/shared").as_ref(), + ], + cx, + ) + .await; + + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|_| { + Thread::new( + project.clone(), + Rc::default(), + action_log.clone(), + Templates::new(), + model.clone(), + ) + }); + let tool = Arc::new(EditFileTool { thread }); + + // Test files in different worktrees + let test_cases = vec![ + ("frontend/src/main.js", false, "File in first worktree"), + ("backend/src/main.rs", false, "File in second worktree"), + ( + "shared/.zed/settings.json", + true, + ".zed file in third worktree", + ), + ("/etc/hosts", true, "Absolute path outside all worktrees"), + ( + "../outside/file.txt", + true, + "Relative path outside worktrees", + ), + ]; + + for (path, should_confirm, description) in test_cases { + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let auth = cx.update(|cx| { + tool.authorize( + &EditFileToolInput { + display_description: "Edit file".into(), + path: path.into(), + mode: EditFileMode::Edit, + }, + &stream_tx, + cx, + ) + }); + + if should_confirm { + stream_rx.expect_tool_authorization().await; + } else { + auth.await.unwrap(); + assert!( + stream_rx.try_next().is_err(), + "Failed for case: {} - path: {} - expected no confirmation but got one", + description, + path + ); + } + } + } + + #[gpui::test] + async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) { + init_test(cx); + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + ".zed": { + "settings.json": "{}" + }, + "src": { + ".zed": { + "local.json": "{}" + } + } + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|_| { + Thread::new( + project.clone(), + Rc::default(), + action_log.clone(), + Templates::new(), + model.clone(), + ) + }); + let tool = Arc::new(EditFileTool { thread }); + + // Test edge cases + let test_cases = vec![ + // Empty path - find_project_path returns Some for empty paths + ("", false, "Empty path is treated as project root"), + // Root directory + ("/", true, "Root directory should be outside project"), + // Parent directory references - find_project_path resolves these + ( + "project/../other", + false, + "Path with .. is resolved by find_project_path", + ), + ( + "project/./src/file.rs", + false, + "Path with . should work normally", + ), + // Windows-style paths (if on Windows) + #[cfg(target_os = "windows")] + ("C:\\Windows\\System32\\hosts", true, "Windows system path"), + #[cfg(target_os = "windows")] + ("project\\src\\main.rs", false, "Windows-style project path"), + ]; + + for (path, should_confirm, description) in test_cases { + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let auth = cx.update(|cx| { + tool.authorize( + &EditFileToolInput { + display_description: "Edit file".into(), + path: path.into(), + mode: EditFileMode::Edit, + }, + &stream_tx, + cx, + ) + }); + + if should_confirm { + stream_rx.expect_tool_authorization().await; + } else { + auth.await.unwrap(); + assert!( + stream_rx.try_next().is_err(), + "Failed for case: {} - path: {} - expected no confirmation but got one", + description, + path + ); + } + } + } + + #[gpui::test] + async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) { + init_test(cx); + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + "existing.txt": "content", + ".zed": { + "settings.json": "{}" + } + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|_| { + Thread::new( + project.clone(), + Rc::default(), + action_log.clone(), + Templates::new(), + model.clone(), + ) + }); + let tool = Arc::new(EditFileTool { thread }); + + // Test different EditFileMode values + let modes = vec![ + EditFileMode::Edit, + EditFileMode::Create, + EditFileMode::Overwrite, + ]; + + for mode in modes { + // Test .zed path with different modes + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let _auth = cx.update(|cx| { + tool.authorize( + &EditFileToolInput { + display_description: "Edit settings".into(), + path: "project/.zed/settings.json".into(), + mode: mode.clone(), + }, + &stream_tx, + cx, + ) + }); + + stream_rx.expect_tool_authorization().await; + + // Test outside path with different modes + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let _auth = cx.update(|cx| { + tool.authorize( + &EditFileToolInput { + display_description: "Edit file".into(), + path: "/outside/file.txt".into(), + mode: mode.clone(), + }, + &stream_tx, + cx, + ) + }); + + stream_rx.expect_tool_authorization().await; + + // Test normal path with different modes + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + cx.update(|cx| { + tool.authorize( + &EditFileToolInput { + display_description: "Edit file".into(), + path: "project/normal.txt".into(), + mode: mode.clone(), + }, + &stream_tx, + cx, + ) + }) + .await + .unwrap(); + assert!(stream_rx.try_next().is_err()); + } + } + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + language::init(cx); + TelemetrySettings::register(cx); + agent_settings::AgentSettings::register(cx); + Project::init_settings(cx); + }); + } +} diff --git a/crates/agent2/src/tools/find_path_tool.rs b/crates/agent2/src/tools/find_path_tool.rs index e840fec78c..24bdcded8c 100644 --- a/crates/agent2/src/tools/find_path_tool.rs +++ b/crates/agent2/src/tools/find_path_tool.rs @@ -1,6 +1,8 @@ +use crate::{AgentTool, ToolCallEventStream}; use agent_client_protocol as acp; use anyhow::{anyhow, Result}; use gpui::{App, AppContext, Entity, SharedString, Task}; +use language_model::LanguageModelToolResultContent; use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -8,8 +10,6 @@ use std::fmt::Write; use std::{cmp, path::PathBuf, sync::Arc}; use util::paths::PathMatcher; -use crate::{AgentTool, ToolCallEventStream}; - /// Fast file path pattern matching tool that works with any codebase size /// /// - Supports glob patterns like "**/*.js" or "src/**/*.ts" @@ -39,8 +39,35 @@ pub struct FindPathToolInput { } #[derive(Debug, Serialize, Deserialize)] -struct FindPathToolOutput { - paths: Vec, +pub struct FindPathToolOutput { + offset: usize, + current_matches_page: Vec, + all_matches_len: usize, +} + +impl From for LanguageModelToolResultContent { + fn from(output: FindPathToolOutput) -> Self { + if output.current_matches_page.is_empty() { + "No matches found".into() + } else { + let mut llm_output = format!("Found {} total matches.", output.all_matches_len); + if output.all_matches_len > RESULTS_PER_PAGE { + write!( + &mut llm_output, + "\nShowing results {}-{} (provide 'offset' parameter for more results):", + output.offset + 1, + output.offset + output.current_matches_page.len() + ) + .unwrap(); + } + + for mat in output.current_matches_page { + write!(&mut llm_output, "\n{}", mat.display()).unwrap(); + } + + llm_output.into() + } + } } const RESULTS_PER_PAGE: usize = 50; @@ -57,6 +84,7 @@ impl FindPathTool { impl AgentTool for FindPathTool { type Input = FindPathToolInput; + type Output = FindPathToolOutput; fn name(&self) -> SharedString { "find_path".into() @@ -75,7 +103,7 @@ impl AgentTool for FindPathTool { input: Self::Input, event_stream: ToolCallEventStream, cx: &mut App, - ) -> Task> { + ) -> Task> { let search_paths_task = search_paths(&input.glob, self.project.clone(), cx); cx.background_spawn(async move { @@ -113,26 +141,11 @@ impl AgentTool for FindPathTool { ..Default::default() }); - if matches.is_empty() { - Ok("No matches found".into()) - } else { - let mut message = format!("Found {} total matches.", matches.len()); - if matches.len() > RESULTS_PER_PAGE { - write!( - &mut message, - "\nShowing results {}-{} (provide 'offset' parameter for more results):", - input.offset + 1, - input.offset + paginated_matches.len() - ) - .unwrap(); - } - - for mat in matches.iter().skip(input.offset).take(RESULTS_PER_PAGE) { - write!(&mut message, "\n{}", mat.display()).unwrap(); - } - - Ok(message) - } + Ok(FindPathToolOutput { + offset: input.offset, + current_matches_page: paginated_matches.to_vec(), + all_matches_len: matches.len(), + }) }) } } diff --git a/crates/agent2/src/tools/read_file_tool.rs b/crates/agent2/src/tools/read_file_tool.rs index 30794ccdad..3d91e3dc74 100644 --- a/crates/agent2/src/tools/read_file_tool.rs +++ b/crates/agent2/src/tools/read_file_tool.rs @@ -1,10 +1,11 @@ use agent_client_protocol::{self as acp}; -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Context, Result}; use assistant_tool::{outline, ActionLog}; use gpui::{Entity, Task}; use indoc::formatdoc; use language::{Anchor, Point}; -use project::{AgentLocation, Project, WorktreeSettings}; +use language_model::{LanguageModelImage, LanguageModelToolResultContent}; +use project::{image_store, AgentLocation, ImageItem, Project, WorktreeSettings}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::Settings; @@ -59,6 +60,7 @@ impl ReadFileTool { impl AgentTool for ReadFileTool { type Input = ReadFileToolInput; + type Output = LanguageModelToolResultContent; fn name(&self) -> SharedString { "read_file".into() @@ -91,9 +93,9 @@ impl AgentTool for ReadFileTool { fn run( self: Arc, input: Self::Input, - event_stream: ToolCallEventStream, + _event_stream: ToolCallEventStream, cx: &mut App, - ) -> Task> { + ) -> Task> { let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx) else { return Task::ready(Err(anyhow!("Path {} not found in project", &input.path))); }; @@ -132,51 +134,27 @@ impl AgentTool for ReadFileTool { let file_path = input.path.clone(); - event_stream.send_update(acp::ToolCallUpdateFields { - locations: Some(vec![acp::ToolCallLocation { - path: project_path.path.to_path_buf(), - line: input.start_line, - // TODO (tracked): use full range - }]), - ..Default::default() - }); + if image_store::is_image_file(&self.project, &project_path, cx) { + return cx.spawn(async move |cx| { + let image_entity: Entity = cx + .update(|cx| { + self.project.update(cx, |project, cx| { + project.open_image(project_path.clone(), cx) + }) + })? + .await?; - // TODO (tracked): images - // if image_store::is_image_file(&self.project, &project_path, cx) { - // let model = &self.thread.read(cx).selected_model; + let image = + image_entity.read_with(cx, |image_item, _| Arc::clone(&image_item.image))?; - // if !model.supports_images() { - // return Task::ready(Err(anyhow!( - // "Attempted to read an image, but Zed doesn't currently support sending images to {}.", - // model.name().0 - // ))) - // .into(); - // } + let language_model_image = cx + .update(|cx| LanguageModelImage::from_image(image, cx))? + .await + .context("processing image")?; - // return cx.spawn(async move |cx| -> Result { - // let image_entity: Entity = cx - // .update(|cx| { - // self.project.update(cx, |project, cx| { - // project.open_image(project_path.clone(), cx) - // }) - // })? - // .await?; - - // let image = - // image_entity.read_with(cx, |image_item, _| Arc::clone(&image_item.image))?; - - // let language_model_image = cx - // .update(|cx| LanguageModelImage::from_image(image, cx))? - // .await - // .context("processing image")?; - - // Ok(ToolResultOutput { - // content: ToolResultContent::Image(language_model_image), - // output: None, - // }) - // }); - // } - // + Ok(language_model_image.into()) + }); + } let project = self.project.clone(); let action_log = self.action_log.clone(); @@ -244,7 +222,7 @@ impl AgentTool for ReadFileTool { })?; } - Ok(result) + Ok(result.into()) } else { // No line ranges specified, so check file size to see if it's too big. let file_size = buffer.read_with(cx, |buffer, _cx| buffer.text().len())?; @@ -257,7 +235,7 @@ impl AgentTool for ReadFileTool { log.buffer_read(buffer, cx); })?; - Ok(result) + Ok(result.into()) } else { // File is too big, so return the outline // and a suggestion to read again with line numbers. @@ -276,7 +254,8 @@ impl AgentTool for ReadFileTool { Alternatively, you can fall back to the `grep` tool (if available) to search the file for specific content." - }) + } + .into()) } } }) @@ -285,8 +264,6 @@ impl AgentTool for ReadFileTool { #[cfg(test)] mod test { - use crate::TestToolCallEventStream; - use super::*; use gpui::{AppContext, TestAppContext, UpdateGlobal as _}; use language::{tree_sitter_rust, Language, LanguageConfig, LanguageMatcher}; @@ -304,7 +281,7 @@ mod test { let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); let tool = Arc::new(ReadFileTool::new(project, action_log)); - let event_stream = TestToolCallEventStream::new(); + let (event_stream, _) = ToolCallEventStream::test(); let result = cx .update(|cx| { @@ -313,7 +290,7 @@ mod test { start_line: None, end_line: None, }; - tool.run(input, event_stream.stream(), cx) + tool.run(input, event_stream, cx) }) .await; assert_eq!( @@ -321,6 +298,7 @@ mod test { "root/nonexistent_file.txt not found" ); } + #[gpui::test] async fn test_read_small_file(cx: &mut TestAppContext) { init_test(cx); @@ -336,7 +314,6 @@ mod test { let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); let tool = Arc::new(ReadFileTool::new(project, action_log)); - let event_stream = TestToolCallEventStream::new(); let result = cx .update(|cx| { let input = ReadFileToolInput { @@ -344,10 +321,10 @@ mod test { start_line: None, end_line: None, }; - tool.run(input, event_stream.stream(), cx) + tool.run(input, ToolCallEventStream::test().0, cx) }) .await; - assert_eq!(result.unwrap(), "This is a small file content"); + assert_eq!(result.unwrap(), "This is a small file content".into()); } #[gpui::test] @@ -367,18 +344,18 @@ mod test { language_registry.add(Arc::new(rust_lang())); let action_log = cx.new(|_| ActionLog::new(project.clone())); let tool = Arc::new(ReadFileTool::new(project, action_log)); - let event_stream = TestToolCallEventStream::new(); - let content = cx + let result = cx .update(|cx| { let input = ReadFileToolInput { path: "root/large_file.rs".into(), start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await .unwrap(); + let content = result.to_str().unwrap(); assert_eq!( content.lines().skip(4).take(6).collect::>(), @@ -399,10 +376,11 @@ mod test { start_line: None, end_line: None, }; - tool.run(input, event_stream.stream(), cx) + tool.run(input, ToolCallEventStream::test().0, cx) }) - .await; - let content = result.unwrap(); + .await + .unwrap(); + let content = result.to_str().unwrap(); let expected_content = (0..1000) .flat_map(|i| { vec![ @@ -438,7 +416,6 @@ mod test { let action_log = cx.new(|_| ActionLog::new(project.clone())); let tool = Arc::new(ReadFileTool::new(project, action_log)); - let event_stream = TestToolCallEventStream::new(); let result = cx .update(|cx| { let input = ReadFileToolInput { @@ -446,10 +423,10 @@ mod test { start_line: Some(2), end_line: Some(4), }; - tool.run(input, event_stream.stream(), cx) + tool.run(input, ToolCallEventStream::test().0, cx) }) .await; - assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4"); + assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4".into()); } #[gpui::test] @@ -467,7 +444,6 @@ mod test { let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); let tool = Arc::new(ReadFileTool::new(project, action_log)); - let event_stream = TestToolCallEventStream::new(); // start_line of 0 should be treated as 1 let result = cx @@ -477,10 +453,10 @@ mod test { start_line: Some(0), end_line: Some(2), }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; - assert_eq!(result.unwrap(), "Line 1\nLine 2"); + assert_eq!(result.unwrap(), "Line 1\nLine 2".into()); // end_line of 0 should result in at least 1 line let result = cx @@ -490,10 +466,10 @@ mod test { start_line: Some(1), end_line: Some(0), }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; - assert_eq!(result.unwrap(), "Line 1"); + assert_eq!(result.unwrap(), "Line 1".into()); // when start_line > end_line, should still return at least 1 line let result = cx @@ -503,10 +479,10 @@ mod test { start_line: Some(3), end_line: Some(2), }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; - assert_eq!(result.unwrap(), "Line 3"); + assert_eq!(result.unwrap(), "Line 3".into()); } fn init_test(cx: &mut TestAppContext) { @@ -612,7 +588,6 @@ mod test { let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); let tool = Arc::new(ReadFileTool::new(project, action_log)); - let event_stream = TestToolCallEventStream::new(); // Reading a file outside the project worktree should fail let result = cx @@ -622,7 +597,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; assert!( @@ -638,7 +613,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; assert!( @@ -654,7 +629,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; assert!( @@ -669,7 +644,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; assert!( @@ -685,7 +660,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; assert!( @@ -700,7 +675,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; assert!( @@ -715,7 +690,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; assert!( @@ -731,11 +706,11 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; assert!(result.is_ok(), "Should be able to read normal files"); - assert_eq!(result.unwrap(), "Normal file content"); + assert_eq!(result.unwrap(), "Normal file content".into()); // Path traversal attempts with .. should fail let result = cx @@ -745,7 +720,7 @@ mod test { start_line: None, end_line: None, }; - tool.run(input, event_stream.stream(), cx) + tool.run(input, ToolCallEventStream::test().0, cx) }) .await; assert!( @@ -826,7 +801,6 @@ mod test { let action_log = cx.new(|_| ActionLog::new(project.clone())); let tool = Arc::new(ReadFileTool::new(project.clone(), action_log.clone())); - let event_stream = TestToolCallEventStream::new(); // Test reading allowed files in worktree1 let result = cx @@ -836,12 +810,15 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await .unwrap(); - assert_eq!(result, "fn main() { println!(\"Hello from worktree1\"); }"); + assert_eq!( + result, + "fn main() { println!(\"Hello from worktree1\"); }".into() + ); // Test reading private file in worktree1 should fail let result = cx @@ -851,7 +828,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; @@ -872,7 +849,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; @@ -893,14 +870,14 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await .unwrap(); assert_eq!( result, - "export function greet() { return 'Hello from worktree2'; }" + "export function greet() { return 'Hello from worktree2'; }".into() ); // Test reading private file in worktree2 should fail @@ -911,7 +888,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; @@ -932,7 +909,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; @@ -954,7 +931,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; diff --git a/crates/agent2/src/tools/thinking_tool.rs b/crates/agent2/src/tools/thinking_tool.rs index bb85d8eceb..d85370e7e5 100644 --- a/crates/agent2/src/tools/thinking_tool.rs +++ b/crates/agent2/src/tools/thinking_tool.rs @@ -20,6 +20,7 @@ pub struct ThinkingTool; impl AgentTool for ThinkingTool { type Input = ThinkingToolInput; + type Output = String; fn name(&self) -> SharedString { "thinking".into() diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 3d1fbba45d..7f4e7e7208 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -42,7 +42,7 @@ use workspace::{CollaboratorId, Workspace}; use zed_actions::agent::{Chat, NextHistoryMessage, PreviousHistoryMessage}; use ::acp_thread::{ - AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk, Diff, + AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk, LoadError, MentionPath, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus, }; @@ -732,7 +732,11 @@ impl AcpThreadView { cx: &App, ) -> Option>> { let entry = self.thread()?.read(cx).entries().get(entry_ix)?; - Some(entry.diffs().map(|diff| diff.multibuffer.clone())) + Some( + entry + .diffs() + .map(|diff| diff.read(cx).multibuffer().clone()), + ) } fn authenticate( @@ -1314,10 +1318,9 @@ impl AcpThreadView { Empty.into_any_element() } } - ToolCallContent::Diff { - diff: Diff { multibuffer, .. }, - .. - } => self.render_diff_editor(multibuffer), + ToolCallContent::Diff { diff, .. } => { + self.render_diff_editor(&diff.read(cx).multibuffer()) + } } } diff --git a/crates/assistant_tools/src/assistant_tools.rs b/crates/assistant_tools/src/assistant_tools.rs index 90bb2e9b7c..bf668e6918 100644 --- a/crates/assistant_tools/src/assistant_tools.rs +++ b/crates/assistant_tools/src/assistant_tools.rs @@ -2,7 +2,7 @@ mod copy_path_tool; mod create_directory_tool; mod delete_path_tool; mod diagnostics_tool; -mod edit_agent; +pub mod edit_agent; mod edit_file_tool; mod fetch_tool; mod find_path_tool; @@ -14,7 +14,7 @@ mod open_tool; mod project_notifications_tool; mod read_file_tool; mod schema; -mod templates; +pub mod templates; mod terminal_tool; mod thinking_tool; mod ui; diff --git a/crates/assistant_tools/src/edit_agent.rs b/crates/assistant_tools/src/edit_agent.rs index 715d106a26..dcb14a48f3 100644 --- a/crates/assistant_tools/src/edit_agent.rs +++ b/crates/assistant_tools/src/edit_agent.rs @@ -29,7 +29,6 @@ use serde::{Deserialize, Serialize}; use std::{cmp, iter, mem, ops::Range, path::PathBuf, pin::Pin, sync::Arc, task::Poll}; use streaming_diff::{CharOperation, StreamingDiff}; use streaming_fuzzy_matcher::StreamingFuzzyMatcher; -use util::debug_panic; #[derive(Serialize)] struct CreateFilePromptTemplate { @@ -682,11 +681,6 @@ impl EditAgent { if last_message.content.is_empty() { conversation.messages.pop(); } - } else { - debug_panic!( - "Last message must be an Assistant tool calling! Got {:?}", - last_message.content - ); } } diff --git a/crates/assistant_tools/src/edit_file_tool.rs b/crates/assistant_tools/src/edit_file_tool.rs index dce9f49abd..311521019d 100644 --- a/crates/assistant_tools/src/edit_file_tool.rs +++ b/crates/assistant_tools/src/edit_file_tool.rs @@ -120,8 +120,6 @@ struct PartialInput { display_description: String, } -const DEFAULT_UI_TEXT: &str = "Editing file"; - impl Tool for EditFileTool { fn name(&self) -> String { "edit_file".into() @@ -211,22 +209,6 @@ impl Tool for EditFileTool { } } - fn still_streaming_ui_text(&self, input: &serde_json::Value) -> String { - if let Some(input) = serde_json::from_value::(input.clone()).ok() { - let description = input.display_description.trim(); - if !description.is_empty() { - return description.to_string(); - } - - let path = input.path.trim(); - if !path.is_empty() { - return path.to_string(); - } - } - - DEFAULT_UI_TEXT.to_string() - } - fn run( self: Arc, input: serde_json::Value, @@ -1370,73 +1352,6 @@ mod tests { assert_eq!(actual, expected); } - #[test] - fn still_streaming_ui_text_with_path() { - let input = json!({ - "path": "src/main.rs", - "display_description": "", - "old_string": "old code", - "new_string": "new code" - }); - - assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs"); - } - - #[test] - fn still_streaming_ui_text_with_description() { - let input = json!({ - "path": "", - "display_description": "Fix error handling", - "old_string": "old code", - "new_string": "new code" - }); - - assert_eq!( - EditFileTool.still_streaming_ui_text(&input), - "Fix error handling", - ); - } - - #[test] - fn still_streaming_ui_text_with_path_and_description() { - let input = json!({ - "path": "src/main.rs", - "display_description": "Fix error handling", - "old_string": "old code", - "new_string": "new code" - }); - - assert_eq!( - EditFileTool.still_streaming_ui_text(&input), - "Fix error handling", - ); - } - - #[test] - fn still_streaming_ui_text_no_path_or_description() { - let input = json!({ - "path": "", - "display_description": "", - "old_string": "old code", - "new_string": "new code" - }); - - assert_eq!( - EditFileTool.still_streaming_ui_text(&input), - DEFAULT_UI_TEXT, - ); - } - - #[test] - fn still_streaming_ui_text_with_null() { - let input = serde_json::Value::Null; - - assert_eq!( - EditFileTool.still_streaming_ui_text(&input), - DEFAULT_UI_TEXT, - ); - } - fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); diff --git a/crates/language_model/src/request.rs b/crates/language_model/src/request.rs index dc485e9937..edce3d03b7 100644 --- a/crates/language_model/src/request.rs +++ b/crates/language_model/src/request.rs @@ -297,6 +297,12 @@ impl From for LanguageModelToolResultContent { } } +impl From for LanguageModelToolResultContent { + fn from(image: LanguageModelImage) -> Self { + Self::Image(image) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)] pub enum MessageContent { Text(String), From d5c4e4b7b2cb8e9bef1bdc955ffd630d4230e192 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Fri, 8 Aug 2025 15:54:26 +0200 Subject: [PATCH 002/109] languages: Fix digest check on downloaded artifact for clangd (#35870) Closes 35864 Release Notes: - N/A --- crates/languages/src/c.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/languages/src/c.rs b/crates/languages/src/c.rs index a55d8ff998..df93e51760 100644 --- a/crates/languages/src/c.rs +++ b/crates/languages/src/c.rs @@ -75,6 +75,9 @@ impl super::LspAdapter for CLspAdapter { &*version.downcast::().unwrap(); let version_dir = container_dir.join(format!("clangd_{name}")); let binary_path = version_dir.join("bin/clangd"); + let expected_digest = digest + .as_ref() + .and_then(|digest| digest.strip_prefix("sha256:")); let binary = LanguageServerBinary { path: binary_path.clone(), @@ -99,7 +102,9 @@ impl super::LspAdapter for CLspAdapter { log::warn!("Unable to run {binary_path:?} asset, redownloading: {err}",) }) }; - if let (Some(actual_digest), Some(expected_digest)) = (&metadata.digest, digest) { + if let (Some(actual_digest), Some(expected_digest)) = + (&metadata.digest, expected_digest) + { if actual_digest == expected_digest { if validity_check().await.is_ok() { return Ok(binary); From 8430197df0ffde444c5f4286fc7c22875368709c Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 8 Aug 2025 15:56:07 +0200 Subject: [PATCH 003/109] Restore accidentally deleted `EditFileTool::still_streaming_ui_text` (#35871) This was accidentally removed in #35844. Release Notes: - N/A Co-authored-by: Ben Brandt --- crates/assistant_tools/src/edit_file_tool.rs | 85 ++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/crates/assistant_tools/src/edit_file_tool.rs b/crates/assistant_tools/src/edit_file_tool.rs index 311521019d..dce9f49abd 100644 --- a/crates/assistant_tools/src/edit_file_tool.rs +++ b/crates/assistant_tools/src/edit_file_tool.rs @@ -120,6 +120,8 @@ struct PartialInput { display_description: String, } +const DEFAULT_UI_TEXT: &str = "Editing file"; + impl Tool for EditFileTool { fn name(&self) -> String { "edit_file".into() @@ -209,6 +211,22 @@ impl Tool for EditFileTool { } } + fn still_streaming_ui_text(&self, input: &serde_json::Value) -> String { + if let Some(input) = serde_json::from_value::(input.clone()).ok() { + let description = input.display_description.trim(); + if !description.is_empty() { + return description.to_string(); + } + + let path = input.path.trim(); + if !path.is_empty() { + return path.to_string(); + } + } + + DEFAULT_UI_TEXT.to_string() + } + fn run( self: Arc, input: serde_json::Value, @@ -1352,6 +1370,73 @@ mod tests { assert_eq!(actual, expected); } + #[test] + fn still_streaming_ui_text_with_path() { + let input = json!({ + "path": "src/main.rs", + "display_description": "", + "old_string": "old code", + "new_string": "new code" + }); + + assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs"); + } + + #[test] + fn still_streaming_ui_text_with_description() { + let input = json!({ + "path": "", + "display_description": "Fix error handling", + "old_string": "old code", + "new_string": "new code" + }); + + assert_eq!( + EditFileTool.still_streaming_ui_text(&input), + "Fix error handling", + ); + } + + #[test] + fn still_streaming_ui_text_with_path_and_description() { + let input = json!({ + "path": "src/main.rs", + "display_description": "Fix error handling", + "old_string": "old code", + "new_string": "new code" + }); + + assert_eq!( + EditFileTool.still_streaming_ui_text(&input), + "Fix error handling", + ); + } + + #[test] + fn still_streaming_ui_text_no_path_or_description() { + let input = json!({ + "path": "", + "display_description": "", + "old_string": "old code", + "new_string": "new code" + }); + + assert_eq!( + EditFileTool.still_streaming_ui_text(&input), + DEFAULT_UI_TEXT, + ); + } + + #[test] + fn still_streaming_ui_text_with_null() { + let input = serde_json::Value::Null; + + assert_eq!( + EditFileTool.still_streaming_ui_text(&input), + DEFAULT_UI_TEXT, + ); + } + fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); From f0782aa243a2d61f1e4a83eebabcc5f4354f34c2 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Fri, 8 Aug 2025 16:01:48 +0200 Subject: [PATCH 004/109] agent: Don't error when the agent navigation history hasn't been persisted (#35863) This causes us to log an unrecognizable error on every startup otherwise Release Notes: - N/A --- crates/agent/src/history_store.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/agent/src/history_store.rs b/crates/agent/src/history_store.rs index 89f75a72bd..eb39c3e454 100644 --- a/crates/agent/src/history_store.rs +++ b/crates/agent/src/history_store.rs @@ -212,7 +212,16 @@ impl HistoryStore { fn load_recently_opened_entries(cx: &AsyncApp) -> Task>> { cx.background_spawn(async move { let path = paths::data_dir().join(NAVIGATION_HISTORY_PATH); - let contents = smol::fs::read_to_string(path).await?; + let contents = match smol::fs::read_to_string(path).await { + Ok(it) => it, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(Vec::new()); + } + Err(e) => { + return Err(e) + .context("deserializing persisted agent panel navigation history"); + } + }; let entries = serde_json::from_str::>(&contents) .context("deserializing persisted agent panel navigation history")? .into_iter() From 95547f099c0872b2c83a90d8406c99838a878929 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Fri, 8 Aug 2025 17:17:18 +0300 Subject: [PATCH 005/109] Add release_channel data to request child spans (#35874) Follow-up of https://github.com/zed-industries/zed/pull/35729 Release Notes: - N/A --- crates/collab/src/rpc.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index ec1105b138..18eb1457dc 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -929,6 +929,7 @@ impl Server { login=field::Empty, impersonator=field::Empty, multi_lsp_query_request=field::Empty, + release_channel=field::Empty, { TOTAL_DURATION_MS }=field::Empty, { PROCESSING_DURATION_MS }=field::Empty, { QUEUE_DURATION_MS }=field::Empty, From 51298b691229b71544eef117739922ab5a556ae7 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 8 Aug 2025 16:30:49 +0200 Subject: [PATCH 006/109] Use `Project`'s EntityId as the "window id" for Alacritty PTYs (#35876) It's unfortunate to need to have access to a GPUI window in order to create a terminal, because it forces to take a `Window` parameter in entities that otherwise would have been pure models. This pull request changes it so that we pass the `Project`'s entity id, which is equally stable as the window id. Release Notes: - N/A Co-authored-by: Ben Brandt --- crates/assistant_tools/src/terminal_tool.rs | 1 - crates/debugger_ui/src/session/running.rs | 7 ++----- crates/project/src/terminals.rs | 8 +++----- crates/terminal/src/terminal.rs | 14 +++++--------- crates/terminal_view/src/persistence.rs | 5 ++--- crates/terminal_view/src/terminal_panel.rs | 16 ++++------------ crates/terminal_view/src/terminal_view.rs | 5 +---- 7 files changed, 17 insertions(+), 39 deletions(-) diff --git a/crates/assistant_tools/src/terminal_tool.rs b/crates/assistant_tools/src/terminal_tool.rs index 58833c5208..8add60f09a 100644 --- a/crates/assistant_tools/src/terminal_tool.rs +++ b/crates/assistant_tools/src/terminal_tool.rs @@ -225,7 +225,6 @@ impl Tool for TerminalTool { env, ..Default::default() }), - window, cx, ) })? diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index f2f9e17d89..a3e2805e2b 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -1014,10 +1014,9 @@ impl RunningState { ..task.resolved.clone() }; let terminal = project - .update_in(cx, |project, window, cx| { + .update(cx, |project, cx| { project.create_terminal( TerminalKind::Task(task_with_shell.clone()), - window.window_handle(), cx, ) })? @@ -1189,9 +1188,7 @@ impl RunningState { let workspace = self.workspace.clone(); let weak_project = project.downgrade(); - let terminal_task = project.update(cx, |project, cx| { - project.create_terminal(kind, window.window_handle(), cx) - }); + let terminal_task = project.update(cx, |project, cx| project.create_terminal(kind, cx)); let terminal_task = cx.spawn_in(window, async move |_, cx| { let terminal = terminal_task.await?; diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index 973d4e8811..41d8c4b2fd 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -1,7 +1,7 @@ use crate::{Project, ProjectPath}; use anyhow::{Context as _, Result}; use collections::HashMap; -use gpui::{AnyWindowHandle, App, AppContext as _, Context, Entity, Task, WeakEntity}; +use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity}; use itertools::Itertools; use language::LanguageName; use remote::ssh_session::SshArgs; @@ -98,7 +98,6 @@ impl Project { pub fn create_terminal( &mut self, kind: TerminalKind, - window: AnyWindowHandle, cx: &mut Context, ) -> Task>> { let path: Option> = match &kind { @@ -134,7 +133,7 @@ impl Project { None }; project.update(cx, |project, cx| { - project.create_terminal_with_venv(kind, python_venv_directory, window, cx) + project.create_terminal_with_venv(kind, python_venv_directory, cx) })? }) } @@ -209,7 +208,6 @@ impl Project { &mut self, kind: TerminalKind, python_venv_directory: Option, - window: AnyWindowHandle, cx: &mut Context, ) -> Result> { let this = &mut *self; @@ -396,7 +394,7 @@ impl Project { settings.alternate_scroll, settings.max_scroll_history_lines, is_ssh_terminal, - window, + cx.entity_id().as_u64(), completion_tx, cx, ) diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 6e359414d7..d6a09a590f 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -63,9 +63,9 @@ use std::{ use thiserror::Error; use gpui::{ - AnyWindowHandle, App, AppContext as _, Bounds, ClipboardItem, Context, EventEmitter, Hsla, - Keystroke, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, - Rgba, ScrollWheelEvent, SharedString, Size, Task, TouchPhase, Window, actions, black, px, + App, AppContext as _, Bounds, ClipboardItem, Context, EventEmitter, Hsla, Keystroke, Modifiers, + MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, Rgba, + ScrollWheelEvent, SharedString, Size, Task, TouchPhase, Window, actions, black, px, }; use crate::mappings::{colors::to_alac_rgb, keys::to_esc_str}; @@ -351,7 +351,7 @@ impl TerminalBuilder { alternate_scroll: AlternateScroll, max_scroll_history_lines: Option, is_ssh_terminal: bool, - window: AnyWindowHandle, + window_id: u64, completion_tx: Sender>, cx: &App, ) -> Result { @@ -463,11 +463,7 @@ impl TerminalBuilder { let term = Arc::new(FairMutex::new(term)); //Setup the pty... - let pty = match tty::new( - &pty_options, - TerminalBounds::default().into(), - window.window_id().as_u64(), - ) { + let pty = match tty::new(&pty_options, TerminalBounds::default().into(), window_id) { Ok(pty) => pty, Err(error) => { bail!(TerminalError { diff --git a/crates/terminal_view/src/persistence.rs b/crates/terminal_view/src/persistence.rs index 056365ab8c..b93b267f58 100644 --- a/crates/terminal_view/src/persistence.rs +++ b/crates/terminal_view/src/persistence.rs @@ -245,9 +245,8 @@ async fn deserialize_pane_group( let kind = TerminalKind::Shell( working_directory.as_deref().map(Path::to_path_buf), ); - let window = window.window_handle(); - let terminal = project - .update(cx, |project, cx| project.create_terminal(kind, window, cx)); + let terminal = + project.update(cx, |project, cx| project.create_terminal(kind, cx)); Some(Some(terminal)) } else { Some(None) diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index cb1e362884..c9528c39b9 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -432,10 +432,9 @@ impl TerminalPanel { }) .unwrap_or((None, None)); let kind = TerminalKind::Shell(working_directory); - let window_handle = window.window_handle(); let terminal = project .update(cx, |project, cx| { - project.create_terminal_with_venv(kind, python_venv_directory, window_handle, cx) + project.create_terminal_with_venv(kind, python_venv_directory, cx) }) .ok()?; @@ -666,13 +665,10 @@ impl TerminalPanel { "terminal not yet supported for remote projects" ))); } - let window_handle = window.window_handle(); let project = workspace.project().downgrade(); cx.spawn_in(window, async move |workspace, cx| { let terminal = project - .update(cx, |project, cx| { - project.create_terminal(kind, window_handle, cx) - })? + .update(cx, |project, cx| project.create_terminal(kind, cx))? .await?; workspace.update_in(cx, |workspace, window, cx| { @@ -709,11 +705,8 @@ impl TerminalPanel { terminal_panel.active_pane.clone() })?; let project = workspace.read_with(cx, |workspace, _| workspace.project().clone())?; - let window_handle = cx.window_handle(); let terminal = project - .update(cx, |project, cx| { - project.create_terminal(kind, window_handle, cx) - })? + .update(cx, |project, cx| project.create_terminal(kind, cx))? .await?; let result = workspace.update_in(cx, |workspace, window, cx| { let terminal_view = Box::new(cx.new(|cx| { @@ -814,7 +807,6 @@ impl TerminalPanel { ) -> Task>> { let reveal = spawn_task.reveal; let reveal_target = spawn_task.reveal_target; - let window_handle = window.window_handle(); let task_workspace = self.workspace.clone(); cx.spawn_in(window, async move |terminal_panel, cx| { let project = terminal_panel.update(cx, |this, cx| { @@ -823,7 +815,7 @@ impl TerminalPanel { })??; let new_terminal = project .update(cx, |project, cx| { - project.create_terminal(TerminalKind::Task(spawn_task), window_handle, cx) + project.create_terminal(TerminalKind::Task(spawn_task), cx) })? .await?; terminal_to_replace.update_in(cx, |terminal_to_replace, window, cx| { diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 2e6be5aaf4..361cdd0b1c 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -1654,7 +1654,6 @@ impl Item for TerminalView { window: &mut Window, cx: &mut Context, ) -> Option> { - let window_handle = window.window_handle(); let terminal = self .project .update(cx, |project, cx| { @@ -1666,7 +1665,6 @@ impl Item for TerminalView { project.create_terminal_with_venv( TerminalKind::Shell(working_directory), python_venv_directory, - window_handle, cx, ) }) @@ -1802,7 +1800,6 @@ impl SerializableItem for TerminalView { window: &mut Window, cx: &mut App, ) -> Task>> { - let window_handle = window.window_handle(); window.spawn(cx, async move |cx| { let cwd = cx .update(|_window, cx| { @@ -1826,7 +1823,7 @@ impl SerializableItem for TerminalView { let terminal = project .update(cx, |project, cx| { - project.create_terminal(TerminalKind::Shell(cwd), window_handle, cx) + project.create_terminal(TerminalKind::Shell(cwd), cx) })? .await?; cx.update(|window, cx| { From db901278f2a7fd166b7820f1c71b69919cb8315e Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 8 Aug 2025 16:39:40 +0200 Subject: [PATCH 007/109] Lay the groundwork to create terminals in `AcpThread` (#35872) This just prepares the types so that it will be easy later to update a tool call with a terminal entity. We paused because we realized we want to simplify how terminals are created in zed, and so that warrants a dedicated pull request that can be reviewed in isolation. Release Notes: - N/A --------- Co-authored-by: Ben Brandt --- crates/acp_thread/src/acp_thread.rs | 67 +++++++++++----- crates/agent2/src/agent.rs | 53 +++++-------- crates/agent2/src/tests/mod.rs | 72 ++++++++++++----- crates/agent2/src/tests/test_tools.rs | 18 +++-- crates/agent2/src/thread.rs | 79 ++++++++++--------- crates/agent2/src/tools/edit_file_tool.rs | 96 ++++++++++++++++++++++- crates/agent2/src/tools/find_path_tool.rs | 10 ++- crates/agent2/src/tools/read_file_tool.rs | 36 +++++---- crates/agent2/src/tools/thinking_tool.rs | 4 +- 9 files changed, 292 insertions(+), 143 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 54bfe56a15..1df0e1def7 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -198,7 +198,7 @@ impl ToolCall { } } - fn update( + fn update_fields( &mut self, fields: acp::ToolCallUpdateFields, language_registry: Arc, @@ -415,6 +415,39 @@ impl ToolCallContent { } } +#[derive(Debug, PartialEq)] +pub enum ToolCallUpdate { + UpdateFields(acp::ToolCallUpdate), + UpdateDiff(ToolCallUpdateDiff), +} + +impl ToolCallUpdate { + fn id(&self) -> &acp::ToolCallId { + match self { + Self::UpdateFields(update) => &update.id, + Self::UpdateDiff(diff) => &diff.id, + } + } +} + +impl From for ToolCallUpdate { + fn from(update: acp::ToolCallUpdate) -> Self { + Self::UpdateFields(update) + } +} + +impl From for ToolCallUpdate { + fn from(diff: ToolCallUpdateDiff) -> Self { + Self::UpdateDiff(diff) + } +} + +#[derive(Debug, PartialEq)] +pub struct ToolCallUpdateDiff { + pub id: acp::ToolCallId, + pub diff: Entity, +} + #[derive(Debug, Default)] pub struct Plan { pub entries: Vec, @@ -710,36 +743,32 @@ impl AcpThread { pub fn update_tool_call( &mut self, - update: acp::ToolCallUpdate, + update: impl Into, cx: &mut Context, ) -> Result<()> { + let update = update.into(); let languages = self.project.read(cx).languages().clone(); let (ix, current_call) = self - .tool_call_mut(&update.id) + .tool_call_mut(update.id()) .context("Tool call not found")?; - current_call.update(update.fields, languages, cx); + match update { + ToolCallUpdate::UpdateFields(update) => { + current_call.update_fields(update.fields, languages, cx); + } + ToolCallUpdate::UpdateDiff(update) => { + current_call.content.clear(); + current_call + .content + .push(ToolCallContent::Diff { diff: update.diff }); + } + } cx.emit(AcpThreadEvent::EntryUpdated(ix)); Ok(()) } - pub fn set_tool_call_diff( - &mut self, - tool_call_id: &acp::ToolCallId, - diff: Entity, - cx: &mut Context, - ) -> Result<()> { - let (ix, current_call) = self - .tool_call_mut(tool_call_id) - .context("Tool call not found")?; - current_call.content.clear(); - current_call.content.push(ToolCallContent::Diff { diff }); - cx.emit(AcpThreadEvent::EntryUpdated(ix)); - Ok(()) - } - /// Updates a tool call if id matches an existing entry, otherwise inserts a new one. pub fn upsert_tool_call(&mut self, tool_call: acp::ToolCall, cx: &mut Context) { let status = ToolCallStatus::Allowed { diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index e7920e7891..df061cd5ed 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -503,29 +503,27 @@ impl acp_thread::AgentConnection for NativeAgentConnection { match event { AgentResponseEvent::Text(text) => { acp_thread.update(cx, |thread, cx| { - thread.handle_session_update( - acp::SessionUpdate::AgentMessageChunk { - content: acp::ContentBlock::Text(acp::TextContent { - text, - annotations: None, - }), - }, + thread.push_assistant_content_block( + acp::ContentBlock::Text(acp::TextContent { + text, + annotations: None, + }), + false, cx, ) - })??; + })?; } AgentResponseEvent::Thinking(text) => { acp_thread.update(cx, |thread, cx| { - thread.handle_session_update( - acp::SessionUpdate::AgentThoughtChunk { - content: acp::ContentBlock::Text(acp::TextContent { - text, - annotations: None, - }), - }, + thread.push_assistant_content_block( + acp::ContentBlock::Text(acp::TextContent { + text, + annotations: None, + }), + true, cx, ) - })??; + })?; } AgentResponseEvent::ToolCallAuthorization(ToolCallAuthorization { tool_call, @@ -551,27 +549,12 @@ impl acp_thread::AgentConnection for NativeAgentConnection { } AgentResponseEvent::ToolCall(tool_call) => { acp_thread.update(cx, |thread, cx| { - thread.handle_session_update( - acp::SessionUpdate::ToolCall(tool_call), - cx, - ) - })??; + thread.upsert_tool_call(tool_call, cx) + })?; } - AgentResponseEvent::ToolCallUpdate(tool_call_update) => { + AgentResponseEvent::ToolCallUpdate(update) => { acp_thread.update(cx, |thread, cx| { - thread.handle_session_update( - acp::SessionUpdate::ToolCallUpdate(tool_call_update), - cx, - ) - })??; - } - AgentResponseEvent::ToolCallDiff(tool_call_diff) => { - acp_thread.update(cx, |thread, cx| { - thread.set_tool_call_diff( - &tool_call_diff.tool_call_id, - tool_call_diff.diff, - cx, - ) + thread.update_tool_call(update, cx) })??; } AgentResponseEvent::Stop(stop_reason) => { diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index b70f54ac0a..273da1dae5 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -306,7 +306,7 @@ async fn test_tool_hallucination(cx: &mut TestAppContext) { let tool_call = expect_tool_call(&mut events).await; assert_eq!(tool_call.title, "nonexistent_tool"); assert_eq!(tool_call.status, acp::ToolCallStatus::Pending); - let update = expect_tool_call_update(&mut events).await; + let update = expect_tool_call_update_fields(&mut events).await; assert_eq!(update.fields.status, Some(acp::ToolCallStatus::Failed)); } @@ -326,7 +326,7 @@ async fn expect_tool_call( } } -async fn expect_tool_call_update( +async fn expect_tool_call_update_fields( events: &mut UnboundedReceiver>, ) -> acp::ToolCallUpdate { let event = events @@ -335,7 +335,9 @@ async fn expect_tool_call_update( .expect("no tool call authorization event received") .unwrap(); match event { - AgentResponseEvent::ToolCallUpdate(tool_call_update) => return tool_call_update, + AgentResponseEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update)) => { + return update + } event => { panic!("Unexpected event {event:?}"); } @@ -425,31 +427,33 @@ async fn test_cancellation(cx: &mut TestAppContext) { }); // Wait until both tools are called. - let mut expected_tool_calls = vec!["echo", "infinite"]; + let mut expected_tools = vec!["Echo", "Infinite Tool"]; let mut echo_id = None; let mut echo_completed = false; while let Some(event) = events.next().await { match event.unwrap() { AgentResponseEvent::ToolCall(tool_call) => { - assert_eq!(tool_call.title, expected_tool_calls.remove(0)); - if tool_call.title == "echo" { + assert_eq!(tool_call.title, expected_tools.remove(0)); + if tool_call.title == "Echo" { echo_id = Some(tool_call.id); } } - AgentResponseEvent::ToolCallUpdate(acp::ToolCallUpdate { - id, - fields: - acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::Completed), - .. - }, - }) if Some(&id) == echo_id.as_ref() => { + AgentResponseEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields( + acp::ToolCallUpdate { + id, + fields: + acp::ToolCallUpdateFields { + status: Some(acp::ToolCallStatus::Completed), + .. + }, + }, + )) if Some(&id) == echo_id.as_ref() => { echo_completed = true; } _ => {} } - if expected_tool_calls.is_empty() && echo_completed { + if expected_tools.is_empty() && echo_completed { break; } } @@ -647,13 +651,26 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { let mut events = thread.update(cx, |thread, cx| thread.send(model.clone(), "Think", cx)); cx.run_until_parked(); - let input = json!({ "content": "Thinking hard!" }); + // Simulate streaming partial input. + let input = json!({}); fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { id: "1".into(), name: ThinkingTool.name().into(), raw_input: input.to_string(), input, + is_input_complete: false, + }, + )); + + // Input streaming completed + let input = json!({ "content": "Thinking hard!" }); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "1".into(), + name: "thinking".into(), + raw_input: input.to_string(), + input, is_input_complete: true, }, )); @@ -670,22 +687,35 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { status: acp::ToolCallStatus::Pending, content: vec![], locations: vec![], - raw_input: Some(json!({ "content": "Thinking hard!" })), + raw_input: Some(json!({})), raw_output: None, } ); - let update = expect_tool_call_update(&mut events).await; + let update = expect_tool_call_update_fields(&mut events).await; assert_eq!( update, acp::ToolCallUpdate { id: acp::ToolCallId("1".into()), fields: acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::InProgress,), + title: Some("Thinking".into()), + kind: Some(acp::ToolKind::Think), + raw_input: Some(json!({ "content": "Thinking hard!" })), ..Default::default() }, } ); - let update = expect_tool_call_update(&mut events).await; + let update = expect_tool_call_update_fields(&mut events).await; + assert_eq!( + update, + acp::ToolCallUpdate { + id: acp::ToolCallId("1".into()), + fields: acp::ToolCallUpdateFields { + status: Some(acp::ToolCallStatus::InProgress), + ..Default::default() + }, + } + ); + let update = expect_tool_call_update_fields(&mut events).await; assert_eq!( update, acp::ToolCallUpdate { @@ -696,7 +726,7 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { }, } ); - let update = expect_tool_call_update(&mut events).await; + let update = expect_tool_call_update_fields(&mut events).await; assert_eq!( update, acp::ToolCallUpdate { diff --git a/crates/agent2/src/tests/test_tools.rs b/crates/agent2/src/tests/test_tools.rs index d22ff6ace8..d06614f3fe 100644 --- a/crates/agent2/src/tests/test_tools.rs +++ b/crates/agent2/src/tests/test_tools.rs @@ -24,7 +24,7 @@ impl AgentTool for EchoTool { acp::ToolKind::Other } - fn initial_title(&self, _: Self::Input) -> SharedString { + fn initial_title(&self, _input: Result) -> SharedString { "Echo".into() } @@ -55,8 +55,12 @@ impl AgentTool for DelayTool { "delay".into() } - fn initial_title(&self, input: Self::Input) -> SharedString { - format!("Delay {}ms", input.ms).into() + fn initial_title(&self, input: Result) -> SharedString { + if let Ok(input) = input { + format!("Delay {}ms", input.ms).into() + } else { + "Delay".into() + } } fn kind(&self) -> acp::ToolKind { @@ -96,7 +100,7 @@ impl AgentTool for ToolRequiringPermission { acp::ToolKind::Other } - fn initial_title(&self, _input: Self::Input) -> SharedString { + fn initial_title(&self, _input: Result) -> SharedString { "This tool requires permission".into() } @@ -131,8 +135,8 @@ impl AgentTool for InfiniteTool { acp::ToolKind::Other } - fn initial_title(&self, _input: Self::Input) -> SharedString { - "This is the tool that never ends... it just goes on and on my friends!".into() + fn initial_title(&self, _input: Result) -> SharedString { + "Infinite Tool".into() } fn run( @@ -182,7 +186,7 @@ impl AgentTool for WordListTool { acp::ToolKind::Other } - fn initial_title(&self, _input: Self::Input) -> SharedString { + fn initial_title(&self, _input: Result) -> SharedString { "List of random words".into() } diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 98f2d0651d..f664e0f5d2 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -102,9 +102,8 @@ pub enum AgentResponseEvent { Text(String), Thinking(String), ToolCall(acp::ToolCall), - ToolCallUpdate(acp::ToolCallUpdate), + ToolCallUpdate(acp_thread::ToolCallUpdate), ToolCallAuthorization(ToolCallAuthorization), - ToolCallDiff(ToolCallDiff), Stop(acp::StopReason), } @@ -115,12 +114,6 @@ pub struct ToolCallAuthorization { pub response: oneshot::Sender, } -#[derive(Debug)] -pub struct ToolCallDiff { - pub tool_call_id: acp::ToolCallId, - pub diff: Entity, -} - pub struct Thread { messages: Vec, completion_mode: CompletionMode, @@ -294,7 +287,7 @@ impl Thread { while let Some(tool_result) = tool_uses.next().await { log::info!("Tool finished {:?}", tool_result); - event_stream.send_tool_call_update( + event_stream.update_tool_call_fields( &tool_result.tool_use_id, acp::ToolCallUpdateFields { status: Some(if tool_result.is_error { @@ -474,15 +467,24 @@ impl Thread { } }); + let mut title = SharedString::from(&tool_use.name); + let mut kind = acp::ToolKind::Other; + if let Some(tool) = tool.as_ref() { + title = tool.initial_title(tool_use.input.clone()); + kind = tool.kind(); + } + if push_new_tool_use { - event_stream.send_tool_call(tool.as_ref(), &tool_use); + event_stream.send_tool_call(&tool_use.id, title, kind, tool_use.input.clone()); last_message .content .push(MessageContent::ToolUse(tool_use.clone())); } else { - event_stream.send_tool_call_update( + event_stream.update_tool_call_fields( &tool_use.id, acp::ToolCallUpdateFields { + title: Some(title.into()), + kind: Some(kind), raw_input: Some(tool_use.input.clone()), ..Default::default() }, @@ -506,7 +508,7 @@ impl Thread { let tool_event_stream = ToolCallEventStream::new(&tool_use, tool.kind(), event_stream.clone()); - tool_event_stream.send_update(acp::ToolCallUpdateFields { + tool_event_stream.update_fields(acp::ToolCallUpdateFields { status: Some(acp::ToolCallStatus::InProgress), ..Default::default() }); @@ -693,7 +695,7 @@ where fn kind(&self) -> acp::ToolKind; /// The initial tool title to display. Can be updated during the tool run. - fn initial_title(&self, input: Self::Input) -> SharedString; + fn initial_title(&self, input: Result) -> SharedString; /// Returns the JSON schema that describes the tool's input. fn input_schema(&self) -> Schema { @@ -724,7 +726,7 @@ pub trait AnyAgentTool { fn name(&self) -> SharedString; fn description(&self, cx: &mut App) -> SharedString; fn kind(&self) -> acp::ToolKind; - fn initial_title(&self, input: serde_json::Value) -> Result; + fn initial_title(&self, input: serde_json::Value) -> SharedString; fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result; fn run( self: Arc, @@ -750,9 +752,9 @@ where self.0.kind() } - fn initial_title(&self, input: serde_json::Value) -> Result { - let parsed_input = serde_json::from_value(input)?; - Ok(self.0.initial_title(parsed_input)) + fn initial_title(&self, input: serde_json::Value) -> SharedString { + let parsed_input = serde_json::from_value(input.clone()).map_err(|_| input); + self.0.initial_title(parsed_input) } fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { @@ -842,17 +844,17 @@ impl AgentResponseEventStream { fn send_tool_call( &self, - tool: Option<&Arc>, - tool_use: &LanguageModelToolUse, + id: &LanguageModelToolUseId, + title: SharedString, + kind: acp::ToolKind, + input: serde_json::Value, ) { self.0 .unbounded_send(Ok(AgentResponseEvent::ToolCall(Self::initial_tool_call( - &tool_use.id, - tool.and_then(|t| t.initial_title(tool_use.input.clone()).ok()) - .map(|i| i.into()) - .unwrap_or_else(|| tool_use.name.to_string()), - tool.map(|t| t.kind()).unwrap_or(acp::ToolKind::Other), - tool_use.input.clone(), + id, + title.to_string(), + kind, + input, )))) .ok(); } @@ -875,7 +877,7 @@ impl AgentResponseEventStream { } } - fn send_tool_call_update( + fn update_tool_call_fields( &self, tool_use_id: &LanguageModelToolUseId, fields: acp::ToolCallUpdateFields, @@ -885,14 +887,21 @@ impl AgentResponseEventStream { acp::ToolCallUpdate { id: acp::ToolCallId(tool_use_id.to_string().into()), fields, - }, + } + .into(), ))) .ok(); } - fn send_tool_call_diff(&self, tool_call_diff: ToolCallDiff) { + fn update_tool_call_diff(&self, tool_use_id: &LanguageModelToolUseId, diff: Entity) { self.0 - .unbounded_send(Ok(AgentResponseEvent::ToolCallDiff(tool_call_diff))) + .unbounded_send(Ok(AgentResponseEvent::ToolCallUpdate( + acp_thread::ToolCallUpdateDiff { + id: acp::ToolCallId(tool_use_id.to_string().into()), + diff, + } + .into(), + ))) .ok(); } @@ -964,15 +973,13 @@ impl ToolCallEventStream { } } - pub fn send_update(&self, fields: acp::ToolCallUpdateFields) { - self.stream.send_tool_call_update(&self.tool_use_id, fields); + pub fn update_fields(&self, fields: acp::ToolCallUpdateFields) { + self.stream + .update_tool_call_fields(&self.tool_use_id, fields); } - pub fn send_diff(&self, diff: Entity) { - self.stream.send_tool_call_diff(ToolCallDiff { - tool_call_id: acp::ToolCallId(self.tool_use_id.to_string().into()), - diff, - }); + pub fn update_diff(&self, diff: Entity) { + self.stream.update_tool_call_diff(&self.tool_use_id, diff); } pub fn authorize(&self, title: String) -> impl use<> + Future> { diff --git a/crates/agent2/src/tools/edit_file_tool.rs b/crates/agent2/src/tools/edit_file_tool.rs index 0dbe0be217..0858bb501c 100644 --- a/crates/agent2/src/tools/edit_file_tool.rs +++ b/crates/agent2/src/tools/edit_file_tool.rs @@ -1,3 +1,4 @@ +use crate::{AgentTool, Thread, ToolCallEventStream}; use acp_thread::Diff; use agent_client_protocol as acp; use anyhow::{anyhow, Context as _, Result}; @@ -20,7 +21,7 @@ use std::sync::Arc; use ui::SharedString; use util::ResultExt; -use crate::{AgentTool, Thread, ToolCallEventStream}; +const DEFAULT_UI_TEXT: &str = "Editing file"; /// This is a tool for creating a new file or editing an existing file. For moving or renaming files, you should generally use the `terminal` tool with the 'mv' command instead. /// @@ -78,6 +79,14 @@ pub struct EditFileToolInput { pub mode: EditFileMode, } +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +struct EditFileToolPartialInput { + #[serde(default)] + path: String, + #[serde(default)] + display_description: String, +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum EditFileMode { @@ -182,8 +191,27 @@ impl AgentTool for EditFileTool { acp::ToolKind::Edit } - fn initial_title(&self, input: Self::Input) -> SharedString { - input.display_description.into() + fn initial_title(&self, input: Result) -> SharedString { + match input { + Ok(input) => input.display_description.into(), + Err(raw_input) => { + if let Some(input) = + serde_json::from_value::(raw_input).ok() + { + let description = input.display_description.trim(); + if !description.is_empty() { + return description.to_string().into(); + } + + let path = input.path.trim().to_string(); + if !path.is_empty() { + return path.into(); + } + } + + DEFAULT_UI_TEXT.into() + } + } } fn run( @@ -226,7 +254,7 @@ impl AgentTool for EditFileTool { .await?; let diff = cx.new(|cx| Diff::new(buffer.clone(), cx))?; - event_stream.send_diff(diff.clone()); + event_stream.update_diff(diff.clone()); let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; let old_text = cx @@ -1348,6 +1376,66 @@ mod tests { } } + #[gpui::test] + async fn test_initial_title_with_partial_input(cx: &mut TestAppContext) { + init_test(cx); + let fs = project::FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|_| { + Thread::new( + project.clone(), + Rc::default(), + action_log.clone(), + Templates::new(), + model.clone(), + ) + }); + let tool = Arc::new(EditFileTool { thread }); + + assert_eq!( + tool.initial_title(Err(json!({ + "path": "src/main.rs", + "display_description": "", + "old_string": "old code", + "new_string": "new code" + }))), + "src/main.rs" + ); + assert_eq!( + tool.initial_title(Err(json!({ + "path": "", + "display_description": "Fix error handling", + "old_string": "old code", + "new_string": "new code" + }))), + "Fix error handling" + ); + assert_eq!( + tool.initial_title(Err(json!({ + "path": "src/main.rs", + "display_description": "Fix error handling", + "old_string": "old code", + "new_string": "new code" + }))), + "Fix error handling" + ); + assert_eq!( + tool.initial_title(Err(json!({ + "path": "", + "display_description": "", + "old_string": "old code", + "new_string": "new code" + }))), + DEFAULT_UI_TEXT + ); + assert_eq!( + tool.initial_title(Err(serde_json::Value::Null)), + DEFAULT_UI_TEXT + ); + } + fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); diff --git a/crates/agent2/src/tools/find_path_tool.rs b/crates/agent2/src/tools/find_path_tool.rs index 24bdcded8c..f4589e5600 100644 --- a/crates/agent2/src/tools/find_path_tool.rs +++ b/crates/agent2/src/tools/find_path_tool.rs @@ -94,8 +94,12 @@ impl AgentTool for FindPathTool { acp::ToolKind::Search } - fn initial_title(&self, input: Self::Input) -> SharedString { - format!("Find paths matching “`{}`”", input.glob).into() + fn initial_title(&self, input: Result) -> SharedString { + let mut title = "Find paths".to_string(); + if let Ok(input) = input { + title.push_str(&format!(" matching “`{}`”", input.glob)); + } + title.into() } fn run( @@ -111,7 +115,7 @@ impl AgentTool for FindPathTool { let paginated_matches: &[PathBuf] = &matches[cmp::min(input.offset, matches.len()) ..cmp::min(input.offset + RESULTS_PER_PAGE, matches.len())]; - event_stream.send_update(acp::ToolCallUpdateFields { + event_stream.update_fields(acp::ToolCallUpdateFields { title: Some(if paginated_matches.len() == 0 { "No matches".into() } else if paginated_matches.len() == 1 { diff --git a/crates/agent2/src/tools/read_file_tool.rs b/crates/agent2/src/tools/read_file_tool.rs index 3d91e3dc74..7bbe3ac4c1 100644 --- a/crates/agent2/src/tools/read_file_tool.rs +++ b/crates/agent2/src/tools/read_file_tool.rs @@ -70,24 +70,28 @@ impl AgentTool for ReadFileTool { acp::ToolKind::Read } - fn initial_title(&self, input: Self::Input) -> SharedString { - 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 - ) + 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), } - (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() } - .into() } fn run( diff --git a/crates/agent2/src/tools/thinking_tool.rs b/crates/agent2/src/tools/thinking_tool.rs index d85370e7e5..43647bb468 100644 --- a/crates/agent2/src/tools/thinking_tool.rs +++ b/crates/agent2/src/tools/thinking_tool.rs @@ -30,7 +30,7 @@ impl AgentTool for ThinkingTool { acp::ToolKind::Think } - fn initial_title(&self, _input: Self::Input) -> SharedString { + fn initial_title(&self, _input: Result) -> SharedString { "Thinking".into() } @@ -40,7 +40,7 @@ impl AgentTool for ThinkingTool { event_stream: ToolCallEventStream, _cx: &mut App, ) -> Task> { - event_stream.send_update(acp::ToolCallUpdateFields { + event_stream.update_fields(acp::ToolCallUpdateFields { content: Some(vec![input.content.into()]), ..Default::default() }); From 2a310d78e1d6008884b5b347e5db01fb939073eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=B0=8F=E7=99=BD?= <364772080@qq.com> Date: Fri, 8 Aug 2025 22:42:20 +0800 Subject: [PATCH 008/109] =?UTF-8?q?windows:=20Fix=20the=20issue=20where=20?= =?UTF-8?q?`ags.dll`=20couldn=E2=80=99t=20be=20replaced=20during=20update?= =?UTF-8?q?=20(#35877)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release Notes: - N/A --------- Co-authored-by: Kirill Bulatov --- .../src/platform/windows/directx_renderer.rs | 66 +++++++++++-------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/crates/gpui/src/platform/windows/directx_renderer.rs b/crates/gpui/src/platform/windows/directx_renderer.rs index ac285b79ac..585b1dab1c 100644 --- a/crates/gpui/src/platform/windows/directx_renderer.rs +++ b/crates/gpui/src/platform/windows/directx_renderer.rs @@ -4,15 +4,16 @@ use ::util::ResultExt; use anyhow::{Context, Result}; use windows::{ Win32::{ - Foundation::{HMODULE, HWND}, + Foundation::{FreeLibrary, HMODULE, HWND}, Graphics::{ Direct3D::*, Direct3D11::*, DirectComposition::*, Dxgi::{Common::*, *}, }, + System::LibraryLoader::LoadLibraryA, }, - core::Interface, + core::{Interface, PCSTR}, }; use crate::{ @@ -1618,17 +1619,32 @@ pub(crate) mod shader_resources { } } +fn with_dll_library(dll_name: PCSTR, f: F) -> Result +where + F: FnOnce(HMODULE) -> Result, +{ + let library = unsafe { + LoadLibraryA(dll_name).with_context(|| format!("Loading dll: {}", dll_name.display()))? + }; + let result = f(library); + unsafe { + FreeLibrary(library) + .with_context(|| format!("Freeing dll: {}", dll_name.display())) + .log_err(); + } + result +} + mod nvidia { use std::{ ffi::CStr, os::raw::{c_char, c_int, c_uint}, }; - use anyhow::{Context, Result}; - use windows::{ - Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA}, - core::s, - }; + use anyhow::Result; + use windows::{Win32::System::LibraryLoader::GetProcAddress, core::s}; + + use crate::platform::windows::directx_renderer::with_dll_library; // https://github.com/NVIDIA/nvapi/blob/7cb76fce2f52de818b3da497af646af1ec16ce27/nvapi_lite_common.h#L180 const NVAPI_SHORT_STRING_MAX: usize = 64; @@ -1645,13 +1661,12 @@ mod nvidia { ) -> c_int; pub(super) fn get_driver_version() -> Result { - unsafe { - // Try to load the NVIDIA driver DLL - #[cfg(target_pointer_width = "64")] - let nvidia_dll = LoadLibraryA(s!("nvapi64.dll")).context("Can't load nvapi64.dll")?; - #[cfg(target_pointer_width = "32")] - let nvidia_dll = LoadLibraryA(s!("nvapi.dll")).context("Can't load nvapi.dll")?; + #[cfg(target_pointer_width = "64")] + let nvidia_dll_name = s!("nvapi64.dll"); + #[cfg(target_pointer_width = "32")] + let nvidia_dll_name = s!("nvapi.dll"); + with_dll_library(nvidia_dll_name, |nvidia_dll| unsafe { let nvapi_query_addr = GetProcAddress(nvidia_dll, s!("nvapi_QueryInterface")) .ok_or_else(|| anyhow::anyhow!("Failed to get nvapi_QueryInterface address"))?; let nvapi_query: extern "C" fn(u32) -> *mut () = std::mem::transmute(nvapi_query_addr); @@ -1686,18 +1701,17 @@ mod nvidia { minor, branch_string.to_string_lossy() )) - } + }) } } mod amd { use std::os::raw::{c_char, c_int, c_void}; - use anyhow::{Context, Result}; - use windows::{ - Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA}, - core::s, - }; + use anyhow::Result; + use windows::{Win32::System::LibraryLoader::GetProcAddress, core::s}; + + use crate::platform::windows::directx_renderer::with_dll_library; // https://github.com/GPUOpen-LibrariesAndSDKs/AGS_SDK/blob/5d8812d703d0335741b6f7ffc37838eeb8b967f7/ags_lib/inc/amd_ags.h#L145 const AGS_CURRENT_VERSION: i32 = (6 << 22) | (3 << 12); @@ -1731,14 +1745,12 @@ mod amd { type agsDeInitialize_t = unsafe extern "C" fn(context: *mut AGSContext) -> c_int; pub(super) fn get_driver_version() -> Result { - unsafe { - #[cfg(target_pointer_width = "64")] - let amd_dll = - LoadLibraryA(s!("amd_ags_x64.dll")).context("Failed to load AMD AGS library")?; - #[cfg(target_pointer_width = "32")] - let amd_dll = - LoadLibraryA(s!("amd_ags_x86.dll")).context("Failed to load AMD AGS library")?; + #[cfg(target_pointer_width = "64")] + let amd_dll_name = s!("amd_ags_x64.dll"); + #[cfg(target_pointer_width = "32")] + let amd_dll_name = s!("amd_ags_x86.dll"); + with_dll_library(amd_dll_name, |amd_dll| unsafe { let ags_initialize_addr = GetProcAddress(amd_dll, s!("agsInitialize")) .ok_or_else(|| anyhow::anyhow!("Failed to get agsInitialize address"))?; let ags_deinitialize_addr = GetProcAddress(amd_dll, s!("agsDeInitialize")) @@ -1784,7 +1796,7 @@ mod amd { ags_deinitialize(context); Ok(format!("{} ({})", software_version, driver_version)) - } + }) } } From 327456d1d2ff748797bd2a0f93f79c3b099599ba Mon Sep 17 00:00:00 2001 From: Alvaro Parker <64918109+AlvaroParker@users.noreply.github.com> Date: Fri, 8 Aug 2025 10:47:00 -0400 Subject: [PATCH 009/109] context menu: Fix go to first element on context menu (#35875) Closes #35873 Release Notes: - Fixed bug where context menu doesn't circle back to the first item when the last item is not selectable --- crates/ui/src/components/context_menu.rs | 29 +++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index 77468fd295..21ab283d88 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -679,18 +679,18 @@ impl ContextMenu { let next_index = ix + 1; if self.items.len() <= next_index { self.select_first(&SelectFirst, window, cx); + return; } else { for (ix, item) in self.items.iter().enumerate().skip(next_index) { if item.is_selectable() { self.select_index(ix, window, cx); cx.notify(); - break; + return; } } } - } else { - self.select_first(&SelectFirst, window, cx); } + self.select_first(&SelectFirst, window, cx); } pub fn select_previous( @@ -1203,6 +1203,7 @@ mod tests { .separator() .separator() .entry("Last entry", None, |_, _| {}) + .header("Last header") }) }); @@ -1255,5 +1256,27 @@ mod tests { "Should go back to previous selectable entry (first)" ); }); + + context_menu.update_in(cx, |context_menu, window, cx| { + context_menu.select_first(&SelectFirst, window, cx); + assert_eq!( + Some(2), + context_menu.selected_index, + "Should start from the first selectable entry" + ); + + context_menu.select_previous(&SelectPrevious, window, cx); + assert_eq!( + Some(5), + context_menu.selected_index, + "Should wrap around to last selectable entry" + ); + context_menu.select_next(&SelectNext, window, cx); + assert_eq!( + Some(2), + context_menu.selected_index, + "Should wrap around to first selectable entry" + ); + }); } } From f2435f7284a4195dbf46439ce7a95b9e53ec44b5 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Fri, 8 Aug 2025 13:02:17 -0400 Subject: [PATCH 010/109] onboarding: Fix a double lease panic caused by Onboarding::clone_on_split (#35815) Release Notes: - N/A --- crates/onboarding/src/onboarding.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/onboarding/src/onboarding.rs b/crates/onboarding/src/onboarding.rs index 98f61df97b..342b52bdda 100644 --- a/crates/onboarding/src/onboarding.rs +++ b/crates/onboarding/src/onboarding.rs @@ -565,9 +565,13 @@ impl Item for Onboarding { _: &mut Window, cx: &mut Context, ) -> Option> { - self.workspace - .update(cx, |workspace, cx| Onboarding::new(workspace, cx)) - .ok() + Some(cx.new(|cx| Onboarding { + workspace: self.workspace.clone(), + user_store: self.user_store.clone(), + selected_page: self.selected_page, + focus_handle: cx.focus_handle(), + _settings_subscription: cx.observe_global::(move |_, cx| cx.notify()), + })) } fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) { From 315a92091b91b4448b4c95ecf9e3dc3fa1bd7a62 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Fri, 8 Aug 2025 14:10:09 -0400 Subject: [PATCH 011/109] Ensure Edit Prediction provider is properly assigned on sign-in (#35885) This PR fixes an issue where Edit Predictions would not be available in buffers that were opened when the workspace loaded. The issue was that there was a race condition between fetching/setting the authenticated user state and when we assigned the Edit Prediction provider to buffers that were already opened. We now wait for the event that we emit when we have successfully loaded the user in order to assign the Edit Prediction provider, as we'll know the user has been loaded into the `UserStore` by that point. Closes https://github.com/zed-industries/zed/issues/35883 Release Notes: - Fixed an issue where Edit Predictions were not working in buffers that were open when the workspace initially loaded. Co-authored-by: Richard Feldman --- crates/client/src/user.rs | 39 +++++++++++++------ .../zed/src/zed/edit_prediction_registry.rs | 29 ++++++-------- 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index 9f76dd7ad0..faf46945d8 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -226,17 +226,35 @@ impl UserStore { match status { Status::Authenticated | Status::Connected { .. } => { if let Some(user_id) = client.user_id() { - let response = client.cloud_client().get_authenticated_user().await; - let mut current_user = None; + let response = client + .cloud_client() + .get_authenticated_user() + .await + .log_err(); + + let current_user_and_response = if let Some(response) = response { + let user = Arc::new(User { + id: user_id, + github_login: response.user.github_login.clone().into(), + avatar_uri: response.user.avatar_url.clone().into(), + name: response.user.name.clone(), + }); + + Some((user, response)) + } else { + None + }; + current_user_tx + .send( + current_user_and_response + .as_ref() + .map(|(user, _)| user.clone()), + ) + .await + .ok(); + cx.update(|cx| { - if let Some(response) = response.log_err() { - let user = Arc::new(User { - id: user_id, - github_login: response.user.github_login.clone().into(), - avatar_uri: response.user.avatar_url.clone().into(), - name: response.user.name.clone(), - }); - current_user = Some(user.clone()); + if let Some((user, response)) = current_user_and_response { this.update(cx, |this, cx| { this.by_github_login .insert(user.github_login.clone(), user_id); @@ -247,7 +265,6 @@ impl UserStore { anyhow::Ok(()) } })??; - current_user_tx.send(current_user).await.ok(); this.update(cx, |_, cx| cx.notify())?; } diff --git a/crates/zed/src/zed/edit_prediction_registry.rs b/crates/zed/src/zed/edit_prediction_registry.rs index b9f561c0e7..da4b6e78c6 100644 --- a/crates/zed/src/zed/edit_prediction_registry.rs +++ b/crates/zed/src/zed/edit_prediction_registry.rs @@ -5,11 +5,9 @@ use editor::Editor; use gpui::{AnyWindowHandle, App, AppContext as _, Context, Entity, WeakEntity}; use language::language_settings::{EditPredictionProvider, all_language_settings}; use settings::SettingsStore; -use smol::stream::StreamExt; use std::{cell::RefCell, rc::Rc, sync::Arc}; use supermaven::{Supermaven, SupermavenCompletionProvider}; use ui::Window; -use util::ResultExt; use workspace::Workspace; use zeta::{ProviderDataCollection, ZetaEditPredictionProvider}; @@ -59,25 +57,20 @@ pub fn init(client: Arc, user_store: Entity, cx: &mut App) { cx.on_action(clear_zeta_edit_history); let mut provider = all_language_settings(None, cx).edit_predictions.provider; - cx.spawn({ - let user_store = user_store.clone(); + cx.subscribe(&user_store, { let editors = editors.clone(); let client = client.clone(); - - async move |cx| { - let mut status = client.status(); - while let Some(_status) = status.next().await { - cx.update(|cx| { - assign_edit_prediction_providers( - &editors, - provider, - &client, - user_store.clone(), - cx, - ); - }) - .log_err(); + move |user_store, event, cx| match event { + client::user::Event::PrivateUserInfoUpdated => { + assign_edit_prediction_providers( + &editors, + provider, + &client, + user_store.clone(), + cx, + ); } + _ => {} } }) .detach(); From 530f5075d0c1e1084a27ab6705bfe3458c9d8e72 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 8 Aug 2025 15:34:25 -0300 Subject: [PATCH 012/109] ui: Fix switch field info tooltip (#35882) Passing an empty on_click handler so that clicking on the info icon doesn't actually trigger the switch itself, which happens if you click anywhere in the general switch field surface area. Release Notes: - N/A --- crates/ui/src/components/toggle.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui/src/components/toggle.rs b/crates/ui/src/components/toggle.rs index 4b985fd2c2..59c056859d 100644 --- a/crates/ui/src/components/toggle.rs +++ b/crates/ui/src/components/toggle.rs @@ -659,10 +659,12 @@ impl RenderOnce for SwitchField { .icon_size(IconSize::XSmall) .icon_color(Color::Muted) .shape(crate::IconButtonShape::Square) + .style(ButtonStyle::Transparent) .tooltip({ let tooltip = tooltip_fn.clone(); move |window, cx| tooltip(window, cx) - }), + }) + .on_click(|_, _, _| {}), // Intentional empty on click handler so that clicking on the info tooltip icon doesn't trigger the switch toggle ) }); From 2cde6da5ffb1960d919e53904d0b50a33c432975 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 8 Aug 2025 15:34:36 -0300 Subject: [PATCH 013/109] Redesign and clean up all icons across Zed (#35856) - [x] Clean up unused and old icons - [x] Swap SVG for all in-use icons with the redesigned version - [x] Document guidelines Release Notes: - N/A --- assets/icons/arrow_circle.svg | 8 +-- assets/icons/arrow_down.svg | 2 +- assets/icons/arrow_down10.svg | 2 +- assets/icons/arrow_down_from_line.svg | 1 - assets/icons/arrow_down_right.svg | 5 +- assets/icons/arrow_left.svg | 2 +- assets/icons/arrow_right.svg | 2 +- assets/icons/arrow_right_left.svg | 7 ++- assets/icons/arrow_up.svg | 2 +- assets/icons/arrow_up_alt.svg | 3 - assets/icons/arrow_up_from_line.svg | 1 - assets/icons/arrow_up_right.svg | 5 +- assets/icons/arrow_up_right_alt.svg | 3 - assets/icons/backspace.svg | 6 +- assets/icons/binary.svg | 2 +- assets/icons/blocks.svg | 2 +- assets/icons/book.svg | 2 +- assets/icons/book_copy.svg | 2 +- assets/icons/bug_off.svg | 1 - assets/icons/caret_down.svg | 8 --- assets/icons/caret_up.svg | 8 --- assets/icons/case_sensitive.svg | 9 +-- assets/icons/check.svg | 2 +- assets/icons/check_circle.svg | 6 +- assets/icons/check_double.svg | 5 +- assets/icons/chevron_down.svg | 4 +- assets/icons/chevron_down_small.svg | 3 - assets/icons/chevron_left.svg | 4 +- assets/icons/chevron_right.svg | 4 +- assets/icons/chevron_up.svg | 4 +- assets/icons/chevron_up_down.svg | 5 +- assets/icons/circle.svg | 4 +- assets/icons/circle_check.svg | 2 +- assets/icons/circle_help.svg | 6 +- assets/icons/circle_off.svg | 1 - assets/icons/close.svg | 4 +- assets/icons/cloud.svg | 1 - assets/icons/cloud_download.svg | 2 +- assets/icons/code.svg | 2 +- assets/icons/cog.svg | 2 +- assets/icons/command.svg | 2 +- assets/icons/context.svg | 6 -- assets/icons/control.svg | 2 +- assets/icons/copilot.svg | 16 ++--- assets/icons/copilot_disabled.svg | 10 ++-- assets/icons/copilot_error.svg | 6 +- assets/icons/copilot_init.svg | 4 +- assets/icons/copy.svg | 5 +- assets/icons/countdown_timer.svg | 2 +- assets/icons/crosshair.svg | 12 ++-- assets/icons/dash.svg | 2 +- assets/icons/database_zap.svg | 2 +- assets/icons/debug.svg | 20 +++---- assets/icons/debug_breakpoint.svg | 4 +- assets/icons/debug_continue.svg | 2 +- assets/icons/debug_detach.svg | 2 +- assets/icons/debug_disabled_breakpoint.svg | 4 +- .../icons/debug_disabled_log_breakpoint.svg | 4 +- assets/icons/debug_ignore_breakpoints.svg | 2 +- assets/icons/debug_log_breakpoint.svg | 4 +- assets/icons/debug_pause.svg | 5 +- assets/icons/debug_restart.svg | 1 - assets/icons/debug_step_back.svg | 2 +- assets/icons/debug_step_into.svg | 6 +- assets/icons/debug_step_out.svg | 6 +- assets/icons/debug_step_over.svg | 6 +- assets/icons/debug_stop.svg | 1 - assets/icons/delete.svg | 1 - assets/icons/diff.svg | 2 +- assets/icons/disconnected.svg | 4 +- assets/icons/document_text.svg | 3 - assets/icons/download.svg | 2 +- assets/icons/ellipsis.svg | 8 +-- assets/icons/ellipsis_vertical.svg | 6 +- assets/icons/equal.svg | 1 - assets/icons/eraser.svg | 5 +- assets/icons/escape.svg | 2 +- assets/icons/expand_down.svg | 6 +- assets/icons/expand_up.svg | 6 +- assets/icons/expand_vertical.svg | 2 +- assets/icons/external_link.svg | 5 -- assets/icons/eye.svg | 5 +- assets/icons/file.svg | 5 +- assets/icons/file_code.svg | 2 +- assets/icons/file_create.svg | 5 -- assets/icons/file_diff.svg | 2 +- assets/icons/file_markdown.svg | 1 + assets/icons/file_search.svg | 5 -- assets/icons/file_text.svg | 6 -- assets/icons/file_text_filled.svg | 3 + assets/icons/file_text_outlined.svg | 6 ++ assets/icons/file_tree.svg | 6 +- assets/icons/flame.svg | 2 +- assets/icons/folder.svg | 2 +- assets/icons/folder_search.svg | 5 ++ assets/icons/folder_x.svg | 5 -- assets/icons/font.svg | 2 +- assets/icons/font_size.svg | 2 +- assets/icons/font_weight.svg | 2 +- assets/icons/forward_arrow.svg | 5 +- assets/icons/function.svg | 1 - assets/icons/generic_maximize.svg | 2 +- assets/icons/generic_restore.svg | 4 +- assets/icons/git_branch.svg | 2 +- assets/icons/git_branch_alt.svg | 7 +++ assets/icons/git_branch_small.svg | 7 --- assets/icons/github.svg | 2 +- assets/icons/globe.svg | 12 ---- assets/icons/hammer.svg | 1 - assets/icons/hash.svg | 7 +-- assets/icons/image.svg | 2 +- assets/icons/inlay_hint.svg | 5 -- assets/icons/keyboard.svg | 2 +- assets/icons/layout.svg | 5 -- assets/icons/library.svg | 7 ++- assets/icons/light_bulb.svg | 3 - assets/icons/line_height.svg | 7 +-- assets/icons/link.svg | 3 - assets/icons/list_collapse.svg | 2 +- assets/icons/list_todo.svg | 2 +- assets/icons/list_x.svg | 10 ++-- assets/icons/load_circle.svg | 2 +- assets/icons/location_edit.svg | 2 +- assets/icons/logo_96.svg | 3 - assets/icons/lsp_debug.svg | 12 ---- assets/icons/lsp_restart.svg | 4 -- assets/icons/lsp_stop.svg | 4 -- assets/icons/magnifying_glass.svg | 1 + assets/icons/mail_open.svg | 1 - assets/icons/maximize.svg | 7 ++- assets/icons/menu.svg | 2 +- assets/icons/menu_alt.svg | 6 +- assets/icons/minimize.svg | 7 ++- assets/icons/notepad.svg | 1 + assets/icons/option.svg | 3 +- assets/icons/panel_left.svg | 1 - assets/icons/panel_right.svg | 1 - assets/icons/pencil.svg | 5 +- assets/icons/person.svg | 5 +- assets/icons/person_circle.svg | 1 - assets/icons/phone_incoming.svg | 1 - assets/icons/pocket_knife.svg | 1 - assets/icons/power.svg | 2 +- assets/icons/public.svg | 4 +- assets/icons/pull_request.svg | 2 +- assets/icons/quote.svg | 2 +- assets/icons/reader.svg | 5 ++ assets/icons/refresh_title.svg | 6 +- assets/icons/regex.svg | 6 +- assets/icons/repl_neutral.svg | 15 ++--- assets/icons/repl_off.svg | 29 ++++----- assets/icons/repl_pause.svg | 21 +++---- assets/icons/repl_play.svg | 19 ++---- assets/icons/rerun.svg | 8 +-- assets/icons/return.svg | 5 +- assets/icons/rotate_ccw.svg | 2 +- assets/icons/rotate_cw.svg | 5 +- assets/icons/route.svg | 1 - assets/icons/save.svg | 1 - assets/icons/scissors.svg | 2 +- assets/icons/scroll_text.svg | 1 - assets/icons/search_selection.svg | 1 - assets/icons/select_all.svg | 6 +- assets/icons/send.svg | 5 +- assets/icons/server.svg | 20 ++----- assets/icons/settings.svg | 2 +- assets/icons/settings_alt.svg | 6 -- assets/icons/shift.svg | 2 +- assets/icons/slash.svg | 4 +- assets/icons/slash_square.svg | 1 - assets/icons/sliders_alt.svg | 6 -- assets/icons/sliders_vertical.svg | 11 ---- assets/icons/snip.svg | 1 - assets/icons/space.svg | 4 +- assets/icons/sparkle.svg | 2 +- assets/icons/sparkle_alt.svg | 3 - assets/icons/sparkle_filled.svg | 3 - assets/icons/speaker_loud.svg | 8 --- assets/icons/split.svg | 8 +-- assets/icons/split_alt.svg | 2 +- assets/icons/square_dot.svg | 5 +- assets/icons/square_minus.svg | 5 +- assets/icons/square_plus.svg | 6 +- assets/icons/star_filled.svg | 2 +- assets/icons/stop.svg | 4 +- assets/icons/stop_filled.svg | 3 - assets/icons/supermaven.svg | 14 ++--- assets/icons/supermaven_disabled.svg | 16 +---- assets/icons/supermaven_error.svg | 14 ++--- assets/icons/supermaven_init.svg | 14 ++--- assets/icons/swatch_book.svg | 2 +- assets/icons/tab.svg | 6 +- assets/icons/terminal_alt.svg | 6 +- assets/icons/text_snippet.svg | 2 +- assets/icons/thumbs_down.svg | 4 +- assets/icons/thumbs_up.svg | 4 +- assets/icons/todo_complete.svg | 5 +- assets/icons/tool_folder.svg | 2 +- assets/icons/tool_terminal.svg | 6 +- assets/icons/tool_think.svg | 2 +- assets/icons/triangle.svg | 4 +- assets/icons/triangle_right.svg | 4 +- assets/icons/undo.svg | 2 +- assets/icons/update.svg | 8 --- assets/icons/user_check.svg | 2 +- assets/icons/user_round_pen.svg | 2 +- assets/icons/visible.svg | 1 - assets/icons/wand.svg | 1 - assets/icons/warning.svg | 2 +- assets/icons/whole_word.svg | 6 +- assets/icons/x.svg | 3 - assets/icons/x_circle.svg | 5 +- assets/icons/zed_assistant_filled.svg | 5 -- assets/icons/zed_burn_mode.svg | 4 +- assets/icons/zed_burn_mode_on.svg | 14 +---- assets/icons/zed_x_copilot.svg | 14 ----- crates/agent/src/context.rs | 6 +- crates/agent_ui/src/acp/thread_view.rs | 15 ++--- crates/agent_ui/src/active_thread.rs | 31 ++++++---- crates/agent_ui/src/agent_configuration.rs | 2 +- .../configure_context_server_modal.rs | 4 +- .../manage_profiles_modal.rs | 4 +- crates/agent_ui/src/context_picker.rs | 4 +- .../src/context_picker/completion_provider.rs | 6 +- crates/agent_ui/src/inline_prompt_editor.rs | 2 +- crates/agent_ui/src/message_editor.rs | 6 +- crates/agent_ui/src/slash_command_picker.rs | 2 +- crates/agent_ui/src/text_thread_editor.rs | 4 +- crates/agent_ui/src/ui/onboarding_modal.rs | 2 +- .../agent_ui/src/ui/preview/usage_callouts.rs | 2 +- crates/ai_onboarding/src/ai_onboarding.rs | 2 +- .../src/fetch_command.rs | 4 +- crates/assistant_tools/src/edit_file_tool.rs | 2 +- crates/assistant_tools/src/find_path_tool.rs | 2 +- crates/assistant_tools/src/web_search_tool.rs | 4 +- crates/collab_ui/src/collab_panel.rs | 2 +- crates/debugger_ui/src/debugger_panel.rs | 6 +- crates/debugger_ui/src/onboarding_modal.rs | 2 +- .../src/session/running/breakpoint_list.rs | 4 +- .../src/session/running/console.rs | 2 +- .../src/session/running/stack_frame_list.rs | 2 +- crates/diagnostics/src/toolbar_controls.rs | 4 +- crates/editor/src/items.rs | 2 +- .../src/components/feature_upsell.rs | 2 +- crates/git_ui/src/branch_picker.rs | 2 +- crates/git_ui/src/commit_modal.rs | 2 +- crates/git_ui/src/git_panel.rs | 12 ++-- crates/git_ui/src/git_ui.rs | 6 +- crates/git_ui/src/onboarding.rs | 2 +- crates/icons/README.md | 29 +++++++++ crates/icons/src/icons.rs | 60 +++---------------- crates/language_models/src/provider/cloud.rs | 2 +- .../language_models/src/provider/lmstudio.rs | 8 +-- crates/language_models/src/provider/ollama.rs | 8 +-- .../language_models/src/provider/open_ai.rs | 2 +- .../src/ui/instruction_list_item.rs | 2 +- crates/notifications/src/status_toast.rs | 2 +- crates/onboarding/src/ai_setup_page.rs | 2 +- crates/onboarding/src/onboarding.rs | 2 +- crates/recent_projects/src/ssh_connections.rs | 2 +- crates/repl/src/components/kernel_options.rs | 4 +- crates/repl/src/notebook/cell.rs | 2 +- crates/repl/src/notebook/notebook_ui.rs | 2 +- crates/repl/src/outputs.rs | 2 +- crates/search/src/buffer_search.rs | 2 +- crates/search/src/project_search.rs | 2 +- crates/settings_ui/src/keybindings.rs | 2 +- .../src/ui_components/keystroke_input.rs | 4 +- crates/snippets_ui/src/snippets_ui.rs | 2 +- crates/terminal_view/src/terminal_view.rs | 2 +- .../theme_selector/src/icon_theme_selector.rs | 2 +- crates/theme_selector/src/theme_selector.rs | 2 +- crates/title_bar/src/collab.rs | 2 +- crates/title_bar/src/title_bar.rs | 2 +- crates/ui/src/components/banner.rs | 4 +- crates/ui/src/components/callout.rs | 2 +- crates/ui/src/components/context_menu.rs | 2 +- crates/ui/src/components/indicator.rs | 2 +- crates/ui/src/components/keybinding.rs | 2 +- .../ui/src/components/stories/icon_button.rs | 2 +- crates/welcome/src/multibuffer_hint.rs | 2 +- crates/zed/src/zed/component_preview.rs | 2 +- .../zed/src/zed/quick_action_bar/repl_menu.rs | 2 +- crates/zeta/src/onboarding_modal.rs | 2 +- 284 files changed, 535 insertions(+), 791 deletions(-) delete mode 100644 assets/icons/arrow_down_from_line.svg delete mode 100644 assets/icons/arrow_up_alt.svg delete mode 100644 assets/icons/arrow_up_from_line.svg delete mode 100644 assets/icons/arrow_up_right_alt.svg delete mode 100644 assets/icons/bug_off.svg delete mode 100644 assets/icons/caret_down.svg delete mode 100644 assets/icons/caret_up.svg delete mode 100644 assets/icons/chevron_down_small.svg delete mode 100644 assets/icons/circle_off.svg delete mode 100644 assets/icons/cloud.svg delete mode 100644 assets/icons/context.svg delete mode 100644 assets/icons/debug_restart.svg delete mode 100644 assets/icons/debug_stop.svg delete mode 100644 assets/icons/delete.svg delete mode 100644 assets/icons/document_text.svg delete mode 100644 assets/icons/equal.svg delete mode 100644 assets/icons/external_link.svg delete mode 100644 assets/icons/file_create.svg create mode 100644 assets/icons/file_markdown.svg delete mode 100644 assets/icons/file_search.svg delete mode 100644 assets/icons/file_text.svg create mode 100644 assets/icons/file_text_filled.svg create mode 100644 assets/icons/file_text_outlined.svg create mode 100644 assets/icons/folder_search.svg delete mode 100644 assets/icons/folder_x.svg delete mode 100644 assets/icons/function.svg create mode 100644 assets/icons/git_branch_alt.svg delete mode 100644 assets/icons/git_branch_small.svg delete mode 100644 assets/icons/globe.svg delete mode 100644 assets/icons/hammer.svg delete mode 100644 assets/icons/inlay_hint.svg delete mode 100644 assets/icons/layout.svg delete mode 100644 assets/icons/light_bulb.svg delete mode 100644 assets/icons/link.svg delete mode 100644 assets/icons/logo_96.svg delete mode 100644 assets/icons/lsp_debug.svg delete mode 100644 assets/icons/lsp_restart.svg delete mode 100644 assets/icons/lsp_stop.svg delete mode 100644 assets/icons/mail_open.svg create mode 100644 assets/icons/notepad.svg delete mode 100644 assets/icons/panel_left.svg delete mode 100644 assets/icons/panel_right.svg delete mode 100644 assets/icons/person_circle.svg delete mode 100644 assets/icons/phone_incoming.svg delete mode 100644 assets/icons/pocket_knife.svg create mode 100644 assets/icons/reader.svg delete mode 100644 assets/icons/route.svg delete mode 100644 assets/icons/save.svg delete mode 100644 assets/icons/scroll_text.svg delete mode 100644 assets/icons/search_selection.svg delete mode 100644 assets/icons/settings_alt.svg delete mode 100644 assets/icons/slash_square.svg delete mode 100644 assets/icons/sliders_alt.svg delete mode 100644 assets/icons/sliders_vertical.svg delete mode 100644 assets/icons/snip.svg delete mode 100644 assets/icons/sparkle_alt.svg delete mode 100644 assets/icons/sparkle_filled.svg delete mode 100644 assets/icons/speaker_loud.svg delete mode 100644 assets/icons/stop_filled.svg delete mode 100644 assets/icons/update.svg delete mode 100644 assets/icons/visible.svg delete mode 100644 assets/icons/wand.svg delete mode 100644 assets/icons/x.svg delete mode 100644 assets/icons/zed_assistant_filled.svg delete mode 100644 assets/icons/zed_x_copilot.svg create mode 100644 crates/icons/README.md diff --git a/assets/icons/arrow_circle.svg b/assets/icons/arrow_circle.svg index 90e352bdea..790428702e 100644 --- a/assets/icons/arrow_circle.svg +++ b/assets/icons/arrow_circle.svg @@ -1,6 +1,6 @@ - - - - + + + + diff --git a/assets/icons/arrow_down.svg b/assets/icons/arrow_down.svg index 7d78497e6d..c71e5437f8 100644 --- a/assets/icons/arrow_down.svg +++ b/assets/icons/arrow_down.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/arrow_down10.svg b/assets/icons/arrow_down10.svg index 97ce967a8b..8eed82276c 100644 --- a/assets/icons/arrow_down10.svg +++ b/assets/icons/arrow_down10.svg @@ -1 +1 @@ - + diff --git a/assets/icons/arrow_down_from_line.svg b/assets/icons/arrow_down_from_line.svg deleted file mode 100644 index 89316973a0..0000000000 --- a/assets/icons/arrow_down_from_line.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/arrow_down_right.svg b/assets/icons/arrow_down_right.svg index b9c10263d0..73f72a2c38 100644 --- a/assets/icons/arrow_down_right.svg +++ b/assets/icons/arrow_down_right.svg @@ -1 +1,4 @@ - + + + + diff --git a/assets/icons/arrow_left.svg b/assets/icons/arrow_left.svg index 57ee750490..ca441497a0 100644 --- a/assets/icons/arrow_left.svg +++ b/assets/icons/arrow_left.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/arrow_right.svg b/assets/icons/arrow_right.svg index 7a5b1174eb..ae14888563 100644 --- a/assets/icons/arrow_right.svg +++ b/assets/icons/arrow_right.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/arrow_right_left.svg b/assets/icons/arrow_right_left.svg index 30331960c9..cfeee0cc24 100644 --- a/assets/icons/arrow_right_left.svg +++ b/assets/icons/arrow_right_left.svg @@ -1 +1,6 @@ - + + + + + + diff --git a/assets/icons/arrow_up.svg b/assets/icons/arrow_up.svg index 81dfee8042..b98c710374 100644 --- a/assets/icons/arrow_up.svg +++ b/assets/icons/arrow_up.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/arrow_up_alt.svg b/assets/icons/arrow_up_alt.svg deleted file mode 100644 index c8cf286a8c..0000000000 --- a/assets/icons/arrow_up_alt.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/arrow_up_from_line.svg b/assets/icons/arrow_up_from_line.svg deleted file mode 100644 index 50a075e42b..0000000000 --- a/assets/icons/arrow_up_from_line.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/arrow_up_right.svg b/assets/icons/arrow_up_right.svg index 9fbafba4ec..fb065bc9ce 100644 --- a/assets/icons/arrow_up_right.svg +++ b/assets/icons/arrow_up_right.svg @@ -1 +1,4 @@ - + + + + diff --git a/assets/icons/arrow_up_right_alt.svg b/assets/icons/arrow_up_right_alt.svg deleted file mode 100644 index 4e923c6867..0000000000 --- a/assets/icons/arrow_up_right_alt.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/backspace.svg b/assets/icons/backspace.svg index f7f1cf107a..679ef1ade1 100644 --- a/assets/icons/backspace.svg +++ b/assets/icons/backspace.svg @@ -1,3 +1,5 @@ - - + + + + diff --git a/assets/icons/binary.svg b/assets/icons/binary.svg index 8f5e456d16..bbc375617f 100644 --- a/assets/icons/binary.svg +++ b/assets/icons/binary.svg @@ -1 +1 @@ - + diff --git a/assets/icons/blocks.svg b/assets/icons/blocks.svg index 588d49abbc..128ca84ef1 100644 --- a/assets/icons/blocks.svg +++ b/assets/icons/blocks.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/book.svg b/assets/icons/book.svg index d30f81f32e..8b0f89e82d 100644 --- a/assets/icons/book.svg +++ b/assets/icons/book.svg @@ -1 +1 @@ - + diff --git a/assets/icons/book_copy.svg b/assets/icons/book_copy.svg index b055d47b5f..f509beffe6 100644 --- a/assets/icons/book_copy.svg +++ b/assets/icons/book_copy.svg @@ -1 +1 @@ - + diff --git a/assets/icons/bug_off.svg b/assets/icons/bug_off.svg deleted file mode 100644 index 23f4ef06df..0000000000 --- a/assets/icons/bug_off.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/caret_down.svg b/assets/icons/caret_down.svg deleted file mode 100644 index ff8b8c3b88..0000000000 --- a/assets/icons/caret_down.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - diff --git a/assets/icons/caret_up.svg b/assets/icons/caret_up.svg deleted file mode 100644 index 53026b83d8..0000000000 --- a/assets/icons/caret_up.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - diff --git a/assets/icons/case_sensitive.svg b/assets/icons/case_sensitive.svg index 8c943e7509..015e241416 100644 --- a/assets/icons/case_sensitive.svg +++ b/assets/icons/case_sensitive.svg @@ -1,8 +1 @@ - - - - - - - - + diff --git a/assets/icons/check.svg b/assets/icons/check.svg index 39352682c9..4563505aaa 100644 --- a/assets/icons/check.svg +++ b/assets/icons/check.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/check_circle.svg b/assets/icons/check_circle.svg index b48fe34631..e6ec5d11ef 100644 --- a/assets/icons/check_circle.svg +++ b/assets/icons/check_circle.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/assets/icons/check_double.svg b/assets/icons/check_double.svg index 5c17d95a6b..b52bef81a4 100644 --- a/assets/icons/check_double.svg +++ b/assets/icons/check_double.svg @@ -1 +1,4 @@ - + + + + diff --git a/assets/icons/chevron_down.svg b/assets/icons/chevron_down.svg index b971555cfa..7894aae764 100644 --- a/assets/icons/chevron_down.svg +++ b/assets/icons/chevron_down.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/chevron_down_small.svg b/assets/icons/chevron_down_small.svg deleted file mode 100644 index 8f8a99d4b9..0000000000 --- a/assets/icons/chevron_down_small.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/chevron_left.svg b/assets/icons/chevron_left.svg index 8e61beed5d..4be4c95dca 100644 --- a/assets/icons/chevron_left.svg +++ b/assets/icons/chevron_left.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/chevron_right.svg b/assets/icons/chevron_right.svg index fcd9d83fc2..c8ff847177 100644 --- a/assets/icons/chevron_right.svg +++ b/assets/icons/chevron_right.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/chevron_up.svg b/assets/icons/chevron_up.svg index 171cdd61c0..8e575e2e8d 100644 --- a/assets/icons/chevron_up.svg +++ b/assets/icons/chevron_up.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/chevron_up_down.svg b/assets/icons/chevron_up_down.svg index a7414ec8a0..c7af01d4a3 100644 --- a/assets/icons/chevron_up_down.svg +++ b/assets/icons/chevron_up_down.svg @@ -1 +1,4 @@ - + + + + diff --git a/assets/icons/circle.svg b/assets/icons/circle.svg index 67306cb12a..1d80edac09 100644 --- a/assets/icons/circle.svg +++ b/assets/icons/circle.svg @@ -1 +1,3 @@ - + + + diff --git a/assets/icons/circle_check.svg b/assets/icons/circle_check.svg index adfc8cecca..8950aa7a0e 100644 --- a/assets/icons/circle_check.svg +++ b/assets/icons/circle_check.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/circle_help.svg b/assets/icons/circle_help.svg index 1a004bfff8..4e2890d3e1 100644 --- a/assets/icons/circle_help.svg +++ b/assets/icons/circle_help.svg @@ -1 +1,5 @@ - + + + + + diff --git a/assets/icons/circle_off.svg b/assets/icons/circle_off.svg deleted file mode 100644 index be1bf29225..0000000000 --- a/assets/icons/circle_off.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/close.svg b/assets/icons/close.svg index 31c5aa31a6..ad487e0a4f 100644 --- a/assets/icons/close.svg +++ b/assets/icons/close.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/cloud.svg b/assets/icons/cloud.svg deleted file mode 100644 index 73a9618067..0000000000 --- a/assets/icons/cloud.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/cloud_download.svg b/assets/icons/cloud_download.svg index bc7a8376d1..0efcbe10f1 100644 --- a/assets/icons/cloud_download.svg +++ b/assets/icons/cloud_download.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/code.svg b/assets/icons/code.svg index 757c5a1cb6..6a1795b59c 100644 --- a/assets/icons/code.svg +++ b/assets/icons/code.svg @@ -1 +1 @@ - + diff --git a/assets/icons/cog.svg b/assets/icons/cog.svg index 03c0a290b7..4f3ada11a6 100644 --- a/assets/icons/cog.svg +++ b/assets/icons/cog.svg @@ -1 +1 @@ - + diff --git a/assets/icons/command.svg b/assets/icons/command.svg index d38389aea4..6602af8e1f 100644 --- a/assets/icons/command.svg +++ b/assets/icons/command.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/context.svg b/assets/icons/context.svg deleted file mode 100644 index 837b3aadd9..0000000000 --- a/assets/icons/context.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/assets/icons/control.svg b/assets/icons/control.svg index 94189dc07d..e831968df6 100644 --- a/assets/icons/control.svg +++ b/assets/icons/control.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/copilot.svg b/assets/icons/copilot.svg index 06dbf178ae..57c0a5f91a 100644 --- a/assets/icons/copilot.svg +++ b/assets/icons/copilot.svg @@ -1,9 +1,9 @@ - - - - - - - - + + + + + + + + diff --git a/assets/icons/copilot_disabled.svg b/assets/icons/copilot_disabled.svg index eba36a2b69..90afa84966 100644 --- a/assets/icons/copilot_disabled.svg +++ b/assets/icons/copilot_disabled.svg @@ -1,9 +1,9 @@ - - - - + + + + - + diff --git a/assets/icons/copilot_error.svg b/assets/icons/copilot_error.svg index 6069c554f1..77744e7529 100644 --- a/assets/icons/copilot_error.svg +++ b/assets/icons/copilot_error.svg @@ -1,7 +1,7 @@ - - + + - + diff --git a/assets/icons/copilot_init.svg b/assets/icons/copilot_init.svg index 6cbf63fb49..754d159584 100644 --- a/assets/icons/copilot_init.svg +++ b/assets/icons/copilot_init.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/copy.svg b/assets/icons/copy.svg index 7a3cdcf6da..dfd8d9dbb9 100644 --- a/assets/icons/copy.svg +++ b/assets/icons/copy.svg @@ -1,4 +1 @@ - - - - + diff --git a/assets/icons/countdown_timer.svg b/assets/icons/countdown_timer.svg index b9b7479228..5e69f1bfb4 100644 --- a/assets/icons/countdown_timer.svg +++ b/assets/icons/countdown_timer.svg @@ -1 +1 @@ - + diff --git a/assets/icons/crosshair.svg b/assets/icons/crosshair.svg index 006c6362aa..1492bf9245 100644 --- a/assets/icons/crosshair.svg +++ b/assets/icons/crosshair.svg @@ -1,7 +1,7 @@ - - - - - - + + + + + + diff --git a/assets/icons/dash.svg b/assets/icons/dash.svg index efff9eab5e..9270f80781 100644 --- a/assets/icons/dash.svg +++ b/assets/icons/dash.svg @@ -1 +1 @@ - + diff --git a/assets/icons/database_zap.svg b/assets/icons/database_zap.svg index 06241b35f4..160ffa5041 100644 --- a/assets/icons/database_zap.svg +++ b/assets/icons/database_zap.svg @@ -1 +1 @@ - + diff --git a/assets/icons/debug.svg b/assets/icons/debug.svg index ff51e42b1a..900caf4b98 100644 --- a/assets/icons/debug.svg +++ b/assets/icons/debug.svg @@ -1,12 +1,12 @@ - - - - - - - - - - + + + + + + + + + + diff --git a/assets/icons/debug_breakpoint.svg b/assets/icons/debug_breakpoint.svg index f6a7b35658..9cab42eecd 100644 --- a/assets/icons/debug_breakpoint.svg +++ b/assets/icons/debug_breakpoint.svg @@ -1 +1,3 @@ - + + + diff --git a/assets/icons/debug_continue.svg b/assets/icons/debug_continue.svg index e2a99c38d0..f663a5a041 100644 --- a/assets/icons/debug_continue.svg +++ b/assets/icons/debug_continue.svg @@ -1 +1 @@ - + diff --git a/assets/icons/debug_detach.svg b/assets/icons/debug_detach.svg index 0eb2537152..a34a0e8171 100644 --- a/assets/icons/debug_detach.svg +++ b/assets/icons/debug_detach.svg @@ -1 +1 @@ - + diff --git a/assets/icons/debug_disabled_breakpoint.svg b/assets/icons/debug_disabled_breakpoint.svg index a7260ec04b..8b80623b02 100644 --- a/assets/icons/debug_disabled_breakpoint.svg +++ b/assets/icons/debug_disabled_breakpoint.svg @@ -1 +1,3 @@ - + + + diff --git a/assets/icons/debug_disabled_log_breakpoint.svg b/assets/icons/debug_disabled_log_breakpoint.svg index d0bb2c8e2b..a028ead3a0 100644 --- a/assets/icons/debug_disabled_log_breakpoint.svg +++ b/assets/icons/debug_disabled_log_breakpoint.svg @@ -1 +1,3 @@ - + + + diff --git a/assets/icons/debug_ignore_breakpoints.svg b/assets/icons/debug_ignore_breakpoints.svg index ba7074e083..a0bbabfb26 100644 --- a/assets/icons/debug_ignore_breakpoints.svg +++ b/assets/icons/debug_ignore_breakpoints.svg @@ -1 +1 @@ - + diff --git a/assets/icons/debug_log_breakpoint.svg b/assets/icons/debug_log_breakpoint.svg index a878ce3e04..7c652db1e9 100644 --- a/assets/icons/debug_log_breakpoint.svg +++ b/assets/icons/debug_log_breakpoint.svg @@ -1 +1,3 @@ - + + + diff --git a/assets/icons/debug_pause.svg b/assets/icons/debug_pause.svg index bea531bc5a..65e1949581 100644 --- a/assets/icons/debug_pause.svg +++ b/assets/icons/debug_pause.svg @@ -1 +1,4 @@ - + + + + diff --git a/assets/icons/debug_restart.svg b/assets/icons/debug_restart.svg deleted file mode 100644 index 4eff13b94b..0000000000 --- a/assets/icons/debug_restart.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/debug_step_back.svg b/assets/icons/debug_step_back.svg index bc7c9b8444..d1112d6b8e 100644 --- a/assets/icons/debug_step_back.svg +++ b/assets/icons/debug_step_back.svg @@ -1 +1 @@ - + diff --git a/assets/icons/debug_step_into.svg b/assets/icons/debug_step_into.svg index 69e5cff3f1..02bdd63cb4 100644 --- a/assets/icons/debug_step_into.svg +++ b/assets/icons/debug_step_into.svg @@ -1,5 +1 @@ - - - - - + diff --git a/assets/icons/debug_step_out.svg b/assets/icons/debug_step_out.svg index 680e13e65e..48190b704b 100644 --- a/assets/icons/debug_step_out.svg +++ b/assets/icons/debug_step_out.svg @@ -1,5 +1 @@ - - - - - + diff --git a/assets/icons/debug_step_over.svg b/assets/icons/debug_step_over.svg index 005b901da3..54afac001f 100644 --- a/assets/icons/debug_step_over.svg +++ b/assets/icons/debug_step_over.svg @@ -1,5 +1 @@ - - - - - + diff --git a/assets/icons/debug_stop.svg b/assets/icons/debug_stop.svg deleted file mode 100644 index fef651c586..0000000000 --- a/assets/icons/debug_stop.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/delete.svg b/assets/icons/delete.svg deleted file mode 100644 index a7edbb6158..0000000000 --- a/assets/icons/delete.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/diff.svg b/assets/icons/diff.svg index ca43c379da..61aa617f5b 100644 --- a/assets/icons/diff.svg +++ b/assets/icons/diff.svg @@ -1 +1 @@ - + diff --git a/assets/icons/disconnected.svg b/assets/icons/disconnected.svg index 37d0ee904c..f3069798d0 100644 --- a/assets/icons/disconnected.svg +++ b/assets/icons/disconnected.svg @@ -1,3 +1 @@ - - - + diff --git a/assets/icons/document_text.svg b/assets/icons/document_text.svg deleted file mode 100644 index 78c08d92f9..0000000000 --- a/assets/icons/document_text.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/download.svg b/assets/icons/download.svg index 2ffa65e8ac..6ddcb1e100 100644 --- a/assets/icons/download.svg +++ b/assets/icons/download.svg @@ -1 +1 @@ - + diff --git a/assets/icons/ellipsis.svg b/assets/icons/ellipsis.svg index 1858c65520..22b5a8fd46 100644 --- a/assets/icons/ellipsis.svg +++ b/assets/icons/ellipsis.svg @@ -1,5 +1,5 @@ - - - - + + + + diff --git a/assets/icons/ellipsis_vertical.svg b/assets/icons/ellipsis_vertical.svg index 077dbe8778..c38437667e 100644 --- a/assets/icons/ellipsis_vertical.svg +++ b/assets/icons/ellipsis_vertical.svg @@ -1 +1,5 @@ - + + + + + diff --git a/assets/icons/equal.svg b/assets/icons/equal.svg deleted file mode 100644 index 9b3a151a12..0000000000 --- a/assets/icons/equal.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/eraser.svg b/assets/icons/eraser.svg index edb893a8c6..601f2b9b90 100644 --- a/assets/icons/eraser.svg +++ b/assets/icons/eraser.svg @@ -1,4 +1 @@ - - - - + diff --git a/assets/icons/escape.svg b/assets/icons/escape.svg index 00c772a2ad..a87f03d2fa 100644 --- a/assets/icons/escape.svg +++ b/assets/icons/escape.svg @@ -1 +1 @@ - + diff --git a/assets/icons/expand_down.svg b/assets/icons/expand_down.svg index a17b9e285c..07390aad18 100644 --- a/assets/icons/expand_down.svg +++ b/assets/icons/expand_down.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/assets/icons/expand_up.svg b/assets/icons/expand_up.svg index 30f9af92e3..73c1358b99 100644 --- a/assets/icons/expand_up.svg +++ b/assets/icons/expand_up.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/assets/icons/expand_vertical.svg b/assets/icons/expand_vertical.svg index e278911478..e2a6dd227e 100644 --- a/assets/icons/expand_vertical.svg +++ b/assets/icons/expand_vertical.svg @@ -1 +1 @@ - + diff --git a/assets/icons/external_link.svg b/assets/icons/external_link.svg deleted file mode 100644 index 561f012452..0000000000 --- a/assets/icons/external_link.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/assets/icons/eye.svg b/assets/icons/eye.svg index 21e3d3ba63..7f10f73801 100644 --- a/assets/icons/eye.svg +++ b/assets/icons/eye.svg @@ -1 +1,4 @@ - + + + + diff --git a/assets/icons/file.svg b/assets/icons/file.svg index 5b1b892756..85f3f543a5 100644 --- a/assets/icons/file.svg +++ b/assets/icons/file.svg @@ -1,4 +1 @@ - - - - + diff --git a/assets/icons/file_code.svg b/assets/icons/file_code.svg index 0a15da7705..b0e632b67f 100644 --- a/assets/icons/file_code.svg +++ b/assets/icons/file_code.svg @@ -1 +1 @@ - + diff --git a/assets/icons/file_create.svg b/assets/icons/file_create.svg deleted file mode 100644 index bd7f88a7ec..0000000000 --- a/assets/icons/file_create.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/assets/icons/file_diff.svg b/assets/icons/file_diff.svg index ff20f16c60..d6cb4440ea 100644 --- a/assets/icons/file_diff.svg +++ b/assets/icons/file_diff.svg @@ -1 +1 @@ - + diff --git a/assets/icons/file_markdown.svg b/assets/icons/file_markdown.svg new file mode 100644 index 0000000000..e26d7a532d --- /dev/null +++ b/assets/icons/file_markdown.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/file_search.svg b/assets/icons/file_search.svg deleted file mode 100644 index ddf5b14770..0000000000 --- a/assets/icons/file_search.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/assets/icons/file_text.svg b/assets/icons/file_text.svg deleted file mode 100644 index a9b8f971e0..0000000000 --- a/assets/icons/file_text.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/assets/icons/file_text_filled.svg b/assets/icons/file_text_filled.svg new file mode 100644 index 0000000000..15c81cca62 --- /dev/null +++ b/assets/icons/file_text_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/file_text_outlined.svg b/assets/icons/file_text_outlined.svg new file mode 100644 index 0000000000..bb9b85d62f --- /dev/null +++ b/assets/icons/file_text_outlined.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/file_tree.svg b/assets/icons/file_tree.svg index a140cd70b1..74acb1fc25 100644 --- a/assets/icons/file_tree.svg +++ b/assets/icons/file_tree.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/flame.svg b/assets/icons/flame.svg index 075e027a5c..3215f0d5ae 100644 --- a/assets/icons/flame.svg +++ b/assets/icons/flame.svg @@ -1 +1 @@ - + diff --git a/assets/icons/folder.svg b/assets/icons/folder.svg index 1a40805a70..0d76b7e3f8 100644 --- a/assets/icons/folder.svg +++ b/assets/icons/folder.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/folder_search.svg b/assets/icons/folder_search.svg new file mode 100644 index 0000000000..15b0705dd6 --- /dev/null +++ b/assets/icons/folder_search.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/folder_x.svg b/assets/icons/folder_x.svg deleted file mode 100644 index b0f06f68eb..0000000000 --- a/assets/icons/folder_x.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/assets/icons/font.svg b/assets/icons/font.svg index 861ab1a415..1cc569ecb7 100644 --- a/assets/icons/font.svg +++ b/assets/icons/font.svg @@ -1 +1 @@ - + diff --git a/assets/icons/font_size.svg b/assets/icons/font_size.svg index cfba2deb6c..fd983cb5d3 100644 --- a/assets/icons/font_size.svg +++ b/assets/icons/font_size.svg @@ -1 +1 @@ - + diff --git a/assets/icons/font_weight.svg b/assets/icons/font_weight.svg index 3ebbfa77bc..73b9852e2f 100644 --- a/assets/icons/font_weight.svg +++ b/assets/icons/font_weight.svg @@ -1 +1 @@ - + diff --git a/assets/icons/forward_arrow.svg b/assets/icons/forward_arrow.svg index 0a7b71993f..503b0b309b 100644 --- a/assets/icons/forward_arrow.svg +++ b/assets/icons/forward_arrow.svg @@ -1 +1,4 @@ - + + + + diff --git a/assets/icons/function.svg b/assets/icons/function.svg deleted file mode 100644 index 5d0b9d58ef..0000000000 --- a/assets/icons/function.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/generic_maximize.svg b/assets/icons/generic_maximize.svg index e44abd8f06..f1d7da44ef 100644 --- a/assets/icons/generic_maximize.svg +++ b/assets/icons/generic_maximize.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/generic_restore.svg b/assets/icons/generic_restore.svg index 3bf581f2cd..d8a3d72bcd 100644 --- a/assets/icons/generic_restore.svg +++ b/assets/icons/generic_restore.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/git_branch.svg b/assets/icons/git_branch.svg index db6190a9c8..811bc74762 100644 --- a/assets/icons/git_branch.svg +++ b/assets/icons/git_branch.svg @@ -1 +1 @@ - + diff --git a/assets/icons/git_branch_alt.svg b/assets/icons/git_branch_alt.svg new file mode 100644 index 0000000000..d18b072512 --- /dev/null +++ b/assets/icons/git_branch_alt.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/git_branch_small.svg b/assets/icons/git_branch_small.svg deleted file mode 100644 index 22832d6fed..0000000000 --- a/assets/icons/git_branch_small.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/assets/icons/github.svg b/assets/icons/github.svg index 28148b9894..fe9186872b 100644 --- a/assets/icons/github.svg +++ b/assets/icons/github.svg @@ -1 +1 @@ - + diff --git a/assets/icons/globe.svg b/assets/icons/globe.svg deleted file mode 100644 index 545b83aa71..0000000000 --- a/assets/icons/globe.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/assets/icons/hammer.svg b/assets/icons/hammer.svg deleted file mode 100644 index ccc0d30e3d..0000000000 --- a/assets/icons/hammer.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/hash.svg b/assets/icons/hash.svg index f685245ed3..9e4dd7c068 100644 --- a/assets/icons/hash.svg +++ b/assets/icons/hash.svg @@ -1,6 +1 @@ - - - - - - + diff --git a/assets/icons/image.svg b/assets/icons/image.svg index 4b17300f47..0a26c35182 100644 --- a/assets/icons/image.svg +++ b/assets/icons/image.svg @@ -1 +1 @@ - + diff --git a/assets/icons/inlay_hint.svg b/assets/icons/inlay_hint.svg deleted file mode 100644 index c8e6bb2d36..0000000000 --- a/assets/icons/inlay_hint.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/assets/icons/keyboard.svg b/assets/icons/keyboard.svg index 8bdc054a65..de9afd9561 100644 --- a/assets/icons/keyboard.svg +++ b/assets/icons/keyboard.svg @@ -1 +1 @@ - + diff --git a/assets/icons/layout.svg b/assets/icons/layout.svg deleted file mode 100644 index 79464013b1..0000000000 --- a/assets/icons/layout.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/assets/icons/library.svg b/assets/icons/library.svg index 95f8c710c8..ed59e1818b 100644 --- a/assets/icons/library.svg +++ b/assets/icons/library.svg @@ -1 +1,6 @@ - + + + + + + diff --git a/assets/icons/light_bulb.svg b/assets/icons/light_bulb.svg deleted file mode 100644 index 61a8f04211..0000000000 --- a/assets/icons/light_bulb.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/line_height.svg b/assets/icons/line_height.svg index 904cfad8a8..7afa70f767 100644 --- a/assets/icons/line_height.svg +++ b/assets/icons/line_height.svg @@ -1,6 +1 @@ - - - - - - + diff --git a/assets/icons/link.svg b/assets/icons/link.svg deleted file mode 100644 index 4925bd8e00..0000000000 --- a/assets/icons/link.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/list_collapse.svg b/assets/icons/list_collapse.svg index a0e0ed604d..938799b151 100644 --- a/assets/icons/list_collapse.svg +++ b/assets/icons/list_collapse.svg @@ -1 +1 @@ - + diff --git a/assets/icons/list_todo.svg b/assets/icons/list_todo.svg index 1f50219418..019af95734 100644 --- a/assets/icons/list_todo.svg +++ b/assets/icons/list_todo.svg @@ -1 +1 @@ - + diff --git a/assets/icons/list_x.svg b/assets/icons/list_x.svg index 683f38ab5d..206faf2ce4 100644 --- a/assets/icons/list_x.svg +++ b/assets/icons/list_x.svg @@ -1,7 +1,7 @@ - - - - - + + + + + diff --git a/assets/icons/load_circle.svg b/assets/icons/load_circle.svg index c4de36b1ff..825aa335b0 100644 --- a/assets/icons/load_circle.svg +++ b/assets/icons/load_circle.svg @@ -1 +1 @@ - + diff --git a/assets/icons/location_edit.svg b/assets/icons/location_edit.svg index de82e8db4e..02cd6f3389 100644 --- a/assets/icons/location_edit.svg +++ b/assets/icons/location_edit.svg @@ -1 +1 @@ - + diff --git a/assets/icons/logo_96.svg b/assets/icons/logo_96.svg deleted file mode 100644 index dc98bb8bc2..0000000000 --- a/assets/icons/logo_96.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/lsp_debug.svg b/assets/icons/lsp_debug.svg deleted file mode 100644 index aa49fcb6a2..0000000000 --- a/assets/icons/lsp_debug.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/assets/icons/lsp_restart.svg b/assets/icons/lsp_restart.svg deleted file mode 100644 index dfc68e7a9e..0000000000 --- a/assets/icons/lsp_restart.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/assets/icons/lsp_stop.svg b/assets/icons/lsp_stop.svg deleted file mode 100644 index c6311d2155..0000000000 --- a/assets/icons/lsp_stop.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/assets/icons/magnifying_glass.svg b/assets/icons/magnifying_glass.svg index 75c3e76c80..b7c22e64bd 100644 --- a/assets/icons/magnifying_glass.svg +++ b/assets/icons/magnifying_glass.svg @@ -1,3 +1,4 @@ + diff --git a/assets/icons/mail_open.svg b/assets/icons/mail_open.svg deleted file mode 100644 index b857037b86..0000000000 --- a/assets/icons/mail_open.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/maximize.svg b/assets/icons/maximize.svg index b3504b5701..c51b71aaf0 100644 --- a/assets/icons/maximize.svg +++ b/assets/icons/maximize.svg @@ -1 +1,6 @@ - + + + + + + diff --git a/assets/icons/menu.svg b/assets/icons/menu.svg index 6598697ff8..0724fb2816 100644 --- a/assets/icons/menu.svg +++ b/assets/icons/menu.svg @@ -1 +1 @@ - + diff --git a/assets/icons/menu_alt.svg b/assets/icons/menu_alt.svg index ae3581ba01..b605e094e3 100644 --- a/assets/icons/menu_alt.svg +++ b/assets/icons/menu_alt.svg @@ -1,5 +1 @@ - - - - - + diff --git a/assets/icons/minimize.svg b/assets/icons/minimize.svg index 0451233cc9..97d4699687 100644 --- a/assets/icons/minimize.svg +++ b/assets/icons/minimize.svg @@ -1 +1,6 @@ - + + + + + + diff --git a/assets/icons/notepad.svg b/assets/icons/notepad.svg new file mode 100644 index 0000000000..48875eedee --- /dev/null +++ b/assets/icons/notepad.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/option.svg b/assets/icons/option.svg index 9d54a6f34b..676c10c93b 100644 --- a/assets/icons/option.svg +++ b/assets/icons/option.svg @@ -1,3 +1,4 @@ - + + diff --git a/assets/icons/panel_left.svg b/assets/icons/panel_left.svg deleted file mode 100644 index 2eed26673e..0000000000 --- a/assets/icons/panel_left.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/panel_right.svg b/assets/icons/panel_right.svg deleted file mode 100644 index d29a4a519e..0000000000 --- a/assets/icons/panel_right.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/pencil.svg b/assets/icons/pencil.svg index d90dcda10d..b913015c08 100644 --- a/assets/icons/pencil.svg +++ b/assets/icons/pencil.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/assets/icons/person.svg b/assets/icons/person.svg index 93bee97a5f..c641678303 100644 --- a/assets/icons/person.svg +++ b/assets/icons/person.svg @@ -1,4 +1 @@ - - - - + diff --git a/assets/icons/person_circle.svg b/assets/icons/person_circle.svg deleted file mode 100644 index 7e22682e0e..0000000000 --- a/assets/icons/person_circle.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/phone_incoming.svg b/assets/icons/phone_incoming.svg deleted file mode 100644 index 4577df47ad..0000000000 --- a/assets/icons/phone_incoming.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/pocket_knife.svg b/assets/icons/pocket_knife.svg deleted file mode 100644 index fb2d078e20..0000000000 --- a/assets/icons/pocket_knife.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/power.svg b/assets/icons/power.svg index 787d1a3519..23f6f48f30 100644 --- a/assets/icons/power.svg +++ b/assets/icons/power.svg @@ -1 +1 @@ - + diff --git a/assets/icons/public.svg b/assets/icons/public.svg index 38278cdaba..574ee1010d 100644 --- a/assets/icons/public.svg +++ b/assets/icons/public.svg @@ -1,3 +1 @@ - - - + diff --git a/assets/icons/pull_request.svg b/assets/icons/pull_request.svg index 150a532cc6..ccfaaacfdc 100644 --- a/assets/icons/pull_request.svg +++ b/assets/icons/pull_request.svg @@ -1 +1 @@ - + diff --git a/assets/icons/quote.svg b/assets/icons/quote.svg index b970db1430..5564a60f95 100644 --- a/assets/icons/quote.svg +++ b/assets/icons/quote.svg @@ -1 +1 @@ - + diff --git a/assets/icons/reader.svg b/assets/icons/reader.svg new file mode 100644 index 0000000000..2ccc37623d --- /dev/null +++ b/assets/icons/reader.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/refresh_title.svg b/assets/icons/refresh_title.svg index bd3657d48c..8a8fdb04f3 100644 --- a/assets/icons/refresh_title.svg +++ b/assets/icons/refresh_title.svg @@ -1,5 +1 @@ - - - - - + diff --git a/assets/icons/regex.svg b/assets/icons/regex.svg index 1b24398cc1..0432cd570f 100644 --- a/assets/icons/regex.svg +++ b/assets/icons/regex.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/assets/icons/repl_neutral.svg b/assets/icons/repl_neutral.svg index db647fe40b..d9c8b001df 100644 --- a/assets/icons/repl_neutral.svg +++ b/assets/icons/repl_neutral.svg @@ -1,13 +1,6 @@ - - - - - - - - - - - + + + + diff --git a/assets/icons/repl_off.svg b/assets/icons/repl_off.svg index 51ada0db46..ac249ad5ff 100644 --- a/assets/icons/repl_off.svg +++ b/assets/icons/repl_off.svg @@ -1,20 +1,11 @@ - - - - - - - - - - - - - - - - - - - + + + + + + + + + + diff --git a/assets/icons/repl_pause.svg b/assets/icons/repl_pause.svg index 2ac327df3b..5273ed60bb 100644 --- a/assets/icons/repl_pause.svg +++ b/assets/icons/repl_pause.svg @@ -1,15 +1,8 @@ - - - - - - - - - - - - - - + + + + + + + diff --git a/assets/icons/repl_play.svg b/assets/icons/repl_play.svg index d23b899112..76c292a382 100644 --- a/assets/icons/repl_play.svg +++ b/assets/icons/repl_play.svg @@ -1,14 +1,7 @@ - - - - - - - - - - - - - + + + + + + diff --git a/assets/icons/rerun.svg b/assets/icons/rerun.svg index 4d22f924f5..a5daa5de1d 100644 --- a/assets/icons/rerun.svg +++ b/assets/icons/rerun.svg @@ -1,7 +1 @@ - - - - - - - + diff --git a/assets/icons/return.svg b/assets/icons/return.svg index 16cfeeda2e..aed9242a95 100644 --- a/assets/icons/return.svg +++ b/assets/icons/return.svg @@ -1 +1,4 @@ - + + + + diff --git a/assets/icons/rotate_ccw.svg b/assets/icons/rotate_ccw.svg index 4eff13b94b..8f6bd6346a 100644 --- a/assets/icons/rotate_ccw.svg +++ b/assets/icons/rotate_ccw.svg @@ -1 +1 @@ - + diff --git a/assets/icons/rotate_cw.svg b/assets/icons/rotate_cw.svg index 2098de38c2..b082096ee4 100644 --- a/assets/icons/rotate_cw.svg +++ b/assets/icons/rotate_cw.svg @@ -1,4 +1 @@ - - - - + diff --git a/assets/icons/route.svg b/assets/icons/route.svg deleted file mode 100644 index 7d2a5621ff..0000000000 --- a/assets/icons/route.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/save.svg b/assets/icons/save.svg deleted file mode 100644 index f83d035331..0000000000 --- a/assets/icons/save.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/scissors.svg b/assets/icons/scissors.svg index e7fb6005f4..89d246841e 100644 --- a/assets/icons/scissors.svg +++ b/assets/icons/scissors.svg @@ -1 +1 @@ - + diff --git a/assets/icons/scroll_text.svg b/assets/icons/scroll_text.svg deleted file mode 100644 index f066c8a84e..0000000000 --- a/assets/icons/scroll_text.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/search_selection.svg b/assets/icons/search_selection.svg deleted file mode 100644 index b970db1430..0000000000 --- a/assets/icons/search_selection.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/select_all.svg b/assets/icons/select_all.svg index 78c3ee6399..c15973c419 100644 --- a/assets/icons/select_all.svg +++ b/assets/icons/select_all.svg @@ -1,5 +1 @@ - - - - - + diff --git a/assets/icons/send.svg b/assets/icons/send.svg index 0d6ad36341..1403a43ff5 100644 --- a/assets/icons/send.svg +++ b/assets/icons/send.svg @@ -1,4 +1 @@ - - - - + diff --git a/assets/icons/server.svg b/assets/icons/server.svg index a8b6ad92b3..bde19efd75 100644 --- a/assets/icons/server.svg +++ b/assets/icons/server.svg @@ -1,16 +1,6 @@ - - - - - + + + + + diff --git a/assets/icons/settings.svg b/assets/icons/settings.svg index a82cf03398..617b14b3cd 100644 --- a/assets/icons/settings.svg +++ b/assets/icons/settings.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/icons/settings_alt.svg b/assets/icons/settings_alt.svg deleted file mode 100644 index a5fb4171d5..0000000000 --- a/assets/icons/settings_alt.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/assets/icons/shift.svg b/assets/icons/shift.svg index 0232114777..35dc2f144c 100644 --- a/assets/icons/shift.svg +++ b/assets/icons/shift.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/slash.svg b/assets/icons/slash.svg index 792c405bb0..e2313f0099 100644 --- a/assets/icons/slash.svg +++ b/assets/icons/slash.svg @@ -1 +1,3 @@ - + + + diff --git a/assets/icons/slash_square.svg b/assets/icons/slash_square.svg deleted file mode 100644 index 8f269ddeb5..0000000000 --- a/assets/icons/slash_square.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/sliders_alt.svg b/assets/icons/sliders_alt.svg deleted file mode 100644 index 36c3feccfe..0000000000 --- a/assets/icons/sliders_alt.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/assets/icons/sliders_vertical.svg b/assets/icons/sliders_vertical.svg deleted file mode 100644 index ab61037a51..0000000000 --- a/assets/icons/sliders_vertical.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/assets/icons/snip.svg b/assets/icons/snip.svg deleted file mode 100644 index 03ae4ce039..0000000000 --- a/assets/icons/snip.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/space.svg b/assets/icons/space.svg index 63718fb4aa..86bd55cd53 100644 --- a/assets/icons/space.svg +++ b/assets/icons/space.svg @@ -1 +1,3 @@ - + + + diff --git a/assets/icons/sparkle.svg b/assets/icons/sparkle.svg index f420f527f1..e5cce9fafd 100644 --- a/assets/icons/sparkle.svg +++ b/assets/icons/sparkle.svg @@ -1 +1 @@ - + diff --git a/assets/icons/sparkle_alt.svg b/assets/icons/sparkle_alt.svg deleted file mode 100644 index d5c227b105..0000000000 --- a/assets/icons/sparkle_alt.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/sparkle_filled.svg b/assets/icons/sparkle_filled.svg deleted file mode 100644 index 96837f618d..0000000000 --- a/assets/icons/sparkle_filled.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/speaker_loud.svg b/assets/icons/speaker_loud.svg deleted file mode 100644 index 68982ee5e9..0000000000 --- a/assets/icons/speaker_loud.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - diff --git a/assets/icons/split.svg b/assets/icons/split.svg index 4c131466c2..eb031ab790 100644 --- a/assets/icons/split.svg +++ b/assets/icons/split.svg @@ -1,5 +1,5 @@ - - - - + + + + diff --git a/assets/icons/split_alt.svg b/assets/icons/split_alt.svg index 3f7622701d..5b99b7a26a 100644 --- a/assets/icons/split_alt.svg +++ b/assets/icons/split_alt.svg @@ -1 +1 @@ - + diff --git a/assets/icons/square_dot.svg b/assets/icons/square_dot.svg index 2c1d8afdcb..4bb684afb2 100644 --- a/assets/icons/square_dot.svg +++ b/assets/icons/square_dot.svg @@ -1 +1,4 @@ - + + + + diff --git a/assets/icons/square_minus.svg b/assets/icons/square_minus.svg index a9ab42c408..4b8fc4d982 100644 --- a/assets/icons/square_minus.svg +++ b/assets/icons/square_minus.svg @@ -1 +1,4 @@ - + + + + diff --git a/assets/icons/square_plus.svg b/assets/icons/square_plus.svg index 8cbe3dc0e7..e0ee106b52 100644 --- a/assets/icons/square_plus.svg +++ b/assets/icons/square_plus.svg @@ -1 +1,5 @@ - + + + + + diff --git a/assets/icons/star_filled.svg b/assets/icons/star_filled.svg index 89b03ded29..d7de9939db 100644 --- a/assets/icons/star_filled.svg +++ b/assets/icons/star_filled.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/stop.svg b/assets/icons/stop.svg index 6291a34c08..41e4fd35e9 100644 --- a/assets/icons/stop.svg +++ b/assets/icons/stop.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/stop_filled.svg b/assets/icons/stop_filled.svg deleted file mode 100644 index caf40d197e..0000000000 --- a/assets/icons/stop_filled.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/supermaven.svg b/assets/icons/supermaven.svg index 19837fbf56..af778c70b7 100644 --- a/assets/icons/supermaven.svg +++ b/assets/icons/supermaven.svg @@ -1,8 +1,8 @@ - - - - - - - + + + + + + + diff --git a/assets/icons/supermaven_disabled.svg b/assets/icons/supermaven_disabled.svg index 39ff8a6122..25eea54cde 100644 --- a/assets/icons/supermaven_disabled.svg +++ b/assets/icons/supermaven_disabled.svg @@ -1,15 +1 @@ - - - - - - - - - - - - - - - + diff --git a/assets/icons/supermaven_error.svg b/assets/icons/supermaven_error.svg index 669322b97d..a0a12e17c3 100644 --- a/assets/icons/supermaven_error.svg +++ b/assets/icons/supermaven_error.svg @@ -1,11 +1,11 @@ - - - - - - + + + + + + - + diff --git a/assets/icons/supermaven_init.svg b/assets/icons/supermaven_init.svg index b919d5559b..6851aad49d 100644 --- a/assets/icons/supermaven_init.svg +++ b/assets/icons/supermaven_init.svg @@ -1,11 +1,11 @@ - - - - - - + + + + + + - + diff --git a/assets/icons/swatch_book.svg b/assets/icons/swatch_book.svg index 985994ffcf..99a1c88bd5 100644 --- a/assets/icons/swatch_book.svg +++ b/assets/icons/swatch_book.svg @@ -1 +1 @@ - + diff --git a/assets/icons/tab.svg b/assets/icons/tab.svg index 49a3536bed..f16d51ccf5 100644 --- a/assets/icons/tab.svg +++ b/assets/icons/tab.svg @@ -1 +1,5 @@ - + + + + + diff --git a/assets/icons/terminal_alt.svg b/assets/icons/terminal_alt.svg index 7afb89db21..82d88167b2 100644 --- a/assets/icons/terminal_alt.svg +++ b/assets/icons/terminal_alt.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/text_snippet.svg b/assets/icons/text_snippet.svg index 255635de6a..12f131fdd5 100644 --- a/assets/icons/text_snippet.svg +++ b/assets/icons/text_snippet.svg @@ -1 +1 @@ - + diff --git a/assets/icons/thumbs_down.svg b/assets/icons/thumbs_down.svg index 2edc09acd1..334115a014 100644 --- a/assets/icons/thumbs_down.svg +++ b/assets/icons/thumbs_down.svg @@ -1,3 +1 @@ - - - + diff --git a/assets/icons/thumbs_up.svg b/assets/icons/thumbs_up.svg index ff4406034d..b1e435936b 100644 --- a/assets/icons/thumbs_up.svg +++ b/assets/icons/thumbs_up.svg @@ -1,3 +1 @@ - - - + diff --git a/assets/icons/todo_complete.svg b/assets/icons/todo_complete.svg index 9fa2e818bb..d50044e435 100644 --- a/assets/icons/todo_complete.svg +++ b/assets/icons/todo_complete.svg @@ -1,4 +1 @@ - - - - + diff --git a/assets/icons/tool_folder.svg b/assets/icons/tool_folder.svg index 9d3ac299d2..0d76b7e3f8 100644 --- a/assets/icons/tool_folder.svg +++ b/assets/icons/tool_folder.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/tool_terminal.svg b/assets/icons/tool_terminal.svg index 5154fa8e70..3c4ab42a4d 100644 --- a/assets/icons/tool_terminal.svg +++ b/assets/icons/tool_terminal.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/tool_think.svg b/assets/icons/tool_think.svg index 54d5ac5fd7..595f8070d8 100644 --- a/assets/icons/tool_think.svg +++ b/assets/icons/tool_think.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/triangle.svg b/assets/icons/triangle.svg index 0ecf071e24..c36d382e73 100644 --- a/assets/icons/triangle.svg +++ b/assets/icons/triangle.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/triangle_right.svg b/assets/icons/triangle_right.svg index 2c78a316f7..bb82d8e637 100644 --- a/assets/icons/triangle_right.svg +++ b/assets/icons/triangle_right.svg @@ -1 +1,3 @@ - + + + diff --git a/assets/icons/undo.svg b/assets/icons/undo.svg index 907cc77195..b2407456dc 100644 --- a/assets/icons/undo.svg +++ b/assets/icons/undo.svg @@ -1 +1 @@ - + diff --git a/assets/icons/update.svg b/assets/icons/update.svg deleted file mode 100644 index b529b2b08b..0000000000 --- a/assets/icons/update.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - diff --git a/assets/icons/user_check.svg b/assets/icons/user_check.svg index e5f13feeb4..cd682b5eda 100644 --- a/assets/icons/user_check.svg +++ b/assets/icons/user_check.svg @@ -1 +1 @@ - + diff --git a/assets/icons/user_round_pen.svg b/assets/icons/user_round_pen.svg index e25bf10469..eb75517323 100644 --- a/assets/icons/user_round_pen.svg +++ b/assets/icons/user_round_pen.svg @@ -1 +1 @@ - + diff --git a/assets/icons/visible.svg b/assets/icons/visible.svg deleted file mode 100644 index 0a7e65d60d..0000000000 --- a/assets/icons/visible.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/wand.svg b/assets/icons/wand.svg deleted file mode 100644 index a6704b1c42..0000000000 --- a/assets/icons/wand.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/warning.svg b/assets/icons/warning.svg index c48a575a90..456799fa5a 100644 --- a/assets/icons/warning.svg +++ b/assets/icons/warning.svg @@ -1 +1 @@ - + diff --git a/assets/icons/whole_word.svg b/assets/icons/whole_word.svg index beca4cbe82..77cecce38c 100644 --- a/assets/icons/whole_word.svg +++ b/assets/icons/whole_word.svg @@ -1,5 +1 @@ - - - - - + diff --git a/assets/icons/x.svg b/assets/icons/x.svg deleted file mode 100644 index 5d91a9edd9..0000000000 --- a/assets/icons/x.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/x_circle.svg b/assets/icons/x_circle.svg index 593629beee..69aaa3f6a1 100644 --- a/assets/icons/x_circle.svg +++ b/assets/icons/x_circle.svg @@ -1,4 +1 @@ - - - - + diff --git a/assets/icons/zed_assistant_filled.svg b/assets/icons/zed_assistant_filled.svg deleted file mode 100644 index 8d16fd9849..0000000000 --- a/assets/icons/zed_assistant_filled.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/assets/icons/zed_burn_mode.svg b/assets/icons/zed_burn_mode.svg index 544368d8e0..f6192d16e7 100644 --- a/assets/icons/zed_burn_mode.svg +++ b/assets/icons/zed_burn_mode.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/zed_burn_mode_on.svg b/assets/icons/zed_burn_mode_on.svg index 94230b6fd6..29a74a3e63 100644 --- a/assets/icons/zed_burn_mode_on.svg +++ b/assets/icons/zed_burn_mode_on.svg @@ -1,13 +1 @@ - - - - - - - - - - - - - + diff --git a/assets/icons/zed_x_copilot.svg b/assets/icons/zed_x_copilot.svg deleted file mode 100644 index d024678c50..0000000000 --- a/assets/icons/zed_x_copilot.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/crates/agent/src/context.rs b/crates/agent/src/context.rs index cd366b8308..8cdb87ef8d 100644 --- a/crates/agent/src/context.rs +++ b/crates/agent/src/context.rs @@ -20,7 +20,7 @@ use text::{Anchor, OffsetRangeExt as _}; use util::markdown::MarkdownCodeBlock; use util::{ResultExt as _, post_inc}; -pub const RULES_ICON: IconName = IconName::Context; +pub const RULES_ICON: IconName = IconName::Reader; pub enum ContextKind { File, @@ -40,8 +40,8 @@ impl ContextKind { ContextKind::File => IconName::File, ContextKind::Directory => IconName::Folder, ContextKind::Symbol => IconName::Code, - ContextKind::Selection => IconName::Context, - ContextKind::FetchedUrl => IconName::Globe, + ContextKind::Selection => IconName::Reader, + ContextKind::FetchedUrl => IconName::ToolWeb, ContextKind::Thread => IconName::Thread, ContextKind::TextThread => IconName::TextThread, ContextKind::Rules => RULES_ICON, diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 7f4e7e7208..74bbac2b1c 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -1094,7 +1094,7 @@ impl AcpThreadView { status: acp::ToolCallStatus::Failed, .. } => Some( - Icon::new(IconName::X) + Icon::new(IconName::Close) .color(Color::Error) .size(IconSize::Small) .into_any_element(), @@ -1351,10 +1351,10 @@ impl AcpThreadView { this.icon(IconName::CheckDouble).icon_color(Color::Success) } acp::PermissionOptionKind::RejectOnce => { - this.icon(IconName::X).icon_color(Color::Error) + this.icon(IconName::Close).icon_color(Color::Error) } acp::PermissionOptionKind::RejectAlways => { - this.icon(IconName::X).icon_color(Color::Error) + this.icon(IconName::Close).icon_color(Color::Error) } }) .icon_position(IconPosition::Start) @@ -2118,7 +2118,7 @@ impl AcpThreadView { .hover(|this| this.opacity(1.0)) .child( IconButton::new("toggle-height", expand_icon) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .icon_color(Color::Muted) .tooltip({ let focus_handle = focus_handle.clone(); @@ -2168,7 +2168,7 @@ impl AcpThreadView { })) .into_any_element() } else { - IconButton::new("stop-generation", IconName::StopFilled) + IconButton::new("stop-generation", IconName::Stop) .icon_color(Color::Error) .style(ButtonStyle::Tinted(ui::TintColor::Error)) .tooltip(move |window, cx| { @@ -2537,7 +2537,7 @@ impl AcpThreadView { } fn render_thread_controls(&self, cx: &Context) -> impl IntoElement { - let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileText) + let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown) .icon_size(IconSize::XSmall) .icon_color(Color::Ignored) .tooltip(Tooltip::text("Open Thread as Markdown")) @@ -2548,7 +2548,7 @@ impl AcpThreadView { } })); - let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUpAlt) + let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp) .icon_size(IconSize::XSmall) .icon_color(Color::Ignored) .tooltip(Tooltip::text("Scroll To Top")) @@ -2560,6 +2560,7 @@ impl AcpThreadView { .w_full() .mr_1() .pb_2() + .gap_1() .px(RESPONSE_PADDING_X) .opacity(0.4) .hover(|style| style.opacity(1.)) diff --git a/crates/agent_ui/src/active_thread.rs b/crates/agent_ui/src/active_thread.rs index 71526c8fe1..ffed62d41f 100644 --- a/crates/agent_ui/src/active_thread.rs +++ b/crates/agent_ui/src/active_thread.rs @@ -434,7 +434,7 @@ fn render_markdown_code_block( .child(content) .child( Icon::new(IconName::ArrowUpRight) - .size(IconSize::XSmall) + .size(IconSize::Small) .color(Color::Ignored), ), ) @@ -1896,8 +1896,9 @@ impl ActiveThread { (colors.editor_background, colors.panel_background) }; - let open_as_markdown = IconButton::new(("open-as-markdown", ix), IconName::DocumentText) - .icon_size(IconSize::XSmall) + let open_as_markdown = IconButton::new(("open-as-markdown", ix), IconName::FileMarkdown) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) .icon_color(Color::Ignored) .tooltip(Tooltip::text("Open Thread as Markdown")) .on_click({ @@ -1911,8 +1912,9 @@ impl ActiveThread { } }); - let scroll_to_top = IconButton::new(("scroll_to_top", ix), IconName::ArrowUpAlt) - .icon_size(IconSize::XSmall) + let scroll_to_top = IconButton::new(("scroll_to_top", ix), IconName::ArrowUp) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) .icon_color(Color::Ignored) .tooltip(Tooltip::text("Scroll To Top")) .on_click(cx.listener(move |this, _, _, cx| { @@ -1926,6 +1928,7 @@ impl ActiveThread { .py_2() .px(RESPONSE_PADDING_X) .mr_1() + .gap_1() .opacity(0.4) .hover(|style| style.opacity(1.)) .gap_1p5() @@ -1949,7 +1952,8 @@ impl ActiveThread { h_flex() .child( IconButton::new(("feedback-thumbs-up", ix), IconName::ThumbsUp) - .icon_size(IconSize::XSmall) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) .icon_color(match feedback { ThreadFeedback::Positive => Color::Accent, ThreadFeedback::Negative => Color::Ignored, @@ -1966,7 +1970,8 @@ impl ActiveThread { ) .child( IconButton::new(("feedback-thumbs-down", ix), IconName::ThumbsDown) - .icon_size(IconSize::XSmall) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) .icon_color(match feedback { ThreadFeedback::Positive => Color::Ignored, ThreadFeedback::Negative => Color::Accent, @@ -1999,7 +2004,8 @@ impl ActiveThread { h_flex() .child( IconButton::new(("feedback-thumbs-up", ix), IconName::ThumbsUp) - .icon_size(IconSize::XSmall) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) .icon_color(Color::Ignored) .tooltip(Tooltip::text("Helpful Response")) .on_click(cx.listener(move |this, _, window, cx| { @@ -2013,7 +2019,8 @@ impl ActiveThread { ) .child( IconButton::new(("feedback-thumbs-down", ix), IconName::ThumbsDown) - .icon_size(IconSize::XSmall) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) .icon_color(Color::Ignored) .tooltip(Tooltip::text("Not Helpful")) .on_click(cx.listener(move |this, _, window, cx| { @@ -2750,7 +2757,7 @@ impl ActiveThread { h_flex() .gap_1p5() .child( - Icon::new(IconName::LightBulb) + Icon::new(IconName::ToolThink) .size(IconSize::XSmall) .color(Color::Muted), ) @@ -3362,7 +3369,7 @@ impl ActiveThread { .mr_0p5(), ) .child( - IconButton::new("open-prompt-library", IconName::ArrowUpRightAlt) + IconButton::new("open-prompt-library", IconName::ArrowUpRight) .shape(ui::IconButtonShape::Square) .icon_size(IconSize::XSmall) .icon_color(Color::Ignored) @@ -3397,7 +3404,7 @@ impl ActiveThread { .mr_0p5(), ) .child( - IconButton::new("open-rule", IconName::ArrowUpRightAlt) + IconButton::new("open-rule", IconName::ArrowUpRight) .shape(ui::IconButtonShape::Square) .icon_size(IconSize::XSmall) .icon_color(Color::Ignored) diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 02c15b7e41..5f72fa58c8 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -573,7 +573,7 @@ impl AgentConfiguration { .style(ButtonStyle::Filled) .layer(ElevationIndex::ModalSurface) .full_width() - .icon(IconName::Hammer) + .icon(IconName::ToolHammer) .icon_size(IconSize::Small) .icon_position(IconPosition::Start) .on_click(|_event, window, cx| { diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs index 06d035d836..32360dd56e 100644 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs +++ b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs @@ -438,7 +438,7 @@ impl ConfigureContextServerModal { format!("{} configured successfully.", id.0), cx, |this, _cx| { - this.icon(ToastIcon::new(IconName::Hammer).color(Color::Muted)) + this.icon(ToastIcon::new(IconName::ToolHammer).color(Color::Muted)) .action("Dismiss", |_, _| {}) }, ); @@ -567,7 +567,7 @@ impl ConfigureContextServerModal { Button::new("open-repository", "Open Repository") .icon(IconName::ArrowUpRight) .icon_color(Color::Muted) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .tooltip({ let repository_url = repository_url.clone(); move |window, cx| { diff --git a/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs b/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs index 5d44bb2d92..09ad013d1c 100644 --- a/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs +++ b/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs @@ -594,7 +594,7 @@ impl ManageProfilesModal { .inset(true) .spacing(ListItemSpacing::Sparse) .start_slot( - Icon::new(IconName::Hammer) + Icon::new(IconName::ToolHammer) .size(IconSize::Small) .color(Color::Muted), ) @@ -763,7 +763,7 @@ impl Render for ManageProfilesModal { .pb_1() .child(ProfileModalHeader::new( format!("{profile_name} — Configure MCP Tools"), - Some(IconName::Hammer), + Some(IconName::ToolHammer), )) .child(ListSeparator) .child(tool_picker.clone()) diff --git a/crates/agent_ui/src/context_picker.rs b/crates/agent_ui/src/context_picker.rs index 32f9a096d9..58f11313e6 100644 --- a/crates/agent_ui/src/context_picker.rs +++ b/crates/agent_ui/src/context_picker.rs @@ -102,7 +102,7 @@ impl ContextPickerAction { pub fn icon(&self) -> IconName { match self { - Self::AddSelections => IconName::Context, + Self::AddSelections => IconName::Reader, } } } @@ -147,7 +147,7 @@ impl ContextPickerMode { match self { Self::File => IconName::File, Self::Symbol => IconName::Code, - Self::Fetch => IconName::Globe, + Self::Fetch => IconName::ToolWeb, Self::Thread => IconName::Thread, Self::Rules => RULES_ICON, } diff --git a/crates/agent_ui/src/context_picker/completion_provider.rs b/crates/agent_ui/src/context_picker/completion_provider.rs index 5ca0913be7..8123b3437d 100644 --- a/crates/agent_ui/src/context_picker/completion_provider.rs +++ b/crates/agent_ui/src/context_picker/completion_provider.rs @@ -371,7 +371,7 @@ impl ContextPickerCompletionProvider { line_range.end.row + 1 ) .into(), - IconName::Context.path().into(), + IconName::Reader.path().into(), range, editor.downgrade(), ); @@ -539,10 +539,10 @@ impl ContextPickerCompletionProvider { label: CodeLabel::plain(url_to_fetch.to_string(), None), documentation: None, source: project::CompletionSource::Custom, - icon_path: Some(IconName::Globe.path().into()), + icon_path: Some(IconName::ToolWeb.path().into()), insert_text_mode: None, confirm: Some(confirm_completion_callback( - IconName::Globe.path().into(), + IconName::ToolWeb.path().into(), url_to_fetch.clone(), excerpt_id, source_range.start, diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index a5f90edb57..e6fca16984 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -541,7 +541,7 @@ impl PromptEditor { match &self.mode { PromptEditorMode::Terminal { .. } => vec![ accept, - IconButton::new("confirm", IconName::PlayOutlined) + IconButton::new("confirm", IconName::PlayFilled) .icon_color(Color::Info) .shape(IconButtonShape::Square) .tooltip(|window, cx| { diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 2185885347..4b6d51c4c1 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -725,7 +725,7 @@ impl MessageEditor { .when(focus_handle.is_focused(window), |this| { this.child( IconButton::new("toggle-height", expand_icon) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .icon_color(Color::Muted) .tooltip({ let focus_handle = focus_handle.clone(); @@ -831,7 +831,7 @@ impl MessageEditor { parent.child( IconButton::new( "stop-generation", - IconName::StopFilled, + IconName::Stop, ) .icon_color(Color::Error) .style(ButtonStyle::Tinted( @@ -1305,7 +1305,7 @@ impl MessageEditor { cx: &mut Context, ) -> Option
{ let icon = if token_usage_ratio == TokenUsageRatio::Exceeded { - Icon::new(IconName::X) + Icon::new(IconName::Close) .color(Color::Error) .size(IconSize::XSmall) } else { diff --git a/crates/agent_ui/src/slash_command_picker.rs b/crates/agent_ui/src/slash_command_picker.rs index a757a2f50a..678562e059 100644 --- a/crates/agent_ui/src/slash_command_picker.rs +++ b/crates/agent_ui/src/slash_command_picker.rs @@ -306,7 +306,7 @@ where ) .child( Icon::new(IconName::ArrowUpRight) - .size(IconSize::XSmall) + .size(IconSize::Small) .color(Color::Muted), ), ) diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs index 4836a95c8e..49a37002f7 100644 --- a/crates/agent_ui/src/text_thread_editor.rs +++ b/crates/agent_ui/src/text_thread_editor.rs @@ -2233,7 +2233,7 @@ fn render_thought_process_fold_icon_button( let button = match status { ThoughtProcessStatus::Pending => button .child( - Icon::new(IconName::LightBulb) + Icon::new(IconName::ToolThink) .size(IconSize::Small) .color(Color::Muted), ) @@ -2248,7 +2248,7 @@ fn render_thought_process_fold_icon_button( ), ThoughtProcessStatus::Completed => button .style(ButtonStyle::Filled) - .child(Icon::new(IconName::LightBulb).size(IconSize::Small)) + .child(Icon::new(IconName::ToolThink).size(IconSize::Small)) .child(Label::new("Thought Process").single_line()), }; diff --git a/crates/agent_ui/src/ui/onboarding_modal.rs b/crates/agent_ui/src/ui/onboarding_modal.rs index 9e04171ec9..b8b038bdfc 100644 --- a/crates/agent_ui/src/ui/onboarding_modal.rs +++ b/crates/agent_ui/src/ui/onboarding_modal.rs @@ -139,7 +139,7 @@ impl Render for AgentOnboardingModal { .child(Headline::new("Agentic Editing in Zed").size(HeadlineSize::Large)), ) .child(h_flex().absolute().top_2().right_2().child( - IconButton::new("cancel", IconName::X).on_click(cx.listener( + IconButton::new("cancel", IconName::Close).on_click(cx.listener( |_, _: &ClickEvent, _window, cx| { agent_onboarding_event!("Cancelled", trigger = "X click"); cx.emit(DismissEvent); diff --git a/crates/agent_ui/src/ui/preview/usage_callouts.rs b/crates/agent_ui/src/ui/preview/usage_callouts.rs index 64869a6ec7..eef878a9d1 100644 --- a/crates/agent_ui/src/ui/preview/usage_callouts.rs +++ b/crates/agent_ui/src/ui/preview/usage_callouts.rs @@ -81,7 +81,7 @@ impl RenderOnce for UsageCallout { }; let icon = if is_limit_reached { - Icon::new(IconName::X) + Icon::new(IconName::Close) .color(Color::Error) .size(IconSize::XSmall) } else { diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs index b9a1e49a4a..75177d4bd2 100644 --- a/crates/ai_onboarding/src/ai_onboarding.rs +++ b/crates/ai_onboarding/src/ai_onboarding.rs @@ -110,7 +110,7 @@ impl ZedAiOnboarding { .style(ButtonStyle::Outlined) .icon(IconName::ArrowUpRight) .icon_color(Color::Muted) - .icon_size(IconSize::XSmall) + .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)) diff --git a/crates/assistant_slash_commands/src/fetch_command.rs b/crates/assistant_slash_commands/src/fetch_command.rs index 5e586d4f23..4e0bb3d05a 100644 --- a/crates/assistant_slash_commands/src/fetch_command.rs +++ b/crates/assistant_slash_commands/src/fetch_command.rs @@ -112,7 +112,7 @@ impl SlashCommand for FetchSlashCommand { } fn icon(&self) -> IconName { - IconName::Globe + IconName::ToolWeb } fn menu_text(&self) -> String { @@ -171,7 +171,7 @@ impl SlashCommand for FetchSlashCommand { text, sections: vec![SlashCommandOutputSection { range, - icon: IconName::Globe, + icon: IconName::ToolWeb, label: format!("fetch {}", url).into(), metadata: None, }], diff --git a/crates/assistant_tools/src/edit_file_tool.rs b/crates/assistant_tools/src/edit_file_tool.rs index dce9f49abd..54431ee1d7 100644 --- a/crates/assistant_tools/src/edit_file_tool.rs +++ b/crates/assistant_tools/src/edit_file_tool.rs @@ -857,7 +857,7 @@ impl ToolCard for EditFileToolCard { ) .child( Icon::new(IconName::ArrowUpRight) - .size(IconSize::XSmall) + .size(IconSize::Small) .color(Color::Ignored), ), ) diff --git a/crates/assistant_tools/src/find_path_tool.rs b/crates/assistant_tools/src/find_path_tool.rs index affc019417..6cdf58eac8 100644 --- a/crates/assistant_tools/src/find_path_tool.rs +++ b/crates/assistant_tools/src/find_path_tool.rs @@ -257,7 +257,7 @@ impl ToolCard for FindPathToolCard { Button::new(("path", index), button_label) .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .icon_position(IconPosition::End) .label_size(LabelSize::Small) .color(Color::Muted) diff --git a/crates/assistant_tools/src/web_search_tool.rs b/crates/assistant_tools/src/web_search_tool.rs index d4a12f22c5..c6c37de472 100644 --- a/crates/assistant_tools/src/web_search_tool.rs +++ b/crates/assistant_tools/src/web_search_tool.rs @@ -45,7 +45,7 @@ impl Tool for WebSearchTool { } fn icon(&self) -> IconName { - IconName::Globe + IconName::ToolWeb } fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { @@ -177,7 +177,7 @@ impl ToolCard for WebSearchToolCard { .label_size(LabelSize::Small) .color(Color::Muted) .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .icon_position(IconPosition::End) .truncate(true) .tooltip({ diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 51e4ff8965..430b447580 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -2931,7 +2931,7 @@ impl CollabPanel { .visible_on_hover(""), ) .child( - IconButton::new("channel_notes", IconName::FileText) + IconButton::new("channel_notes", IconName::Reader) .style(ButtonStyle::Filled) .shape(ui::IconButtonShape::Square) .icon_size(IconSize::Small) diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index 0ac419580b..91382c74ae 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -649,7 +649,7 @@ impl DebugPanel { .tooltip(Tooltip::text("Open Documentation")) }; let logs_button = || { - IconButton::new("debug-open-logs", IconName::ScrollText) + IconButton::new("debug-open-logs", IconName::Notepad) .icon_size(IconSize::Small) .on_click(move |_, window, cx| { window.dispatch_action(debugger_tools::OpenDebugAdapterLogs.boxed_clone(), cx) @@ -788,7 +788,7 @@ impl DebugPanel { ) .child( IconButton::new("debug-step-out", IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .shape(ui::IconButtonShape::Square) .on_click(window.listener_for( &running_state, @@ -812,7 +812,7 @@ impl DebugPanel { ) .child(Divider::vertical()) .child( - IconButton::new("debug-restart", IconName::DebugRestart) + IconButton::new("debug-restart", IconName::RotateCcw) .icon_size(IconSize::XSmall) .on_click(window.listener_for( &running_state, diff --git a/crates/debugger_ui/src/onboarding_modal.rs b/crates/debugger_ui/src/onboarding_modal.rs index c9fa009940..2a9f68d0c9 100644 --- a/crates/debugger_ui/src/onboarding_modal.rs +++ b/crates/debugger_ui/src/onboarding_modal.rs @@ -131,7 +131,7 @@ impl Render for DebuggerOnboardingModal { .child(Headline::new("Zed's Debugger").size(HeadlineSize::Large)), ) .child(h_flex().absolute().top_2().right_2().child( - IconButton::new("cancel", IconName::X).on_click(cx.listener( + IconButton::new("cancel", IconName::Close).on_click(cx.listener( |_, _: &ClickEvent, _window, cx| { debugger_onboarding_event!("Cancelled", trigger = "X click"); cx.emit(DismissEvent); diff --git a/crates/debugger_ui/src/session/running/breakpoint_list.rs b/crates/debugger_ui/src/session/running/breakpoint_list.rs index a6defbbf35..326fb84e20 100644 --- a/crates/debugger_ui/src/session/running/breakpoint_list.rs +++ b/crates/debugger_ui/src/session/running/breakpoint_list.rs @@ -681,7 +681,7 @@ impl BreakpointList { }), ) .child( - IconButton::new("remove-breakpoint-breakpoint-list", IconName::X) + IconButton::new("remove-breakpoint-breakpoint-list", IconName::Close) .icon_size(IconSize::XSmall) .icon_color(ui::Color::Error) .when_some(remove_breakpoint_tooltip, |this, tooltip| { @@ -1439,7 +1439,7 @@ impl RenderOnce for BreakpointOptionsStrip { .child( IconButton::new( SharedString::from(format!("{id}-log-toggle")), - IconName::ScrollText, + IconName::Notepad, ) .icon_size(IconSize::XSmall) .style(style_for_toggle(ActiveBreakpointStripMode::Log, has_logs)) diff --git a/crates/debugger_ui/src/session/running/console.rs b/crates/debugger_ui/src/session/running/console.rs index 1385bec54e..daf4486f81 100644 --- a/crates/debugger_ui/src/session/running/console.rs +++ b/crates/debugger_ui/src/session/running/console.rs @@ -352,7 +352,7 @@ impl Console { .child( div() .px_1() - .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)), + .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)), ), ) .when( diff --git a/crates/debugger_ui/src/session/running/stack_frame_list.rs b/crates/debugger_ui/src/session/running/stack_frame_list.rs index 2149502f4a..8b44c231c3 100644 --- a/crates/debugger_ui/src/session/running/stack_frame_list.rs +++ b/crates/debugger_ui/src/session/running/stack_frame_list.rs @@ -493,7 +493,7 @@ impl StackFrameList { .child( IconButton::new( ("restart-stack-frame", stack_frame.id), - IconName::DebugRestart, + IconName::RotateCcw, ) .icon_size(IconSize::Small) .on_click(cx.listener({ diff --git a/crates/diagnostics/src/toolbar_controls.rs b/crates/diagnostics/src/toolbar_controls.rs index 9a7dcbe62f..e77b80115f 100644 --- a/crates/diagnostics/src/toolbar_controls.rs +++ b/crates/diagnostics/src/toolbar_controls.rs @@ -54,7 +54,7 @@ impl Render for ToolbarControls { .map(|div| { if is_updating { div.child( - IconButton::new("stop-updating", IconName::StopFilled) + IconButton::new("stop-updating", IconName::Stop) .icon_color(Color::Info) .shape(IconButtonShape::Square) .tooltip(Tooltip::for_action_title( @@ -73,7 +73,7 @@ impl Render for ToolbarControls { ) } else { div.child( - IconButton::new("refresh-diagnostics", IconName::Update) + IconButton::new("refresh-diagnostics", IconName::ArrowCircle) .icon_color(Color::Info) .shape(IconButtonShape::Square) .disabled(!has_stale_excerpts && !fetch_cargo_diagnostics) diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index ca635a2132..231aaa1d00 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -1825,7 +1825,7 @@ pub fn entry_diagnostic_aware_icon_name_and_color( diagnostic_severity: Option, ) -> Option<(IconName, Color)> { match diagnostic_severity { - Some(DiagnosticSeverity::ERROR) => Some((IconName::X, Color::Error)), + Some(DiagnosticSeverity::ERROR) => Some((IconName::Close, Color::Error)), Some(DiagnosticSeverity::WARNING) => Some((IconName::Triangle, Color::Warning)), _ => None, } diff --git a/crates/extensions_ui/src/components/feature_upsell.rs b/crates/extensions_ui/src/components/feature_upsell.rs index e2e65f1598..573b0b992d 100644 --- a/crates/extensions_ui/src/components/feature_upsell.rs +++ b/crates/extensions_ui/src/components/feature_upsell.rs @@ -58,7 +58,7 @@ impl RenderOnce for FeatureUpsell { el.child( Button::new("open_docs", "View Documentation") .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .icon_position(IconPosition::End) .on_click({ let docs_url = docs_url.clone(); diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index b74fa649b0..6bb84db834 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -473,7 +473,7 @@ impl PickerDelegate for BranchListDelegate { && entry.is_new { Some( - IconButton::new("branch-from-default", IconName::GitBranchSmall) + IconButton::new("branch-from-default", IconName::GitBranchAlt) .on_click(cx.listener(move |this, _, window, cx| { this.delegate.set_selected_index(ix, window, cx); this.delegate.confirm(true, window, cx); diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs index 5dfa800ae5..5e7430ebc6 100644 --- a/crates/git_ui/src/commit_modal.rs +++ b/crates/git_ui/src/commit_modal.rs @@ -272,7 +272,7 @@ impl CommitModal { .child( div() .px_1() - .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)), + .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)), ), ) .menu({ diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 44222b8299..e4f445858d 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -2900,10 +2900,10 @@ impl GitPanel { use remote_output::SuccessStyle::*; match style { Toast { .. } => { - this.icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted)) + this.icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted)) } ToastWithLog { output } => this - .icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted)) + .icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted)) .action("View Log", move |window, cx| { let output = output.clone(); let output = @@ -2915,7 +2915,7 @@ impl GitPanel { .ok(); }), PushPrLink { text, link } => this - .icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted)) + .icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted)) .action(text, move |_, cx| cx.open_url(&link)), } }); @@ -3109,7 +3109,7 @@ impl GitPanel { .justify_center() .border_l_1() .border_color(cx.theme().colors().border) - .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)), + .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)), ), ) .menu({ @@ -4561,7 +4561,7 @@ impl Panel for GitPanel { } fn icon(&self, _: &Window, cx: &App) -> Option { - Some(ui::IconName::GitBranchSmall).filter(|_| GitPanelSettings::get_global(cx).button) + Some(ui::IconName::GitBranchAlt).filter(|_| GitPanelSettings::get_global(cx).button) } fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> { @@ -4808,7 +4808,7 @@ impl RenderOnce for PanelRepoFooter { .items_center() .child( div().child( - Icon::new(IconName::GitBranchSmall) + Icon::new(IconName::GitBranchAlt) .size(IconSize::Small) .color(if single_repo { Color::Disabled diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 0163175eda..bde867bcd2 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -356,7 +356,7 @@ mod remote_button { "Publish", 0, 0, - Some(IconName::ArrowUpFromLine), + Some(IconName::ExpandUp), keybinding_target.clone(), move |_, window, cx| { window.dispatch_action(Box::new(git::Push), cx); @@ -383,7 +383,7 @@ mod remote_button { "Republish", 0, 0, - Some(IconName::ArrowUpFromLine), + Some(IconName::ExpandUp), keybinding_target.clone(), move |_, window, cx| { window.dispatch_action(Box::new(git::Push), cx); @@ -438,7 +438,7 @@ mod remote_button { .child( div() .px_1() - .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)), + .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)), ), ) .menu(move |window, cx| { diff --git a/crates/git_ui/src/onboarding.rs b/crates/git_ui/src/onboarding.rs index d721b21a2a..d1709e043b 100644 --- a/crates/git_ui/src/onboarding.rs +++ b/crates/git_ui/src/onboarding.rs @@ -110,7 +110,7 @@ impl Render for GitOnboardingModal { .child(Headline::new("Native Git Support").size(HeadlineSize::Large)), ) .child(h_flex().absolute().top_2().right_2().child( - IconButton::new("cancel", IconName::X).on_click(cx.listener( + IconButton::new("cancel", IconName::Close).on_click(cx.listener( |_, _: &ClickEvent, _window, cx| { git_onboarding_event!("Cancelled", trigger = "X click"); cx.emit(DismissEvent); diff --git a/crates/icons/README.md b/crates/icons/README.md new file mode 100644 index 0000000000..5fbd6d4948 --- /dev/null +++ b/crates/icons/README.md @@ -0,0 +1,29 @@ +# Zed Icons + +## Guidelines + +Icons are a big part of Zed, and they're how we convey hundreds of actions without relying on labeled buttons. +When introducing a new icon to the set, it's important to ensure it is consistent with the whole set, which follows a few guidelines: + +1. The SVG view box should be 16x16. +2. For outlined icons, use a 1.5px stroke width. +3. Not all icons are mathematically aligned; there's quite a bit of optical adjustment. But try to keep the icon within an internal 12x12 bounding box as much as possible while ensuring proper visibility. +4. Use the `filled` and `outlined` terminology when introducing icons that will have the two variants. +5. Icons that are deeply contextual may have the feature context as their name prefix. For example, `ToolWeb`, `ReplPlay`, `DebugStepInto`, etc. +6. Avoid complex layer structure in the icon SVG, like clipping masks and whatnot. When the shape ends up too complex, we recommend running the SVG in [SVGOMG](https://jakearchibald.github.io/svgomg/) to clean it up a bit. + +## Sourcing + +Most icons are created by sourcing them from [Lucide](https://lucide.dev/). +Then, they're modified, adjusted, cleaned up, and simplified depending on their use and overall fit with Zed. + +Sometimes, we may use other sources like [Phosphor](https://phosphoricons.com/), but we also design many of them completely from scratch. + +## Contributing + +To introduce a new icon, add the `.svg` file in the `assets/icon` directory and then add its corresponding item in the `icons.rs` file within the `crates` directory. + +- SVG files in the assets folder follow a snake case name format. +- Icons in the `icons.rs` file follow the pascal case name format. + +Ensure you tag a member of Zed's design team so we can adjust and double-check any newly introduced icon. diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 12805e62e0..f5c2a83fec 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -28,16 +28,12 @@ pub enum IconName { ArrowCircle, ArrowDown, ArrowDown10, - ArrowDownFromLine, ArrowDownRight, ArrowLeft, ArrowRight, ArrowRightLeft, ArrowUp, - ArrowUpAlt, - ArrowUpFromLine, ArrowUpRight, - ArrowUpRightAlt, AudioOff, AudioOn, Backspace, @@ -51,28 +47,22 @@ pub enum IconName { BoltFilled, Book, BookCopy, - BugOff, CaseSensitive, Chat, Check, CheckDouble, ChevronDown, - /// This chevron indicates a popover menu. - ChevronDownSmall, ChevronLeft, ChevronRight, ChevronUp, ChevronUpDown, Circle, - CircleOff, CircleHelp, Close, - Cloud, CloudDownload, Code, Cog, Command, - Context, Control, Copilot, CopilotDisabled, @@ -93,16 +83,12 @@ pub enum IconName { DebugIgnoreBreakpoints, DebugLogBreakpoint, DebugPause, - DebugRestart, DebugStepBack, DebugStepInto, DebugStepOut, DebugStepOver, - DebugStop, - Delete, Diff, Disconnected, - DocumentText, Download, EditorAtom, EditorCursor, @@ -113,59 +99,50 @@ pub enum IconName { Ellipsis, EllipsisVertical, Envelope, - Equal, Eraser, Escape, Exit, ExpandDown, ExpandUp, ExpandVertical, - ExternalLink, Eye, File, FileCode, - FileCreate, FileDiff, FileDoc, FileGeneric, FileGit, FileLock, + FileMarkdown, FileRust, - FileSearch, - FileText, + FileTextFilled, + FileTextOutlined, FileToml, FileTree, Filter, Flame, Folder, FolderOpen, - FolderX, + FolderSearch, Font, FontSize, FontWeight, ForwardArrow, - Function, GenericClose, GenericMaximize, GenericMinimize, GenericRestore, GitBranch, - GitBranchSmall, + GitBranchAlt, Github, - Globe, - Hammer, Hash, HistoryRerun, Image, Indicator, Info, - InlayHint, Keyboard, - Layout, Library, - LightBulb, LineHeight, - Link, ListCollapse, ListTodo, ListTree, @@ -173,35 +150,28 @@ pub enum IconName { LoadCircle, LocationEdit, LockOutlined, - LspDebug, - LspRestart, - LspStop, MagnifyingGlass, - MailOpen, Maximize, Menu, MenuAlt, Mic, MicMute, Minimize, + Notepad, Option, PageDown, PageUp, - PanelLeft, - PanelRight, Pencil, Person, - PersonCircle, - PhoneIncoming, Pin, PlayOutlined, PlayFilled, Plus, - PocketKnife, Power, Public, PullRequest, Quote, + Reader, RefreshTitle, Regex, ReplNeutral, @@ -213,28 +183,18 @@ pub enum IconName { Return, RotateCcw, RotateCw, - Route, - Save, Scissors, Screen, - ScrollText, - SearchSelection, SelectAll, Send, Server, Settings, - SettingsAlt, ShieldCheck, Shift, Slash, - SlashSquare, Sliders, - SlidersVertical, - Snip, Space, Sparkle, - SparkleAlt, - SparkleFilled, Split, SplitAlt, SquareDot, @@ -243,7 +203,6 @@ pub enum IconName { Star, StarFilled, Stop, - StopFilled, Supermaven, SupermavenDisabled, SupermavenError, @@ -279,18 +238,13 @@ pub enum IconName { TriangleRight, Undo, Unpin, - Update, UserCheck, UserGroup, UserRoundPen, - Visible, - Wand, Warning, WholeWord, - X, XCircle, ZedAssistant, - ZedAssistantFilled, ZedBurnMode, ZedBurnModeOn, ZedMcpCustom, diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 40dd120761..ba110be9c5 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -437,7 +437,7 @@ fn render_accept_terms( .style(ButtonStyle::Subtle) .icon(IconName::ArrowUpRight) .icon_color(Color::Muted) - .icon_size(IconSize::XSmall) + .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")); diff --git a/crates/language_models/src/provider/lmstudio.rs b/crates/language_models/src/provider/lmstudio.rs index 9792b4f27b..36a32ab941 100644 --- a/crates/language_models/src/provider/lmstudio.rs +++ b/crates/language_models/src/provider/lmstudio.rs @@ -690,7 +690,7 @@ impl Render for ConfigurationView { Button::new("lmstudio-site", "LM Studio") .style(ButtonStyle::Subtle) .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .icon_color(Color::Muted) .on_click(move |_, _window, cx| { cx.open_url(LMSTUDIO_SITE) @@ -705,7 +705,7 @@ impl Render for ConfigurationView { ) .style(ButtonStyle::Subtle) .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .icon_color(Color::Muted) .on_click(move |_, _window, cx| { cx.open_url(LMSTUDIO_DOWNLOAD_URL) @@ -718,7 +718,7 @@ impl Render for ConfigurationView { Button::new("view-models", "Model Catalog") .style(ButtonStyle::Subtle) .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .icon_color(Color::Muted) .on_click(move |_, _window, cx| { cx.open_url(LMSTUDIO_CATALOG_URL) @@ -744,7 +744,7 @@ impl Render for ConfigurationView { Button::new("retry_lmstudio_models", "Connect") .icon_position(IconPosition::Start) .icon_size(IconSize::XSmall) - .icon(IconName::PlayOutlined) + .icon(IconName::PlayFilled) .on_click(cx.listener(move |this, _, _window, cx| { this.retry_connection(cx) })), diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs index c845c97b09..0c2b1107b1 100644 --- a/crates/language_models/src/provider/ollama.rs +++ b/crates/language_models/src/provider/ollama.rs @@ -608,7 +608,7 @@ impl Render for ConfigurationView { Button::new("ollama-site", "Ollama") .style(ButtonStyle::Subtle) .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .icon_color(Color::Muted) .on_click(move |_, _, cx| cx.open_url(OLLAMA_SITE)) .into_any_element(), @@ -621,7 +621,7 @@ impl Render for ConfigurationView { ) .style(ButtonStyle::Subtle) .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .icon_color(Color::Muted) .on_click(move |_, _, cx| { cx.open_url(OLLAMA_DOWNLOAD_URL) @@ -634,7 +634,7 @@ impl Render for ConfigurationView { Button::new("view-models", "View All Models") .style(ButtonStyle::Subtle) .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .icon_color(Color::Muted) .on_click(move |_, _, cx| cx.open_url(OLLAMA_LIBRARY_URL)), ), @@ -658,7 +658,7 @@ impl Render for ConfigurationView { Button::new("retry_ollama_models", "Connect") .icon_position(IconPosition::Start) .icon_size(IconSize::XSmall) - .icon(IconName::PlayOutlined) + .icon(IconName::PlayFilled) .on_click(cx.listener(move |this, _, _, cx| { this.retry_connection(cx) })), diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index ee74562687..7a6c8e09ed 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -869,7 +869,7 @@ impl Render for ConfigurationView { .child( Button::new("docs", "Learn More") .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .icon_color(Color::Muted) .on_click(move |_, _window, cx| { cx.open_url("https://zed.dev/docs/ai/llm-providers#openai-api-compatible") diff --git a/crates/language_models/src/ui/instruction_list_item.rs b/crates/language_models/src/ui/instruction_list_item.rs index 794a85b400..3dee97aff6 100644 --- a/crates/language_models/src/ui/instruction_list_item.rs +++ b/crates/language_models/src/ui/instruction_list_item.rs @@ -47,7 +47,7 @@ impl IntoElement for InstructionListItem { Button::new(unique_id, button_label) .style(ButtonStyle::Subtle) .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .icon_color(Color::Muted) .on_click(move |_, _window, cx| cx.open_url(&link)), ) diff --git a/crates/notifications/src/status_toast.rs b/crates/notifications/src/status_toast.rs index ffd87e0b8b..7affa93f5a 100644 --- a/crates/notifications/src/status_toast.rs +++ b/crates/notifications/src/status_toast.rs @@ -205,7 +205,7 @@ impl Component for StatusToast { let pr_example = StatusToast::new("`zed/new-notification-system` created!", cx, |this, _cx| { - this.icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted)) + this.icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted)) .action("Open Pull Request", |_, cx| { cx.open_url("https://github.com/") }) diff --git a/crates/onboarding/src/ai_setup_page.rs b/crates/onboarding/src/ai_setup_page.rs index 00f2d5fc8b..0397bcbd9b 100644 --- a/crates/onboarding/src/ai_setup_page.rs +++ b/crates/onboarding/src/ai_setup_page.rs @@ -95,7 +95,7 @@ fn render_privacy_card(tab_index: &mut isize, disabled: bool, cx: &mut App) -> i .style(ButtonStyle::Outlined) .label_size(LabelSize::Small) .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .icon_color(Color::Muted) .on_click(|_, _, cx| { cx.open_url("https://zed.dev/docs/ai/privacy-and-security"); diff --git a/crates/onboarding/src/onboarding.rs b/crates/onboarding/src/onboarding.rs index 342b52bdda..145cb07a1c 100644 --- a/crates/onboarding/src/onboarding.rs +++ b/crates/onboarding/src/onboarding.rs @@ -694,7 +694,7 @@ pub async fn handle_import_vscode_settings( "Failed to import settings. See log for details", cx, |this, _| { - this.icon(ToastIcon::new(IconName::X).color(Color::Error)) + this.icon(ToastIcon::new(IconName::Close).color(Color::Error)) .action("Open Log", |window, cx| { window.dispatch_action(workspace::OpenLog.boxed_clone(), cx) }) diff --git a/crates/recent_projects/src/ssh_connections.rs b/crates/recent_projects/src/ssh_connections.rs index 5a38e1aadb..7b58792178 100644 --- a/crates/recent_projects/src/ssh_connections.rs +++ b/crates/recent_projects/src/ssh_connections.rs @@ -357,7 +357,7 @@ impl RenderOnce for SshConnectionHeader { .rounded_t_sm() .w_full() .gap_1p5() - .child(Icon::new(IconName::Server).size(IconSize::XSmall)) + .child(Icon::new(IconName::Server).size(IconSize::Small)) .child( h_flex() .gap_1() diff --git a/crates/repl/src/components/kernel_options.rs b/crates/repl/src/components/kernel_options.rs index 0623fd7ea5..cd73783b4c 100644 --- a/crates/repl/src/components/kernel_options.rs +++ b/crates/repl/src/components/kernel_options.rs @@ -235,8 +235,8 @@ impl PickerDelegate for KernelPickerDelegate { .gap_4() .child( Button::new("kernel-docs", "Kernel Docs") - .icon(IconName::ExternalLink) - .icon_size(IconSize::XSmall) + .icon(IconName::ArrowUpRight) + .icon_size(IconSize::Small) .icon_color(Color::Muted) .icon_position(IconPosition::End) .on_click(move |_, _, cx| cx.open_url(KERNEL_DOCS_URL)), diff --git a/crates/repl/src/notebook/cell.rs b/crates/repl/src/notebook/cell.rs index 18851417c0..15179a632c 100644 --- a/crates/repl/src/notebook/cell.rs +++ b/crates/repl/src/notebook/cell.rs @@ -38,7 +38,7 @@ pub enum CellControlType { impl CellControlType { fn icon_name(&self) -> IconName { match self { - CellControlType::RunCell => IconName::PlayOutlined, + CellControlType::RunCell => IconName::PlayFilled, CellControlType::RerunCell => IconName::ArrowCircle, CellControlType::ClearCell => IconName::ListX, CellControlType::CellOptions => IconName::Ellipsis, diff --git a/crates/repl/src/notebook/notebook_ui.rs b/crates/repl/src/notebook/notebook_ui.rs index 2efa51e0cc..b53809dff0 100644 --- a/crates/repl/src/notebook/notebook_ui.rs +++ b/crates/repl/src/notebook/notebook_ui.rs @@ -321,7 +321,7 @@ impl NotebookEditor { .child( Self::render_notebook_control( "run-all-cells", - IconName::PlayOutlined, + IconName::PlayFilled, window, cx, ) diff --git a/crates/repl/src/outputs.rs b/crates/repl/src/outputs.rs index e13e569c2a..ed252b239f 100644 --- a/crates/repl/src/outputs.rs +++ b/crates/repl/src/outputs.rs @@ -163,7 +163,7 @@ impl Output { el.child( IconButton::new( ElementId::Name("open-in-buffer".into()), - IconName::FileText, + IconName::FileTextOutlined, ) .style(ButtonStyle::Transparent) .tooltip(Tooltip::text("Open in Buffer")) diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 5d77a95027..14703be7a2 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -336,7 +336,7 @@ impl Render for BufferSearchBar { this.child( IconButton::new( "buffer-search-bar-toggle-search-selection-button", - IconName::SearchSelection, + IconName::Quote, ) .style(ButtonStyle::Subtle) .shape(IconButtonShape::Square) diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 15c1099aec..96194cdad2 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -2268,7 +2268,7 @@ impl Render for ProjectSearchBar { .min_w_64() .gap_1() .child( - IconButton::new("project-search-opened-only", IconName::FileSearch) + IconButton::new("project-search-opened-only", IconName::FolderSearch) .shape(IconButtonShape::Square) .toggle_state(self.is_opened_only_enabled(cx)) .tooltip(Tooltip::text("Only Search Open Files")) diff --git a/crates/settings_ui/src/keybindings.rs b/crates/settings_ui/src/keybindings.rs index 599bb0b18f..a62c669488 100644 --- a/crates/settings_ui/src/keybindings.rs +++ b/crates/settings_ui/src/keybindings.rs @@ -2473,7 +2473,7 @@ impl Render for KeybindingEditorModal { .label_size(LabelSize::Small) .icon(IconName::ArrowUpRight) .icon_color(Color::Muted) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .on_click(cx.listener( |this, _, window, cx| { this.show_matching_bindings( diff --git a/crates/settings_ui/src/ui_components/keystroke_input.rs b/crates/settings_ui/src/ui_components/keystroke_input.rs index ee5c4036ea..f23d80931c 100644 --- a/crates/settings_ui/src/ui_components/keystroke_input.rs +++ b/crates/settings_ui/src/ui_components/keystroke_input.rs @@ -590,7 +590,7 @@ impl Render for KeystrokeInput { .map(|this| { if is_recording { this.child( - IconButton::new("stop-record-btn", IconName::StopFilled) + IconButton::new("stop-record-btn", IconName::Stop) .shape(IconButtonShape::Square) .map(|this| { this.tooltip(Tooltip::for_action_title( @@ -629,7 +629,7 @@ impl Render for KeystrokeInput { } }) .child( - IconButton::new("clear-btn", IconName::Delete) + IconButton::new("clear-btn", IconName::Backspace) .shape(IconButtonShape::Square) .tooltip(Tooltip::for_action_title( "Clear Keystrokes", diff --git a/crates/snippets_ui/src/snippets_ui.rs b/crates/snippets_ui/src/snippets_ui.rs index a8710d1672..bf0ef63bff 100644 --- a/crates/snippets_ui/src/snippets_ui.rs +++ b/crates/snippets_ui/src/snippets_ui.rs @@ -330,7 +330,7 @@ impl PickerDelegate for ScopeSelectorDelegate { .and_then(|available_language| self.scope_icon(available_language.matcher(), cx)) .or_else(|| { Some( - Icon::from_path(IconName::Globe.path()) + Icon::from_path(IconName::ToolWeb.path()) .map(|icon| icon.color(Color::Muted)), ) }) diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 361cdd0b1c..0c05aec85e 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -1591,7 +1591,7 @@ impl Item for TerminalView { let (icon, icon_color, rerun_button) = match terminal.task() { Some(terminal_task) => match &terminal_task.status { TaskStatus::Running => ( - IconName::PlayOutlined, + IconName::PlayFilled, Color::Disabled, TerminalView::rerun_button(&terminal_task), ), diff --git a/crates/theme_selector/src/icon_theme_selector.rs b/crates/theme_selector/src/icon_theme_selector.rs index 2d0b9480d5..af7abdee62 100644 --- a/crates/theme_selector/src/icon_theme_selector.rs +++ b/crates/theme_selector/src/icon_theme_selector.rs @@ -318,7 +318,7 @@ impl PickerDelegate for IconThemeSelectorDelegate { Button::new("docs", "View Icon Theme Docs") .icon(IconName::ArrowUpRight) .icon_position(IconPosition::End) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .icon_color(Color::Muted) .on_click(|_event, _window, cx| { cx.open_url("https://zed.dev/docs/icon-themes"); diff --git a/crates/theme_selector/src/theme_selector.rs b/crates/theme_selector/src/theme_selector.rs index ba8bde243b..8c48f295dd 100644 --- a/crates/theme_selector/src/theme_selector.rs +++ b/crates/theme_selector/src/theme_selector.rs @@ -376,7 +376,7 @@ impl PickerDelegate for ThemeSelectorDelegate { Button::new("docs", "View Theme Docs") .icon(IconName::ArrowUpRight) .icon_position(IconPosition::End) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .icon_color(Color::Muted) .on_click(cx.listener(|_, _, _, cx| { cx.open_url("https://zed.dev/docs/themes"); diff --git a/crates/title_bar/src/collab.rs b/crates/title_bar/src/collab.rs index d026b4de14..74d60a6d66 100644 --- a/crates/title_bar/src/collab.rs +++ b/crates/title_bar/src/collab.rs @@ -518,7 +518,7 @@ impl TitleBar { .mx_neg_0p5() .h_full() .justify_center() - .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)), + .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)), ) .toggle_state(self.screen_share_popover_handle.is_deployed()), ) diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index a8b16d881f..d11d3b7081 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -344,7 +344,7 @@ impl TitleBar { .child( IconWithIndicator::new( Icon::new(IconName::Server) - .size(IconSize::XSmall) + .size(IconSize::Small) .color(icon_color), Some(Indicator::dot().color(indicator_color)), ) diff --git a/crates/ui/src/components/banner.rs b/crates/ui/src/components/banner.rs index d88905d466..d493e8a0d3 100644 --- a/crates/ui/src/components/banner.rs +++ b/crates/ui/src/components/banner.rs @@ -24,7 +24,7 @@ pub enum Severity { /// .action_slot( /// Button::new("learn-more", "Learn More") /// .icon(IconName::ArrowUpRight) -/// .icon_size(IconSize::XSmall) +/// .icon_size(IconSize::Small) /// .icon_position(IconPosition::End), /// ) /// ``` @@ -150,7 +150,7 @@ impl Component for Banner { .action_slot( Button::new("learn-more", "Learn More") .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .icon_position(IconPosition::End), ) .into_any_element(), diff --git a/crates/ui/src/components/callout.rs b/crates/ui/src/components/callout.rs index 9c1c9fb1a9..abb03198ab 100644 --- a/crates/ui/src/components/callout.rs +++ b/crates/ui/src/components/callout.rs @@ -207,7 +207,7 @@ impl Component for Callout { "Error with Multiple Actions", Callout::new() .icon( - Icon::new(IconName::X) + Icon::new(IconName::Close) .color(Color::Error) .size(IconSize::Small), ) diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index 21ab283d88..25575c4f1e 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::XSmall, + icon_size: IconSize::Small, icon_position: IconPosition::End, icon_color: None, disabled: false, diff --git a/crates/ui/src/components/indicator.rs b/crates/ui/src/components/indicator.rs index d319547bed..59d69a068b 100644 --- a/crates/ui/src/components/indicator.rs +++ b/crates/ui/src/components/indicator.rs @@ -164,7 +164,7 @@ impl Component for Indicator { ), single_example( "Error", - Indicator::icon(Icon::new(IconName::X)) + Indicator::icon(Icon::new(IconName::Close)) .color(Color::Error) .into_any_element(), ), diff --git a/crates/ui/src/components/keybinding.rs b/crates/ui/src/components/keybinding.rs index 5779093ccc..56be867796 100644 --- a/crates/ui/src/components/keybinding.rs +++ b/crates/ui/src/components/keybinding.rs @@ -188,7 +188,7 @@ fn icon_for_key(keystroke: &Keystroke, platform_style: PlatformStyle) -> Option< "up" => Some(IconName::ArrowUp), "down" => Some(IconName::ArrowDown), "backspace" => Some(IconName::Backspace), - "delete" => Some(IconName::Delete), + "delete" => Some(IconName::Backspace), "return" => Some(IconName::Return), "enter" => Some(IconName::Return), "tab" => Some(IconName::Tab), diff --git a/crates/ui/src/components/stories/icon_button.rs b/crates/ui/src/components/stories/icon_button.rs index ad6886252d..166297eabc 100644 --- a/crates/ui/src/components/stories/icon_button.rs +++ b/crates/ui/src/components/stories/icon_button.rs @@ -90,7 +90,7 @@ impl Render for IconButtonStory { let selected_with_tooltip_button = StoryItem::new( "Selected with `tooltip`", - IconButton::new("selected_with_tooltip_button", IconName::InlayHint) + IconButton::new("selected_with_tooltip_button", IconName::CaseSensitive) .toggle_state(true) .tooltip(Tooltip::text("Toggle inlay hints")), ) diff --git a/crates/welcome/src/multibuffer_hint.rs b/crates/welcome/src/multibuffer_hint.rs index ea64cab9df..3a20cbb6bd 100644 --- a/crates/welcome/src/multibuffer_hint.rs +++ b/crates/welcome/src/multibuffer_hint.rs @@ -159,7 +159,7 @@ impl Render for MultibufferHint { .child( Button::new("open_docs", "Learn More") .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .icon_color(Color::Muted) .icon_position(IconPosition::End) .on_click(move |_event, _, cx| { diff --git a/crates/zed/src/zed/component_preview.rs b/crates/zed/src/zed/component_preview.rs index db75b544f6..ac889a7ad9 100644 --- a/crates/zed/src/zed/component_preview.rs +++ b/crates/zed/src/zed/component_preview.rs @@ -697,7 +697,7 @@ impl ComponentPreview { workspace.update(cx, |workspace, cx| { let status_toast = StatusToast::new("`zed/new-notification-system` created!", cx, |this, _cx| { - this.icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted)) + this.icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted)) .action("Open Pull Request", |_, cx| { cx.open_url("https://github.com/") }) diff --git a/crates/zed/src/zed/quick_action_bar/repl_menu.rs b/crates/zed/src/zed/quick_action_bar/repl_menu.rs index 12e5cf1b76..5d1a6c8887 100644 --- a/crates/zed/src/zed/quick_action_bar/repl_menu.rs +++ b/crates/zed/src/zed/quick_action_bar/repl_menu.rs @@ -212,7 +212,7 @@ impl QuickActionBar { .trigger_with_tooltip( ButtonLike::new_rounded_right(element_id("dropdown")) .child( - Icon::new(IconName::ChevronDownSmall) + Icon::new(IconName::ChevronDown) .size(IconSize::XSmall) .color(Color::Muted), ) diff --git a/crates/zeta/src/onboarding_modal.rs b/crates/zeta/src/onboarding_modal.rs index 1d59f36b05..c2886f2864 100644 --- a/crates/zeta/src/onboarding_modal.rs +++ b/crates/zeta/src/onboarding_modal.rs @@ -141,7 +141,7 @@ impl Render for ZedPredictModal { )), ) .child(h_flex().absolute().top_2().right_2().child( - IconButton::new("cancel", IconName::X).on_click(cx.listener( + IconButton::new("cancel", IconName::Close).on_click(cx.listener( |_, _: &ClickEvent, _window, cx| { onboarding_event!("Cancelled", trigger = "X click"); cx.emit(DismissEvent); From f3a58b50c4e44a088831033e0e1e791785a53298 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Fri, 8 Aug 2025 15:03:50 -0400 Subject: [PATCH 014/109] Handle drag and drop in new agent threads (#35879) This is a bit simpler than for the original agent thread view, since we don't have to deal with opening buffers or a context store. Release Notes: - N/A --- .../agent_ui/src/acp/completion_provider.rs | 2 +- crates/agent_ui/src/acp/thread_view.rs | 57 ++++++++++++++++++- crates/agent_ui/src/agent_panel.rs | 6 +- 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/crates/agent_ui/src/acp/completion_provider.rs b/crates/agent_ui/src/acp/completion_provider.rs index fca4ae0300..d8f452afa5 100644 --- a/crates/agent_ui/src/acp/completion_provider.rs +++ b/crates/agent_ui/src/acp/completion_provider.rs @@ -59,7 +59,7 @@ impl ContextPickerCompletionProvider { } } - fn completion_for_path( + pub(crate) fn completion_for_path( project_path: ProjectPath, path_prefix: &str, is_recent: bool, diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 74bbac2b1c..6411abb84f 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -30,7 +30,7 @@ use language::language_settings::SoftWrap; use language::{Buffer, Language}; use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle}; use parking_lot::Mutex; -use project::Project; +use project::{CompletionIntent, Project}; use settings::{Settings as _, SettingsStore}; use text::{Anchor, BufferSnapshot}; use theme::ThemeSettings; @@ -2611,6 +2611,61 @@ impl AcpThreadView { }) } } + + pub(crate) fn insert_dragged_files( + &self, + paths: Vec, + _added_worktrees: Vec>, + window: &mut Window, + cx: &mut Context<'_, Self>, + ) { + let buffer = self.message_editor.read(cx).buffer().clone(); + let Some((&excerpt_id, _, _)) = buffer.read(cx).snapshot(cx).as_singleton() else { + return; + }; + let Some(buffer) = buffer.read(cx).as_singleton() else { + return; + }; + for path in paths { + let Some(entry) = self.project.read(cx).entry_for_path(&path, cx) else { + continue; + }; + let Some(abs_path) = self.project.read(cx).absolute_path(&path, cx) else { + continue; + }; + + let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len())); + let path_prefix = abs_path + .file_name() + .unwrap_or(path.path.as_os_str()) + .display() + .to_string(); + let completion = ContextPickerCompletionProvider::completion_for_path( + path, + &path_prefix, + false, + entry.is_dir(), + excerpt_id, + anchor..anchor, + self.message_editor.clone(), + self.mention_set.clone(), + cx, + ); + + self.message_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.clone() { + confirm(CompletionIntent::Complete, window, cx); + } + } + } } impl Focusable for AcpThreadView { diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 6b8e36066b..87e4dd822c 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -3187,8 +3187,10 @@ impl AgentPanel { .detach(); }); } - ActiveView::ExternalAgentThread { .. } => { - unimplemented!() + ActiveView::ExternalAgentThread { thread_view } => { + thread_view.update(cx, |thread_view, cx| { + thread_view.insert_dragged_files(paths, added_worktrees, window, cx); + }); } ActiveView::TextThread { context_editor, .. } => { context_editor.update(cx, |context_editor, cx| { From b77a15d53a881d1c08df15e3f881cc56c67d91d5 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Fri, 8 Aug 2025 15:20:10 -0400 Subject: [PATCH 015/109] ci: Use faster Linux ARM runners (#35880) Switch our Linux aarch_64 release builds from Linux on Graviton (32 vCPU, 64GB) to Linux running on Apple M4 Pro (8vCPU, 32GB). Builds are faster (20mins vs 30mins) for the same cost (960 unit minutes; ~$0.96/ea). Screenshot 2025-08-08 at 13 14 41 Release Notes: - N/A --- .github/actionlint.yml | 3 +++ .github/workflows/ci.yml | 16 +++++++--------- .github/workflows/release_nightly.yml | 2 +- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/actionlint.yml b/.github/actionlint.yml index ad09545902..0ee6af8a1d 100644 --- a/.github/actionlint.yml +++ b/.github/actionlint.yml @@ -24,6 +24,9 @@ self-hosted-runner: - namespace-profile-8x16-ubuntu-2204 - namespace-profile-16x32-ubuntu-2204 - namespace-profile-32x64-ubuntu-2204 + # Namespace Limited Preview + - namespace-profile-8x16-ubuntu-2004-arm-m4 + - namespace-profile-8x32-ubuntu-2004-arm-m4 # Self Hosted Runners - self-mini-macos - self-32vcpu-windows-2022 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84907351fe..02edc99824 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -511,8 +511,8 @@ jobs: runs-on: - self-mini-macos if: | - startsWith(github.ref, 'refs/tags/v') - || contains(github.event.pull_request.labels.*.name, 'run-bundling') + ( startsWith(github.ref, 'refs/tags/v') + || contains(github.event.pull_request.labels.*.name, 'run-bundling') ) needs: [macos_tests] env: MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} @@ -599,8 +599,8 @@ jobs: runs-on: - namespace-profile-16x32-ubuntu-2004 # ubuntu 20.04 for minimal glibc if: | - startsWith(github.ref, 'refs/tags/v') - || contains(github.event.pull_request.labels.*.name, 'run-bundling') + ( startsWith(github.ref, 'refs/tags/v') + || contains(github.event.pull_request.labels.*.name, 'run-bundling') ) needs: [linux_tests] steps: - name: Checkout repo @@ -650,7 +650,7 @@ jobs: timeout-minutes: 60 name: Linux arm64 release bundle runs-on: - - namespace-profile-32x64-ubuntu-2004-arm # ubuntu 20.04 for minimal glibc + - namespace-profile-8x32-ubuntu-2004-arm-m4 # ubuntu 20.04 for minimal glibc if: | startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling') @@ -703,10 +703,8 @@ jobs: timeout-minutes: 60 runs-on: github-8vcpu-ubuntu-2404 if: | - false && ( - startsWith(github.ref, 'refs/tags/v') - || contains(github.event.pull_request.labels.*.name, 'run-bundling') - ) + ( startsWith(github.ref, 'refs/tags/v') + || contains(github.event.pull_request.labels.*.name, 'run-bundling') ) needs: [linux_tests] name: Build Zed on FreeBSD steps: diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index b3500a085b..ed9f4c8450 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -168,7 +168,7 @@ jobs: name: Create a Linux *.tar.gz bundle for ARM if: github.repository_owner == 'zed-industries' runs-on: - - namespace-profile-32x64-ubuntu-2004-arm # ubuntu 20.04 for minimal glibc + - namespace-profile-8x32-ubuntu-2004-arm-m4 # ubuntu 20.04 for minimal glibc needs: tests steps: - name: Checkout repo From 024a5bbcd0f40dc7c9c762a207ef49964b0ec8b4 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 8 Aug 2025 16:40:41 -0300 Subject: [PATCH 016/109] onboarding: Add some adjustments (#35887) Release Notes: - N/A --- assets/keymaps/default-linux.json | 3 +- assets/keymaps/default-macos.json | 3 +- crates/client/src/zed_urls.rs | 8 +++ crates/onboarding/src/ai_setup_page.rs | 97 +++++++++++--------------- crates/onboarding/src/editing_page.rs | 2 +- crates/onboarding/src/onboarding.rs | 50 +++++++++++-- 6 files changed, 97 insertions(+), 66 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index c436b1a8fb..708432393c 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1187,7 +1187,8 @@ "ctrl-2": "onboarding::ActivateEditingPage", "ctrl-3": "onboarding::ActivateAISetupPage", "ctrl-escape": "onboarding::Finish", - "alt-tab": "onboarding::SignIn" + "alt-tab": "onboarding::SignIn", + "alt-shift-a": "onboarding::OpenAccount" } } ] diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 960bac1479..abb741af29 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1289,7 +1289,8 @@ "cmd-2": "onboarding::ActivateEditingPage", "cmd-3": "onboarding::ActivateAISetupPage", "cmd-escape": "onboarding::Finish", - "alt-tab": "onboarding::SignIn" + "alt-tab": "onboarding::SignIn", + "alt-shift-a": "onboarding::OpenAccount" } } ] diff --git a/crates/client/src/zed_urls.rs b/crates/client/src/zed_urls.rs index 693c7bf836..9df41906d7 100644 --- a/crates/client/src/zed_urls.rs +++ b/crates/client/src/zed_urls.rs @@ -35,3 +35,11 @@ pub fn upgrade_to_zed_pro_url(cx: &App) -> String { pub fn terms_of_service(cx: &App) -> String { format!("{server_url}/terms-of-service", server_url = server_url(cx)) } + +/// Returns the URL to Zed AI's privacy and security docs. +pub fn ai_privacy_and_security(cx: &App) -> String { + format!( + "{server_url}/docs/ai/privacy-and-security", + server_url = server_url(cx) + ) +} diff --git a/crates/onboarding/src/ai_setup_page.rs b/crates/onboarding/src/ai_setup_page.rs index 0397bcbd9b..8203f96479 100644 --- a/crates/onboarding/src/ai_setup_page.rs +++ b/crates/onboarding/src/ai_setup_page.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use ai_onboarding::AiUpsellCard; -use client::{Client, UserStore}; +use client::{Client, UserStore, zed_urls}; use fs::Fs; use gpui::{ Action, AnyView, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, WeakEntity, @@ -42,10 +42,16 @@ fn render_llm_provider_section( } fn render_privacy_card(tab_index: &mut isize, disabled: bool, cx: &mut App) -> impl IntoElement { - let privacy_badge = || { - Badge::new("Privacy") - .icon(IconName::ShieldCheck) - .tooltip(move |_, cx| cx.new(|_| AiPrivacyTooltip::new()).into()) + let (title, description) = if disabled { + ( + "AI is disabled across Zed", + "Re-enable it any time in Settings.", + ) + } else { + ( + "Privacy is the default for Zed", + "Any use or storage of your data is with your explicit, single-use, opt-in consent.", + ) }; v_flex() @@ -60,62 +66,41 @@ fn render_privacy_card(tab_index: &mut isize, disabled: bool, cx: &mut App) -> i .bg(cx.theme().colors().surface_background.opacity(0.3)) .rounded_lg() .overflow_hidden() - .map(|this| { - if disabled { - this.child( + .child( + h_flex() + .gap_2() + .justify_between() + .child(Label::new(title)) + .child( h_flex() - .gap_2() - .justify_between() + .gap_1() .child( - h_flex() - .gap_1() - .child(Label::new("AI is disabled across Zed")) - .child( - Icon::new(IconName::Check) - .color(Color::Success) - .size(IconSize::XSmall), - ), + Badge::new("Privacy") + .icon(IconName::ShieldCheck) + .tooltip(move |_, cx| cx.new(|_| AiPrivacyTooltip::new()).into()), ) - .child(privacy_badge()), - ) - .child( - Label::new("Re-enable it any time in Settings.") - .size(LabelSize::Small) - .color(Color::Muted), - ) - } else { - this.child( - h_flex() - .gap_2() - .justify_between() - .child(Label::new("Privacy is the default for Zed")) .child( - h_flex().gap_1().child(privacy_badge()).child( - Button::new("learn_more", "Learn More") - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .on_click(|_, _, cx| { - cx.open_url("https://zed.dev/docs/ai/privacy-and-security"); - }) - .tab_index({ - *tab_index += 1; - *tab_index - 1 - }), - ), + Button::new("learn_more", "Learn More") + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .icon(IconName::ArrowUpRight) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .on_click(|_, _, cx| { + cx.open_url(&zed_urls::ai_privacy_and_security(cx)) + }) + .tab_index({ + *tab_index += 1; + *tab_index - 1 + }), ), - ) - .child( - Label::new( - "Any use or storage of your data is with your explicit, single-use, opt-in consent.", - ) - .size(LabelSize::Small) - .color(Color::Muted), - ) - } - }) + ), + ) + .child( + Label::new(description) + .size(LabelSize::Small) + .color(Color::Muted), + ) } fn render_llm_provider_card( diff --git a/crates/onboarding/src/editing_page.rs b/crates/onboarding/src/editing_page.rs index 8b4293db0d..13b4f6a5c1 100644 --- a/crates/onboarding/src/editing_page.rs +++ b/crates/onboarding/src/editing_page.rs @@ -655,7 +655,7 @@ fn render_popular_settings_section( .child( SwitchField::new( "onboarding-git-blame-switch", - "Git Blame", + "Inline Git Blame", Some("See who committed each line on a given file.".into()), if read_git_blame(cx) { ui::ToggleState::Selected diff --git a/crates/onboarding/src/onboarding.rs b/crates/onboarding/src/onboarding.rs index 145cb07a1c..7ba7ba60cb 100644 --- a/crates/onboarding/src/onboarding.rs +++ b/crates/onboarding/src/onboarding.rs @@ -1,5 +1,5 @@ use crate::welcome::{ShowWelcome, WelcomePage}; -use client::{Client, UserStore}; +use client::{Client, UserStore, zed_urls}; use command_palette_hooks::CommandPaletteFilter; use db::kvp::KEY_VALUE_STORE; use feature_flags::{FeatureFlag, FeatureFlagViewExt as _}; @@ -78,7 +78,9 @@ actions!( /// Finish the onboarding process. Finish, /// Sign in while in the onboarding flow. - SignIn + SignIn, + /// Open the user account in zed.dev while in the onboarding flow. + OpenAccount ] ); @@ -420,11 +422,40 @@ impl Onboarding { ) .child( if let Some(user) = self.user_store.read(cx).current_user() { - h_flex() - .pl_1p5() - .gap_2() - .child(Avatar::new(user.avatar_uri.clone())) - .child(Label::new(user.github_login.clone())) + v_flex() + .gap_1() + .child( + h_flex() + .ml_2() + .gap_2() + .max_w_full() + .w_full() + .child(Avatar::new(user.avatar_uri.clone())) + .child(Label::new(user.github_login.clone()).truncate()), + ) + .child( + ButtonLike::new("open_account") + .size(ButtonSize::Medium) + .child( + h_flex() + .ml_1() + .w_full() + .justify_between() + .child(Label::new("Open Account")) + .children( + KeyBinding::for_action_in( + &OpenAccount, + &self.focus_handle, + window, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ), + ) + .on_click(|_, window, cx| { + window.dispatch_action(OpenAccount.boxed_clone(), cx); + }), + ) .into_any_element() } else { Button::new("sign_in", "Sign In") @@ -460,6 +491,10 @@ impl Onboarding { .detach(); } + fn handle_open_account(_: &OpenAccount, _: &mut Window, cx: &mut App) { + cx.open_url(&zed_urls::account_url(cx)) + } + fn render_page(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { let client = Client::global(cx); @@ -495,6 +530,7 @@ impl Render for Onboarding { .bg(cx.theme().colors().editor_background) .on_action(Self::on_finish) .on_action(Self::handle_sign_in) + .on_action(Self::handle_open_account) .on_action(cx.listener(|this, _: &ActivateBasicsPage, _, cx| { this.set_page(SelectedPage::Basics, cx); })) From e0fc32009f061f3c767effa0936c7da83086edcd Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Fri, 8 Aug 2025 23:12:41 +0300 Subject: [PATCH 017/109] Fill capabilities on project (re)join (#35892) Follow-up of https://github.com/zed-industries/zed/pull/35682 Release Notes: - N/A Co-authored-by: Smit Barmase --- crates/project/src/lsp_store.rs | 11 +++++++++-- crates/project/src/project.rs | 10 ++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index b88cf42ff5..d3843bc4ea 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -7768,12 +7768,19 @@ impl LspStore { pub(crate) fn set_language_server_statuses_from_proto( &mut self, language_servers: Vec, + server_capabilities: Vec, ) { self.language_server_statuses = language_servers .into_iter() - .map(|server| { + .zip(server_capabilities) + .map(|(server, server_capabilities)| { + let server_id = LanguageServerId(server.id as usize); + if let Ok(server_capabilities) = serde_json::from_str(&server_capabilities) { + self.lsp_server_capabilities + .insert(server_id, server_capabilities); + } ( - LanguageServerId(server.id as usize), + server_id, LanguageServerStatus { name: LanguageServerName::from_proto(server.name), pending_work: Default::default(), diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index b3a9e6fdf5..d543e6bf25 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -1487,7 +1487,10 @@ impl Project { fs.clone(), cx, ); - lsp_store.set_language_server_statuses_from_proto(response.payload.language_servers); + lsp_store.set_language_server_statuses_from_proto( + response.payload.language_servers, + response.payload.language_server_capabilities, + ); lsp_store })?; @@ -2318,7 +2321,10 @@ impl Project { self.set_worktrees_from_proto(message.worktrees, cx)?; self.set_collaborators_from_proto(message.collaborators, cx)?; self.lsp_store.update(cx, |lsp_store, _| { - lsp_store.set_language_server_statuses_from_proto(message.language_servers) + lsp_store.set_language_server_statuses_from_proto( + message.language_servers, + message.language_server_capabilities, + ) }); self.enqueue_buffer_ordered_message(BufferOrderedMessage::Resync) .unwrap(); From fd1beedb16fcc5444e4389d412df78691fe8150e Mon Sep 17 00:00:00 2001 From: ddoemonn <109994179+ddoemonn@users.noreply.github.com> Date: Fri, 8 Aug 2025 23:46:31 +0300 Subject: [PATCH 018/109] Prevent scrollbar from covering bottom right text in terminal (#33636) Closes https://github.com/zed-industries/zed/issues/27241 Release Notes: - Fixed terminal scrollbar covering bottom right text by adding proper content padding when scrollbar is visible --------- Co-authored-by: Danilo Leal --- crates/terminal_view/src/terminal_view.rs | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 0c05aec85e..0ec5f816d5 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -64,8 +64,8 @@ use std::{ }; const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500); - const GIT_DIFF_PATH_PREFIXES: &[&str] = &["a", "b"]; +const TERMINAL_SCROLLBAR_WIDTH: Pixels = px(12.); /// Event to transmit the scroll from the element to the view #[derive(Clone, Debug, PartialEq)] @@ -956,13 +956,12 @@ impl TerminalView { .on_scroll_wheel(cx.listener(|_, _, _window, cx| { cx.notify(); })) - .h_full() .absolute() - .right_1() - .top_1() + .top_0() .bottom_0() - .w(px(12.)) - .cursor_default() + .right_0() + .h_full() + .w(TERMINAL_SCROLLBAR_WIDTH) .children(Scrollbar::vertical(self.scrollbar_state.clone())), ) } @@ -1496,6 +1495,16 @@ impl Render for TerminalView { let focused = self.focus_handle.is_focused(window); + // Always calculate scrollbar width to prevent layout shift + let scrollbar_width = if Self::should_show_scrollbar(cx) + && self.content_mode(window, cx).is_scrollable() + && self.terminal.read(cx).total_lines() > self.terminal.read(cx).viewport_lines() + { + TERMINAL_SCROLLBAR_WIDTH + } else { + px(0.) + }; + div() .id("terminal-view") .size_full() @@ -1545,6 +1554,8 @@ impl Render for TerminalView { // TODO: Oddly this wrapper div is needed for TerminalElement to not steal events from the context menu div() .size_full() + .bg(cx.theme().colors().editor_background) + .when(scrollbar_width > px(0.), |div| div.pr(scrollbar_width)) .child(TerminalElement::new( terminal_handle, terminal_view_handle, From 91474e247f188a761a4f1adba9fead7f1b54cb96 Mon Sep 17 00:00:00 2001 From: Daniel Sauble Date: Fri, 8 Aug 2025 14:04:32 -0700 Subject: [PATCH 019/109] Make close tab and pin tab buttons slightly larger for better usability (#34428) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #6817 Increases the size of tab buttons from 16px to 18px so they're easier to click. For comparison, tab buttons in VSCode have a click target size of 20px, so we're still a bit smaller than that. Before: before_tab_buttons After: after_tab_buttons VSCode (for comparison): Screenshot 2025-07-14 at 1 43 03 PM Release Notes: - Improve usability of close tab and pin tab buttons by making them slightly larger --------- Co-authored-by: Danilo Leal --- crates/ui/src/components/tab.rs | 7 +++++-- crates/workspace/src/pane.rs | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/ui/src/components/tab.rs b/crates/ui/src/components/tab.rs index d704846a68..e6823f46b7 100644 --- a/crates/ui/src/components/tab.rs +++ b/crates/ui/src/components/tab.rs @@ -5,6 +5,9 @@ use smallvec::SmallVec; use crate::prelude::*; +const START_TAB_SLOT_SIZE: Pixels = px(12.); +const END_TAB_SLOT_SIZE: Pixels = px(14.); + /// The position of a [`Tab`] within a list of tabs. #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum TabPosition { @@ -123,12 +126,12 @@ impl RenderOnce for Tab { let (start_slot, end_slot) = { let start_slot = h_flex() - .size(px(12.)) // use px over rem from size_3 + .size(START_TAB_SLOT_SIZE) .justify_center() .children(self.start_slot); let end_slot = h_flex() - .size(px(12.)) // use px over rem from size_3 + .size(END_TAB_SLOT_SIZE) .justify_center() .children(self.end_slot); diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index a9e7304e47..0c35752165 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -2519,7 +2519,7 @@ impl Pane { .shape(IconButtonShape::Square) .icon_color(Color::Muted) .size(ButtonSize::None) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .on_click(cx.listener(move |pane, _, window, cx| { pane.unpin_tab_at(ix, window, cx); })) @@ -2539,7 +2539,7 @@ impl Pane { .shape(IconButtonShape::Square) .icon_color(Color::Muted) .size(ButtonSize::None) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .on_click(cx.listener(move |pane, _, window, cx| { pane.close_item_by_id(item_id, SaveIntent::Close, window, cx) .detach_and_log_err(cx); From c6ef35ba378cce9c3d8801ce8aede114dfd90179 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Fri, 8 Aug 2025 17:05:28 -0400 Subject: [PATCH 020/109] Disable edit predictions in Zed settings by default (#34401) In Zed settings, json schema based LSP autocomplete is very good, edit predictions are not. Disable the latter by default. Release Notes: - N/A --- assets/settings/default.json | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 9c579b858d..28cf591ee7 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1210,7 +1210,18 @@ // Any addition to this list will be merged with the default list. // Globs are matched relative to the worktree root, // except when starting with a slash (/) or equivalent in Windows. - "disabled_globs": ["**/.env*", "**/*.pem", "**/*.key", "**/*.cert", "**/*.crt", "**/.dev.vars", "**/secrets.yml"], + "disabled_globs": [ + "**/.env*", + "**/*.pem", + "**/*.key", + "**/*.cert", + "**/*.crt", + "**/.dev.vars", + "**/secrets.yml", + "**/.zed/settings.json", // zed project settings + "/**/zed/settings.json", // zed user settings + "/**/zed/keymap.json" + ], // When to show edit predictions previews in buffer. // This setting takes two possible values: // 1. Display predictions inline when there are no language server completions available. From 2be6f9d17bfdc0687f0b227139f74bbbe7028897 Mon Sep 17 00:00:00 2001 From: Aleksei Gusev Date: Sat, 9 Aug 2025 00:17:19 +0300 Subject: [PATCH 021/109] theme: Add support for per-theme overrides (#30860) Closes #14050 Release Notes: - Added the ability to set theme-specific overrides via the `theme_overrides` setting. --------- Co-authored-by: Peter Tripp Co-authored-by: Marshall Bowers --- crates/theme/src/settings.rs | 73 +++++++++++++++++++++++------------- 1 file changed, 47 insertions(+), 26 deletions(-) diff --git a/crates/theme/src/settings.rs b/crates/theme/src/settings.rs index 6d19494f40..f5f1fd5547 100644 --- a/crates/theme/src/settings.rs +++ b/crates/theme/src/settings.rs @@ -4,6 +4,7 @@ use crate::{ ThemeNotFoundError, ThemeRegistry, ThemeStyleContent, }; use anyhow::Result; +use collections::HashMap; use derive_more::{Deref, DerefMut}; use gpui::{ App, Context, Font, FontFallbacks, FontFeatures, FontStyle, FontWeight, Global, Pixels, @@ -117,7 +118,9 @@ pub struct ThemeSettings { /// Manual overrides for the active theme. /// /// Note: This setting is still experimental. See [this tracking issue](https://github.com/zed-industries/zed/issues/18078) - pub theme_overrides: Option, + pub experimental_theme_overrides: Option, + /// Manual overrides per theme + pub theme_overrides: HashMap, /// The current icon theme selection. pub icon_theme_selection: Option, /// The active icon theme. @@ -425,7 +428,13 @@ pub struct ThemeSettingsContent { /// /// These values will override the ones on the current theme specified in `theme`. #[serde(rename = "experimental.theme_overrides", default)] - pub theme_overrides: Option, + pub experimental_theme_overrides: Option, + + /// Overrides per theme + /// + /// These values will override the ones on the specified theme + #[serde(default)] + pub theme_overrides: HashMap, } fn default_font_features() -> Option { @@ -647,30 +656,39 @@ impl ThemeSettings { /// Applies the theme overrides, if there are any, to the current theme. pub fn apply_theme_overrides(&mut self) { - if let Some(theme_overrides) = &self.theme_overrides { - let mut base_theme = (*self.active_theme).clone(); - - if let Some(window_background_appearance) = theme_overrides.window_background_appearance - { - base_theme.styles.window_background_appearance = - window_background_appearance.into(); - } - - base_theme - .styles - .colors - .refine(&theme_overrides.theme_colors_refinement()); - base_theme - .styles - .status - .refine(&theme_overrides.status_colors_refinement()); - base_theme.styles.player.merge(&theme_overrides.players); - base_theme.styles.accents.merge(&theme_overrides.accents); - base_theme.styles.syntax = - SyntaxTheme::merge(base_theme.styles.syntax, theme_overrides.syntax_overrides()); - - self.active_theme = Arc::new(base_theme); + // Apply the old overrides setting first, so that the new setting can override those. + if let Some(experimental_theme_overrides) = &self.experimental_theme_overrides { + let mut theme = (*self.active_theme).clone(); + ThemeSettings::modify_theme(&mut theme, experimental_theme_overrides); + self.active_theme = Arc::new(theme); } + + if let Some(theme_overrides) = self.theme_overrides.get(self.active_theme.name.as_ref()) { + let mut theme = (*self.active_theme).clone(); + ThemeSettings::modify_theme(&mut theme, theme_overrides); + self.active_theme = Arc::new(theme); + } + } + + fn modify_theme(base_theme: &mut Theme, theme_overrides: &ThemeStyleContent) { + if let Some(window_background_appearance) = theme_overrides.window_background_appearance { + base_theme.styles.window_background_appearance = window_background_appearance.into(); + } + + base_theme + .styles + .colors + .refine(&theme_overrides.theme_colors_refinement()); + base_theme + .styles + .status + .refine(&theme_overrides.status_colors_refinement()); + base_theme.styles.player.merge(&theme_overrides.players); + base_theme.styles.accents.merge(&theme_overrides.accents); + base_theme.styles.syntax = SyntaxTheme::merge( + base_theme.styles.syntax.clone(), + theme_overrides.syntax_overrides(), + ); } /// Switches to the icon theme with the given name, if it exists. @@ -848,7 +866,8 @@ impl settings::Settings for ThemeSettings { .get(defaults.theme.as_ref().unwrap().theme(*system_appearance)) .or(themes.get(&zed_default_dark().name)) .unwrap(), - theme_overrides: None, + experimental_theme_overrides: None, + theme_overrides: HashMap::default(), icon_theme_selection: defaults.icon_theme.clone(), active_icon_theme: defaults .icon_theme @@ -918,6 +937,8 @@ impl settings::Settings for ThemeSettings { } } + this.experimental_theme_overrides + .clone_from(&value.experimental_theme_overrides); this.theme_overrides.clone_from(&value.theme_overrides); this.apply_theme_overrides(); From f3399daf6c7d48adf9c07e3d9df3349495c8c0f0 Mon Sep 17 00:00:00 2001 From: Alvaro Parker <64918109+AlvaroParker@users.noreply.github.com> Date: Fri, 8 Aug 2025 17:32:13 -0400 Subject: [PATCH 022/109] file_finder: Fix right border not rendering (#35684) Closes #35683 Release Notes: - Fixed file finder borders not rendering properly Before: image After: image --- crates/file_finder/src/file_finder.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index e5ac70bb58..c6997ccdc0 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -17,7 +17,7 @@ use fuzzy::{CharBag, PathMatch, PathMatchCandidate}; use gpui::{ Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, WeakEntity, - Window, actions, + Window, actions, rems, }; use open_path_prompt::OpenPathPrompt; use picker::{Picker, PickerDelegate}; @@ -350,7 +350,7 @@ impl FileFinder { pub fn modal_max_width(width_setting: Option, window: &mut Window) -> Pixels { let window_width = window.viewport_size().width; - let small_width = Pixels(545.); + let small_width = rems(34.).to_pixels(window.rem_size()); match width_setting { None | Some(FileFinderWidth::Small) => small_width, From d7db03443a4a27e24c95be106e6ce10624c01f68 Mon Sep 17 00:00:00 2001 From: Julia Ryan Date: Fri, 8 Aug 2025 16:32:36 -0500 Subject: [PATCH 023/109] Upload debug info for preview/stable builds (#35895) This should fix all the unsymbolicated backtraces we're seeing on preview builds Release Notes: - N/A --- .github/workflows/ci.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02edc99824..928c47a4a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -526,6 +526,11 @@ jobs: with: node-version: "18" + - name: Setup Sentry CLI + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2 + with: + token: ${{ SECRETS.SENTRY_AUTH_TOKEN }} + - name: Checkout repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: @@ -611,6 +616,11 @@ jobs: - name: Install Linux dependencies run: ./script/linux && ./script/install-mold 2.34.0 + - name: Setup Sentry CLI + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2 + with: + token: ${{ SECRETS.SENTRY_AUTH_TOKEN }} + - name: Determine version and release channel if: startsWith(github.ref, 'refs/tags/v') run: | @@ -664,6 +674,11 @@ jobs: - name: Install Linux dependencies run: ./script/linux + - name: Setup Sentry CLI + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2 + with: + token: ${{ SECRETS.SENTRY_AUTH_TOKEN }} + - name: Determine version and release channel if: startsWith(github.ref, 'refs/tags/v') run: | @@ -789,6 +804,11 @@ jobs: with: clean: false + - name: Setup Sentry CLI + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2 + with: + token: ${{ SECRETS.SENTRY_AUTH_TOKEN }} + - name: Determine version and release channel working-directory: ${{ env.ZED_WORKSPACE }} if: ${{ startsWith(github.ref, 'refs/tags/v') }} From a4f7747c7382e2116ed2f29ffefd028d3cd043f0 Mon Sep 17 00:00:00 2001 From: Phileas Lebada Date: Fri, 8 Aug 2025 22:44:03 +0100 Subject: [PATCH 024/109] Improve extension development docs (#33646) I'm installing an extension for the first time from source and assumed that the sentence > If you already have a published extension with the same name installed, your dev extension will override it. also means that it would override the already installed extension. Besides that I've had to use `--foreground` mode to also get more meaningful error messages under NixOS without using `programs.nix-ld.enabled = true;`. Release Notes: - Improved Zed documentation for extension development --------- Co-authored-by: Peter Tripp --- docs/src/extensions/developing-extensions.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/src/extensions/developing-extensions.md b/docs/src/extensions/developing-extensions.md index 97af1f2673..947956f5b7 100644 --- a/docs/src/extensions/developing-extensions.md +++ b/docs/src/extensions/developing-extensions.md @@ -19,10 +19,16 @@ Before starting to develop an extension for Zed, be sure to [install Rust via ru When developing an extension, you can use it in Zed without needing to publish it by installing it as a _dev extension_. -From the extensions page, click the `Install Dev Extension` button and select the directory containing your extension. +From the extensions page, click the `Install Dev Extension` button (or the {#action zed::InstallDevExtension} action) and select the directory containing your extension. + +If you need to troubleshoot, you can check the Zed.log ({#action zed::OpenLog}) for additional output. For debug output, close and relaunch zed with the `zed --foreground` from the command line which show more verbose INFO level logging. If you already have a published extension with the same name installed, your dev extension will override it. +After installing the `Extensions` page will indicate that that the upstream extension is "Overridden by dev extension". + +Pre-installed extensions with the same name have to be uninstalled before installing the dev extension. See [#31106](https://github.com/zed-industries/zed/issues/31106) for more. + ## Directory Structure of a Zed Extension A Zed extension is a Git repository that contains an `extension.toml`. This file must contain some From a1bc6ee75e9371121caac1b115518cc1396085a3 Mon Sep 17 00:00:00 2001 From: Michael Sloan Date: Fri, 8 Aug 2025 16:16:13 -0600 Subject: [PATCH 025/109] zeta: Only send outline and diagnostics when data collection is enabled (#35896) This data is not currently used by edit predictions - it is only useful when `can_collect_data == true`. Release Notes: - N/A --- crates/zeta/src/zeta.rs | 8 ++++++-- crates/zeta_cli/src/main.rs | 38 +++++++++++++++++++++++-------------- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/crates/zeta/src/zeta.rs b/crates/zeta/src/zeta.rs index b1bd737dbf..828310a3bd 100644 --- a/crates/zeta/src/zeta.rs +++ b/crates/zeta/src/zeta.rs @@ -1211,7 +1211,7 @@ pub fn gather_context( let local_lsp_store = project.and_then(|project| project.read(cx).lsp_store().read(cx).as_local()); let diagnostic_groups: Vec<(String, serde_json::Value)> = - if let Some(local_lsp_store) = local_lsp_store { + if can_collect_data && let Some(local_lsp_store) = local_lsp_store { snapshot .diagnostic_groups(None) .into_iter() @@ -1245,7 +1245,11 @@ pub fn gather_context( MAX_CONTEXT_TOKENS, ); let input_events = make_events_prompt(); - let input_outline = prompt_for_outline(&snapshot); + let input_outline = if can_collect_data { + prompt_for_outline(&snapshot) + } else { + String::new() + }; let editable_range = input_excerpt.editable_range.to_offset(&snapshot); let body = PredictEditsBody { diff --git a/crates/zeta_cli/src/main.rs b/crates/zeta_cli/src/main.rs index adf7683152..d78035bc9d 100644 --- a/crates/zeta_cli/src/main.rs +++ b/crates/zeta_cli/src/main.rs @@ -171,21 +171,31 @@ async fn get_context( Some(events) => events.read_to_string().await?, None => String::new(), }; - let can_collect_data = false; + // Enable gathering extra data not currently needed for edit predictions + let can_collect_data = true; let git_info = None; - cx.update(|cx| { - gather_context( - project.as_ref(), - full_path_str, - &snapshot, - clipped_cursor, - move || events, - can_collect_data, - git_info, - cx, - ) - })? - .await + let mut gather_context_output = cx + .update(|cx| { + gather_context( + project.as_ref(), + full_path_str, + &snapshot, + clipped_cursor, + move || events, + can_collect_data, + git_info, + cx, + ) + })? + .await; + + // Disable data collection for these requests, as this is currently just used for evals + match gather_context_output.as_mut() { + Ok(gather_context_output) => gather_context_output.body.can_collect_data = false, + Err(_) => {} + } + + gather_context_output } pub async fn open_buffer_with_language_server( From 9443c930de078bc6ebdc7c82099b05816ec68aa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20G=C3=B3mez?= Date: Sat, 9 Aug 2025 00:50:39 +0200 Subject: [PATCH 026/109] Make One Dark's `ansi.*magenta` colors more magenta-y (#35423) Tweak the `ansi.*magenta` colours so they are not confused with `ansi.*red`. This matches how "One Light" behaves, where `ansi.*magenta` uses the same purple as for keyword. This change helps distinguish anything that the terminal might use magenta for from errors, and helps make more readable the output of certain tools. For maintainers: The color for `ansi.magenta` is the same as for `syntax.keyword`. The others are modifications on that colour to taste. If you have some specific shades that need to be used please tell me, or feel free to take over the PR. Before: `jj log` and `difftastic` output Screenshot 2025-07-31 at 19 32 11 After: Screenshot 2025-07-31 at 19 35 33 Release Notes: - N/A --------- Co-authored-by: Danilo Leal --- assets/themes/one/one.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/assets/themes/one/one.json b/assets/themes/one/one.json index 384ad28272..23ebbcc67e 100644 --- a/assets/themes/one/one.json +++ b/assets/themes/one/one.json @@ -86,9 +86,9 @@ "terminal.ansi.blue": "#74ade8ff", "terminal.ansi.bright_blue": "#385378ff", "terminal.ansi.dim_blue": "#bed5f4ff", - "terminal.ansi.magenta": "#be5046ff", - "terminal.ansi.bright_magenta": "#5e2b26ff", - "terminal.ansi.dim_magenta": "#e6a79eff", + "terminal.ansi.magenta": "#b477cfff", + "terminal.ansi.bright_magenta": "#d6b4e4ff", + "terminal.ansi.dim_magenta": "#612a79ff", "terminal.ansi.cyan": "#6eb4bfff", "terminal.ansi.bright_cyan": "#3a565bff", "terminal.ansi.dim_cyan": "#b9d9dfff", From aedf195e978cb2312d66249f1c5d798c2d138d72 Mon Sep 17 00:00:00 2001 From: Michael Sloan Date: Fri, 8 Aug 2025 17:26:38 -0600 Subject: [PATCH 027/109] Use distinct user agents in agent eval and zeta-cli (#35897) Agent eval now also uses a proper Zed version Release Notes: - N/A --- crates/eval/build.rs | 14 ++++++++++++++ crates/eval/src/eval.rs | 4 ++-- crates/zeta_cli/build.rs | 2 +- crates/zeta_cli/src/headless.rs | 2 +- 4 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 crates/eval/build.rs diff --git a/crates/eval/build.rs b/crates/eval/build.rs new file mode 100644 index 0000000000..9ab40da0fb --- /dev/null +++ b/crates/eval/build.rs @@ -0,0 +1,14 @@ +fn main() { + let cargo_toml = + std::fs::read_to_string("../zed/Cargo.toml").expect("Failed to read crates/zed/Cargo.toml"); + let version = cargo_toml + .lines() + .find(|line| line.starts_with("version = ")) + .expect("Version not found in crates/zed/Cargo.toml") + .split('=') + .nth(1) + .expect("Invalid version format") + .trim() + .trim_matches('"'); + println!("cargo:rustc-env=ZED_PKG_VERSION={}", version); +} diff --git a/crates/eval/src/eval.rs b/crates/eval/src/eval.rs index d638ac171f..6558222d89 100644 --- a/crates/eval/src/eval.rs +++ b/crates/eval/src/eval.rs @@ -337,7 +337,7 @@ pub struct AgentAppState { } pub fn init(cx: &mut App) -> Arc { - let app_version = AppVersion::global(cx); + let app_version = AppVersion::load(env!("ZED_PKG_VERSION")); release_channel::init(app_version, cx); gpui_tokio::init(cx); @@ -350,7 +350,7 @@ pub fn init(cx: &mut App) -> Arc { // Set User-Agent so we can download language servers from GitHub let user_agent = format!( - "Zed/{} ({}; {})", + "Zed Agent Eval/{} ({}; {})", app_version, std::env::consts::OS, std::env::consts::ARCH diff --git a/crates/zeta_cli/build.rs b/crates/zeta_cli/build.rs index ccbb54c5b4..9ab40da0fb 100644 --- a/crates/zeta_cli/build.rs +++ b/crates/zeta_cli/build.rs @@ -1,6 +1,6 @@ fn main() { let cargo_toml = - std::fs::read_to_string("../zed/Cargo.toml").expect("Failed to read Cargo.toml"); + std::fs::read_to_string("../zed/Cargo.toml").expect("Failed to read crates/zed/Cargo.toml"); let version = cargo_toml .lines() .find(|line| line.starts_with("version = ")) diff --git a/crates/zeta_cli/src/headless.rs b/crates/zeta_cli/src/headless.rs index 959bb91a8f..d6ee085d18 100644 --- a/crates/zeta_cli/src/headless.rs +++ b/crates/zeta_cli/src/headless.rs @@ -40,7 +40,7 @@ pub fn init(cx: &mut App) -> ZetaCliAppState { // Set User-Agent so we can download language servers from GitHub let user_agent = format!( - "Zed/{} ({}; {})", + "Zeta CLI/{} ({}; {})", app_version, std::env::consts::OS, std::env::consts::ARCH From c0539230154356f948a858bc1ccf75af2b70b6e5 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Fri, 8 Aug 2025 19:50:59 -0400 Subject: [PATCH 028/109] thread_view: Trim only trailing whitespace from last chunk of user message (#35902) This fixes internal whitespace after the last @mention going missing from the user message as displayed in history. Release Notes: - N/A --- crates/agent_ui/src/acp/thread_view.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 6411abb84f..c811878c21 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -410,7 +410,7 @@ impl AcpThreadView { } if ix < text.len() { - let last_chunk = text[ix..].trim(); + let last_chunk = text[ix..].trim_end(); if !last_chunk.is_empty() { chunks.push(last_chunk.into()); } From 4e97968bcb6963ed4c474d12477e181899b4decc Mon Sep 17 00:00:00 2001 From: Michael Sloan Date: Fri, 8 Aug 2025 18:38:54 -0600 Subject: [PATCH 029/109] zeta: Update data collection eligibility when license file contents change + add Apache 2.0 (#35900) Closes #35070 Release Notes: - Edit Prediction: Made license detection update eligibility for data collection when license files change. - Edit Prediction: Added Apache 2.0 license to opensource licenses eligible for data collection. - Edit Prediction: Made license detection less sensitive to whitespace differences and check more files. --- crates/zeta/src/license_detection.rs | 654 +++++++++++++----- crates/zeta/src/license_detection/apache-text | 174 +++++ .../zeta/src/license_detection/apache.regex | 201 ++++++ crates/zeta/src/license_detection/isc.regex | 15 + crates/zeta/src/license_detection/mit-text | 21 + crates/zeta/src/license_detection/mit.regex | 21 + crates/zeta/src/license_detection/upl.regex | 35 + crates/zeta/src/zeta.rs | 66 +- 8 files changed, 955 insertions(+), 232 deletions(-) create mode 100644 crates/zeta/src/license_detection/apache-text create mode 100644 crates/zeta/src/license_detection/apache.regex create mode 100644 crates/zeta/src/license_detection/isc.regex create mode 100644 crates/zeta/src/license_detection/mit-text create mode 100644 crates/zeta/src/license_detection/mit.regex create mode 100644 crates/zeta/src/license_detection/upl.regex diff --git a/crates/zeta/src/license_detection.rs b/crates/zeta/src/license_detection.rs index e27ef8918d..c55f8d5d08 100644 --- a/crates/zeta/src/license_detection.rs +++ b/crates/zeta/src/license_detection.rs @@ -1,204 +1,213 @@ +use std::{ + collections::BTreeSet, + path::{Path, PathBuf}, + sync::{Arc, LazyLock}, +}; + +use fs::Fs; +use futures::StreamExt as _; +use gpui::{App, AppContext as _, Entity, Subscription, Task}; +use postage::watch; +use project::Worktree; use regex::Regex; +use util::ResultExt as _; +use worktree::ChildEntriesOptions; -/// The most common license locations, with US and UK English spelling. -pub const LICENSE_FILES_TO_CHECK: &[&str] = &[ - "LICENSE", - "LICENCE", - "LICENSE.txt", - "LICENCE.txt", - "LICENSE.md", - "LICENCE.md", -]; +/// Matches the most common license locations, with US and UK English spelling. +const LICENSE_FILE_NAME_REGEX: LazyLock = LazyLock::new(|| { + regex::bytes::RegexBuilder::new( + "^ \ + (?: license | licence) \ + (?: [\\-._] (?: apache | isc | mit | upl))? \ + (?: \\.txt | \\.md)? \ + $", + ) + .ignore_whitespace(true) + .case_insensitive(true) + .build() + .unwrap() +}); -pub fn is_license_eligible_for_data_collection(license: &str) -> bool { - // TODO: Include more licenses later (namely, Apache) - for pattern in [MIT_LICENSE_REGEX, ISC_LICENSE_REGEX, UPL_LICENSE_REGEX] { - let regex = Regex::new(pattern.trim()).unwrap(); - if regex.is_match(license.trim()) { - return true; - } - } - false +fn is_license_eligible_for_data_collection(license: &str) -> bool { + const LICENSE_REGEXES: LazyLock> = LazyLock::new(|| { + [ + include_str!("license_detection/apache.regex"), + include_str!("license_detection/isc.regex"), + include_str!("license_detection/mit.regex"), + include_str!("license_detection/upl.regex"), + ] + .into_iter() + .map(|pattern| Regex::new(&canonicalize_license_text(pattern)).unwrap()) + .collect() + }); + + let license = canonicalize_license_text(license); + LICENSE_REGEXES.iter().any(|regex| regex.is_match(&license)) } -const MIT_LICENSE_REGEX: &str = r#" -^.*MIT License.* +/// Canonicalizes the whitespace of license text and license regexes. +fn canonicalize_license_text(license: &str) -> String { + const PARAGRAPH_SEPARATOR_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"\s*\n\s*\n\s*").unwrap()); -Copyright.*? + PARAGRAPH_SEPARATOR_REGEX + .split(license) + .filter(|paragraph| !paragraph.trim().is_empty()) + .map(|paragraph| { + paragraph + .trim() + .split_whitespace() + .collect::>() + .join(" ") + }) + .collect::>() + .join("\n\n") +} -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files \(the "Software"\), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +pub enum LicenseDetectionWatcher { + Local { + is_open_source_rx: watch::Receiver, + _is_open_source_task: Task<()>, + _worktree_subscription: Subscription, + }, + SingleFile, + Remote, +} -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software\. +impl LicenseDetectionWatcher { + pub fn new(worktree: &Entity, cx: &mut App) -> Self { + let worktree_ref = worktree.read(cx); + if worktree_ref.is_single_file() { + return Self::SingleFile; + } -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT\. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE\.$ -"#; + let (files_to_check_tx, mut files_to_check_rx) = futures::channel::mpsc::unbounded(); -const ISC_LICENSE_REGEX: &str = r#" -^ISC License + let Worktree::Local(local_worktree) = worktree_ref else { + return Self::Remote; + }; + let fs = local_worktree.fs().clone(); + let worktree_abs_path = local_worktree.abs_path().clone(); -Copyright.*? + let options = ChildEntriesOptions { + include_files: true, + include_dirs: false, + include_ignored: true, + }; + for top_file in local_worktree.child_entries_with_options(Path::new(""), options) { + let path_bytes = top_file.path.as_os_str().as_encoded_bytes(); + if top_file.is_created() && LICENSE_FILE_NAME_REGEX.is_match(path_bytes) { + let rel_path = top_file.path.clone(); + files_to_check_tx.unbounded_send(rel_path).ok(); + } + } -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies\. + let _worktree_subscription = + cx.subscribe(worktree, move |_worktree, event, _cx| match event { + worktree::Event::UpdatedEntries(updated_entries) => { + for updated_entry in updated_entries.iter() { + let rel_path = &updated_entry.0; + let path_bytes = rel_path.as_os_str().as_encoded_bytes(); + if LICENSE_FILE_NAME_REGEX.is_match(path_bytes) { + files_to_check_tx.unbounded_send(rel_path.clone()).ok(); + } + } + } + worktree::Event::DeletedEntry(_) | worktree::Event::UpdatedGitRepositories(_) => {} + }); -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS\. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE\.$ -"#; + let (mut is_open_source_tx, is_open_source_rx) = watch::channel_with::(false); -const UPL_LICENSE_REGEX: &str = r#" -Copyright.*? + let _is_open_source_task = cx.background_spawn(async move { + let mut eligible_licenses = BTreeSet::new(); + while let Some(rel_path) = files_to_check_rx.next().await { + let abs_path = worktree_abs_path.join(&rel_path); + let was_open_source = !eligible_licenses.is_empty(); + if Self::is_path_eligible(&fs, abs_path).await.unwrap_or(false) { + eligible_licenses.insert(rel_path); + } else { + eligible_licenses.remove(&rel_path); + } + let is_open_source = !eligible_licenses.is_empty(); + if is_open_source != was_open_source { + *is_open_source_tx.borrow_mut() = is_open_source; + } + } + }); -The Universal Permissive License.*? + Self::Local { + is_open_source_rx, + _is_open_source_task, + _worktree_subscription, + } + } -Subject to the condition set forth below, permission is hereby granted to any person -obtaining a copy of this software, associated documentation and/or data \(collectively -the "Software"\), free of charge and under any and all copyright rights in the -Software, and any and all patent rights owned or freely licensable by each licensor -hereunder covering either \(i\) the unmodified Software as contributed to or provided -by such licensor, or \(ii\) the Larger Works \(as defined below\), to deal in both + async fn is_path_eligible(fs: &Arc, abs_path: PathBuf) -> Option { + log::info!("checking if `{abs_path:?}` is an open source license"); + // Resolve symlinks so that the file size from metadata is correct. + let Some(abs_path) = fs.canonicalize(&abs_path).await.ok() else { + log::info!( + "`{abs_path:?}` license file probably deleted (error canonicalizing the path)" + ); + return None; + }; + let metadata = fs.metadata(&abs_path).await.log_err()??; + // If the license file is >32kb it's unlikely to legitimately match any eligible license. + if metadata.len > 32768 { + return None; + } + let text = fs.load(&abs_path).await.log_err()?; + let is_eligible = is_license_eligible_for_data_collection(&text); + if is_eligible { + log::info!( + "`{abs_path:?}` matches a license that is eligible for data collection (if enabled)" + ); + } else { + log::info!( + "`{abs_path:?}` does not match a license that is eligible for data collection" + ); + } + Some(is_eligible) + } -\(a\) the Software, and - -\(b\) any piece of software and/or hardware listed in the lrgrwrks\.txt file if one is - included with the Software \(each a "Larger Work" to which the Software is - contributed by such licensors\), - -without restriction, including without limitation the rights to copy, create -derivative works of, display, perform, and distribute the Software and make, use, -sell, offer for sale, import, export, have made, and have sold the Software and the -Larger Work\(s\), and to sublicense the foregoing rights on either these or other -terms\. - -This license is subject to the following condition: - -The above copyright notice and either this complete permission notice or at a minimum -a reference to the UPL must be included in all copies or substantial portions of the -Software\. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, -INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT\. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF -CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE -OR THE USE OR OTHER DEALINGS IN THE SOFTWARE\.$ -"#; + /// Answers false until we find out it's open source + pub fn is_project_open_source(&self) -> bool { + match self { + Self::Local { + is_open_source_rx, .. + } => *is_open_source_rx.borrow(), + Self::SingleFile | Self::Remote => false, + } + } +} #[cfg(test)] mod tests { - use unindent::unindent; - use crate::is_license_eligible_for_data_collection; + use fs::FakeFs; + use gpui::TestAppContext; + use serde_json::json; + use settings::{Settings as _, SettingsStore}; + use unindent::unindent; + use worktree::WorktreeSettings; + + use super::*; + + const MIT_LICENSE: &str = include_str!("license_detection/mit-text"); + const APACHE_LICENSE: &str = include_str!("license_detection/apache-text"); #[test] fn test_mit_positive_detection() { - let example_license = unindent( - r#" - MIT License - - Copyright (c) 2024 John Doe - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - "# - .trim(), - ); - - assert!(is_license_eligible_for_data_collection(&example_license)); - - let example_license = unindent( - r#" - The MIT License (MIT) - - Copyright (c) 2019 John Doe - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - "# - .trim(), - ); - - assert!(is_license_eligible_for_data_collection(&example_license)); + assert!(is_license_eligible_for_data_collection(&MIT_LICENSE)); } #[test] fn test_mit_negative_detection() { - let example_license = unindent( - r#" - MIT License + let example_license = format!( + r#"{MIT_LICENSE} - Copyright (c) 2024 John Doe - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - - This project is dual licensed under the MIT License and the Apache License, Version 2.0. - "# - .trim(), + This project is dual licensed under the MIT License and the Apache License, Version 2.0."# ); - assert!(!is_license_eligible_for_data_collection(&example_license)); } @@ -351,4 +360,307 @@ mod tests { assert!(!is_license_eligible_for_data_collection(&example_license)); } + + #[test] + fn test_apache_positive_detection() { + assert!(is_license_eligible_for_data_collection(APACHE_LICENSE)); + + let license_with_appendix = format!( + r#"{APACHE_LICENSE} + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License."# + ); + assert!(is_license_eligible_for_data_collection( + &license_with_appendix + )); + + // Sometimes people fill in the appendix with copyright info. + let license_with_copyright = license_with_appendix.replace( + "Copyright [yyyy] [name of copyright owner]", + "Copyright 2025 John Doe", + ); + assert!(license_with_copyright != license_with_appendix); + assert!(is_license_eligible_for_data_collection( + &license_with_copyright + )); + } + + #[test] + fn test_apache_negative_detection() { + assert!(!is_license_eligible_for_data_collection(&format!( + "{APACHE_LICENSE}\n\nThe terms in this license are void if P=NP." + ))); + } + + #[test] + fn test_license_file_name_regex() { + // Test basic license file names + assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE")); + assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENCE")); + assert!(LICENSE_FILE_NAME_REGEX.is_match(b"license")); + assert!(LICENSE_FILE_NAME_REGEX.is_match(b"licence")); + + // Test with extensions + assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE.txt")); + assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE.md")); + assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENCE.txt")); + assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENCE.md")); + + // Test with specific license types + assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE-APACHE")); + assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE-MIT")); + assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE.MIT")); + assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE_MIT")); + assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE-ISC")); + assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE-UPL")); + + // Test combinations + assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE-MIT.txt")); + assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENCE.ISC.md")); + assert!(LICENSE_FILE_NAME_REGEX.is_match(b"license_upl")); + + // Test case insensitive + assert!(LICENSE_FILE_NAME_REGEX.is_match(b"License")); + assert!(LICENSE_FILE_NAME_REGEX.is_match(b"license-mit.TXT")); + assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENCE_isc.MD")); + + // Test edge cases that should match + assert!(LICENSE_FILE_NAME_REGEX.is_match(b"license.mit")); + assert!(LICENSE_FILE_NAME_REGEX.is_match(b"licence-upl.txt")); + + // Test non-matching patterns + assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"COPYING")); + assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE.html")); + assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"MYLICENSE")); + assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"src/LICENSE")); + assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE.old")); + assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE-GPL")); + assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"LICENSEABC")); + assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"")); + } + + #[test] + fn test_canonicalize_license_text() { + // Test basic whitespace normalization + let input = "Line 1\n Line 2 \n\n\n Line 3 "; + let expected = "Line 1 Line 2\n\nLine 3"; + assert_eq!(canonicalize_license_text(input), expected); + + // Test paragraph separation + let input = "Paragraph 1\nwith multiple lines\n\n\n\nParagraph 2\nwith more lines"; + let expected = "Paragraph 1 with multiple lines\n\nParagraph 2 with more lines"; + assert_eq!(canonicalize_license_text(input), expected); + + // Test empty paragraphs are filtered out + let input = "\n\n\nParagraph 1\n\n\n \n\n\nParagraph 2\n\n\n"; + let expected = "Paragraph 1\n\nParagraph 2"; + assert_eq!(canonicalize_license_text(input), expected); + + // Test single line + let input = " Single line with spaces "; + let expected = "Single line with spaces"; + assert_eq!(canonicalize_license_text(input), expected); + + // Test multiple consecutive spaces within lines + let input = "Word1 Word2\n\nWord3 Word4"; + let expected = "Word1 Word2\n\nWord3 Word4"; + assert_eq!(canonicalize_license_text(input), expected); + + // Test tabs and mixed whitespace + let input = "Word1\t\tWord2\n\n Word3\r\n\r\n\r\nWord4 "; + let expected = "Word1 Word2\n\nWord3\n\nWord4"; + assert_eq!(canonicalize_license_text(input), expected); + } + + #[test] + fn test_license_detection_canonicalizes_whitespace() { + let mit_with_weird_spacing = unindent( + r#" + MIT License + + + Copyright (c) 2024 John Doe + + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + "# + .trim(), + ); + + assert!(is_license_eligible_for_data_collection( + &mit_with_weird_spacing + )); + } + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + WorktreeSettings::register(cx); + }); + } + + #[gpui::test] + async fn test_watcher_single_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree("/root", json!({ "main.rs": "fn main() {}" })) + .await; + + let worktree = Worktree::local( + Path::new("/root/main.rs"), + true, + fs.clone(), + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + + let watcher = cx.update(|cx| LicenseDetectionWatcher::new(&worktree, cx)); + assert!(matches!(watcher, LicenseDetectionWatcher::SingleFile)); + assert!(!watcher.is_project_open_source()); + } + + #[gpui::test] + async fn test_watcher_updates_on_changes(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree("/root", json!({ "main.rs": "fn main() {}" })) + .await; + + let worktree = Worktree::local( + Path::new("/root"), + true, + fs.clone(), + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + + let watcher = cx.update(|cx| LicenseDetectionWatcher::new(&worktree, cx)); + assert!(matches!(watcher, LicenseDetectionWatcher::Local { .. })); + assert!(!watcher.is_project_open_source()); + + fs.write(Path::new("/root/LICENSE-MIT"), MIT_LICENSE.as_bytes()) + .await + .unwrap(); + + cx.background_executor.run_until_parked(); + assert!(watcher.is_project_open_source()); + + fs.write(Path::new("/root/LICENSE-APACHE"), APACHE_LICENSE.as_bytes()) + .await + .unwrap(); + + cx.background_executor.run_until_parked(); + assert!(watcher.is_project_open_source()); + + fs.write(Path::new("/root/LICENSE-MIT"), "Nevermind".as_bytes()) + .await + .unwrap(); + + // Still considered open source as LICENSE-APACHE is present + cx.background_executor.run_until_parked(); + assert!(watcher.is_project_open_source()); + + fs.write( + Path::new("/root/LICENSE-APACHE"), + "Also nevermind".as_bytes(), + ) + .await + .unwrap(); + + cx.background_executor.run_until_parked(); + assert!(!watcher.is_project_open_source()); + } + + #[gpui::test] + async fn test_watcher_initially_opensource_and_then_deleted(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + json!({ "main.rs": "fn main() {}", "LICENSE-MIT": MIT_LICENSE }), + ) + .await; + + let worktree = Worktree::local( + Path::new("/root"), + true, + fs.clone(), + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + + let watcher = cx.update(|cx| LicenseDetectionWatcher::new(&worktree, cx)); + assert!(matches!(watcher, LicenseDetectionWatcher::Local { .. })); + + cx.background_executor.run_until_parked(); + assert!(watcher.is_project_open_source()); + + fs.remove_file( + Path::new("/root/LICENSE-MIT"), + fs::RemoveOptions { + recursive: false, + ignore_if_not_exists: false, + }, + ) + .await + .unwrap(); + + cx.background_executor.run_until_parked(); + assert!(!watcher.is_project_open_source()); + } } diff --git a/crates/zeta/src/license_detection/apache-text b/crates/zeta/src/license_detection/apache-text new file mode 100644 index 0000000000..dd5b3a58aa --- /dev/null +++ b/crates/zeta/src/license_detection/apache-text @@ -0,0 +1,174 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. diff --git a/crates/zeta/src/license_detection/apache.regex b/crates/zeta/src/license_detection/apache.regex new file mode 100644 index 0000000000..e200e063c9 --- /dev/null +++ b/crates/zeta/src/license_detection/apache.regex @@ -0,0 +1,201 @@ + ^Apache License + Version 2\.0, January 2004 + http://www\.apache\.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1\. Definitions\. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document\. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License\. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity\. For the purposes of this definition, + "control" means \(i\) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or \(ii\) ownership of fifty percent \(50%\) or more of the + outstanding shares, or \(iii\) beneficial ownership of such entity\. + + "You" \(or "Your"\) shall mean an individual or Legal Entity + exercising permissions granted by this License\. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files\. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types\. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + \(an example is provided in the Appendix below\)\. + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on \(or derived from\) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship\. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link \(or bind by name\) to the interfaces of, + the Work and Derivative Works thereof\. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner\. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution\." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work\. + + 2\. Grant of Copyright License\. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non\-exclusive, no\-charge, royalty\-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form\. + + 3\. Grant of Patent License\. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non\-exclusive, no\-charge, royalty\-free, irrevocable + \(except as stated in this section\) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution\(s\) alone or by combination of their Contribution\(s\) + with the Work to which such Contribution\(s\) was submitted\. If You + institute patent litigation against any entity \(including a + cross\-claim or counterclaim in a lawsuit\) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed\. + + 4\. Redistribution\. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + \(a\) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + \(b\) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + \(c\) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + \(d\) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third\-party notices normally appear\. The contents + of the NOTICE file are for informational purposes only and + do not modify the License\. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License\. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License\. + + 5\. Submission of Contributions\. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions\. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions\. + + 6\. Trademarks\. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file\. + + 7\. Disclaimer of Warranty\. Unless required by applicable law or + agreed to in writing, Licensor provides the Work \(and each + Contributor provides its Contributions\) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON\-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE\. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License\. + + 8\. Limitation of Liability\. In no event and under no legal theory, + whether in tort \(including negligence\), contract, or otherwise, + unless required by applicable law \(such as deliberate and grossly + negligent acts\) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work \(including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses\), even if such Contributor + has been advised of the possibility of such damages\. + + 9\. Accepting Warranty or Additional Liability\. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License\. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability\.(:? + + END OF TERMS AND CONDITIONS)?(:? + + APPENDIX: How to apply the Apache License to your work\. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "\[\]" + replaced with your own identifying information\. \(Don't include + the brackets!\) The text should be enclosed in the appropriate + comment syntax for the file format\. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third\-party archives\.)?(:? + + Copyright .*)?(:? + + Licensed under the Apache License, Version 2\.0 \(the "License"\); + you may not use this file except in compliance with the License\. + You may obtain a copy of the License at + + http://www\.apache\.org/licenses/LICENSE\-2\.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied\. + See the License for the specific language governing permissions and + limitations under the License\.)?$ diff --git a/crates/zeta/src/license_detection/isc.regex b/crates/zeta/src/license_detection/isc.regex new file mode 100644 index 0000000000..63c6126bce --- /dev/null +++ b/crates/zeta/src/license_detection/isc.regex @@ -0,0 +1,15 @@ +^.*ISC License.* + +Copyright.* + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies\. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS\. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE\.$ diff --git a/crates/zeta/src/license_detection/mit-text b/crates/zeta/src/license_detection/mit-text new file mode 100644 index 0000000000..2b8f73ab0d --- /dev/null +++ b/crates/zeta/src/license_detection/mit-text @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 John Doe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/zeta/src/license_detection/mit.regex b/crates/zeta/src/license_detection/mit.regex new file mode 100644 index 0000000000..deda8f0352 --- /dev/null +++ b/crates/zeta/src/license_detection/mit.regex @@ -0,0 +1,21 @@ +^.*MIT License.* + +Copyright.* + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files \(the "Software"\), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software\. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT\. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE\.$ diff --git a/crates/zeta/src/license_detection/upl.regex b/crates/zeta/src/license_detection/upl.regex new file mode 100644 index 0000000000..34ba2a64c6 --- /dev/null +++ b/crates/zeta/src/license_detection/upl.regex @@ -0,0 +1,35 @@ +^Copyright.* + +The Universal Permissive License.* + +Subject to the condition set forth below, permission is hereby granted to any person +obtaining a copy of this software, associated documentation and/or data \(collectively +the "Software"\), free of charge and under any and all copyright rights in the +Software, and any and all patent rights owned or freely licensable by each licensor +hereunder covering either \(i\) the unmodified Software as contributed to or provided +by such licensor, or \(ii\) the Larger Works \(as defined below\), to deal in both + +\(a\) the Software, and + +\(b\) any piece of software and/or hardware listed in the lrgrwrks\.txt file if one is + included with the Software \(each a "Larger Work" to which the Software is + contributed by such licensors\), + +without restriction, including without limitation the rights to copy, create +derivative works of, display, perform, and distribute the Software and make, use, +sell, offer for sale, import, export, have made, and have sold the Software and the +Larger Work\(s\), and to sublicense the foregoing rights on either these or other +terms\. + +This license is subject to the following condition: + +The above copyright notice and either this complete permission notice or at a minimum +a reference to the UPL must be included in all copies or substantial portions of the +Software\. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT\. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF +CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE +OR THE USE OR OTHER DEALINGS IN THE SOFTWARE\.$ diff --git a/crates/zeta/src/zeta.rs b/crates/zeta/src/zeta.rs index 828310a3bd..1ddbd25cb8 100644 --- a/crates/zeta/src/zeta.rs +++ b/crates/zeta/src/zeta.rs @@ -10,8 +10,7 @@ pub(crate) use completion_diff_element::*; use db::kvp::{Dismissable, KEY_VALUE_STORE}; use edit_prediction::DataCollectionState; pub use init::*; -use license_detection::LICENSE_FILES_TO_CHECK; -pub use license_detection::is_license_eligible_for_data_collection; +use license_detection::LicenseDetectionWatcher; pub use rate_completion_modal::*; use anyhow::{Context as _, Result, anyhow}; @@ -33,7 +32,6 @@ use language::{ Anchor, Buffer, BufferSnapshot, EditPreview, OffsetRangeExt, ToOffset, ToPoint, text_diff, }; use language_model::{LlmApiToken, RefreshLlmTokenListener}; -use postage::watch; use project::{Project, ProjectPath}; use release_channel::AppVersion; use settings::WorktreeId; @@ -253,11 +251,10 @@ impl Zeta { this.update(cx, move |this, cx| { if let Some(worktree) = worktree { - worktree.update(cx, |worktree, cx| { - this.license_detection_watchers - .entry(worktree.id()) - .or_insert_with(|| Rc::new(LicenseDetectionWatcher::new(worktree, cx))); - }); + let worktree_id = worktree.read(cx).id(); + this.license_detection_watchers + .entry(worktree_id) + .or_insert_with(|| Rc::new(LicenseDetectionWatcher::new(&worktree, cx))); } }); @@ -1104,59 +1101,6 @@ pub struct ZedUpdateRequiredError { minimum_version: SemanticVersion, } -struct LicenseDetectionWatcher { - is_open_source_rx: watch::Receiver, - _is_open_source_task: Task<()>, -} - -impl LicenseDetectionWatcher { - pub fn new(worktree: &Worktree, cx: &mut Context) -> Self { - let (mut is_open_source_tx, is_open_source_rx) = watch::channel_with::(false); - - // Check if worktree is a single file, if so we do not need to check for a LICENSE file - let task = if worktree.abs_path().is_file() { - Task::ready(()) - } else { - let loaded_files = LICENSE_FILES_TO_CHECK - .iter() - .map(Path::new) - .map(|file| worktree.load_file(file, cx)) - .collect::>(); - - cx.background_spawn(async move { - for loaded_file in loaded_files.into_iter() { - let Ok(loaded_file) = loaded_file.await else { - continue; - }; - - let path = &loaded_file.file.path; - if is_license_eligible_for_data_collection(&loaded_file.text) { - log::info!("detected '{path:?}' as open source license"); - *is_open_source_tx.borrow_mut() = true; - } else { - log::info!("didn't detect '{path:?}' as open source license"); - } - - // stop on the first license that successfully read - return; - } - - log::debug!("didn't find a license file to check, assuming closed source"); - }) - }; - - Self { - is_open_source_rx, - _is_open_source_task: task, - } - } - - /// Answers false until we find out it's open source - pub fn is_project_open_source(&self) -> bool { - *self.is_open_source_rx.borrow() - } -} - fn common_prefix, T2: Iterator>(a: T1, b: T2) -> usize { a.zip(b) .take_while(|(a, b)| a == b) From 4c5058c077981d20c886332480a9e861de63d430 Mon Sep 17 00:00:00 2001 From: Julia Ryan Date: Sat, 9 Aug 2025 05:28:36 -0500 Subject: [PATCH 030/109] Fix uploading mac dsyms (#35904) I'm not sure we actually want to be using `debug-info=unpacked` and then running `dsymutil` with `--flat`, but for now the minimal change to get this working is to manually specify the flattened, uncompressed debug info file for upload, which in turn will cause `sentry-cli` to pick up on source-info for the zed binary. I think in the future we should switch to `packed` debug info, both for the zed binary _and_ the remote server, and then we can tar up the better supported `dSYM` folder format rather than the flat dwarf version. Release Notes: - N/A --- script/bundle-mac | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/script/bundle-mac b/script/bundle-mac index b2be573235..f2a5bf313d 100755 --- a/script/bundle-mac +++ b/script/bundle-mac @@ -207,7 +207,7 @@ function prepare_binaries() { rm -f target/${architecture}/${target_dir}/Zed.dwarf.gz echo "Gzipping dSYMs for $architecture" - gzip -f target/${architecture}/${target_dir}/Zed.dwarf + gzip -kf target/${architecture}/${target_dir}/Zed.dwarf echo "Uploading dSYMs${architecture} for $architecture to by-uuid/${uuid}.dwarf.gz" upload_to_blob_store_public \ @@ -367,19 +367,25 @@ else gzip -f --stdout --best target/aarch64-apple-darwin/release/remote_server > target/zed-remote-server-macos-aarch64.gz fi -# Upload debug info to sentry.io -if ! command -v sentry-cli >/dev/null 2>&1; then - echo "sentry-cli not found. skipping sentry upload." - echo "install with: 'curl -sL https://sentry.io/get-cli | bash'" -else +function upload_debug_info() { + architecture=$1 if [[ -n "${SENTRY_AUTH_TOKEN:-}" ]]; then echo "Uploading zed debug symbols to sentry..." # note: this uploads the unstripped binary which is needed because it contains # .eh_frame data for stack unwinindg. see https://github.com/getsentry/symbolic/issues/783 sentry-cli debug-files upload --include-sources --wait -p zed -o zed-dev \ - "target/x86_64-apple-darwin/${target_dir}/" \ - "target/aarch64-apple-darwin/${target_dir}/" + "target/${architecture}/${target_dir}/zed" \ + "target/${architecture}/${target_dir}/remote_server" \ + "target/${architecture}/${target_dir}/zed.dwarf" else echo "missing SENTRY_AUTH_TOKEN. skipping sentry upload." fi +} + +if command -v sentry-cli >/dev/null 2>&1; then + upload_debug_info "aarch64-apple-darwin" + upload_debug_info "x86_64-apple-darwin" +else + echo "sentry-cli not found. skipping sentry upload." + echo "install with: 'curl -sL https://sentry.io/get-cli | bash'" fi From c91fb4caf4c8a221a5e17b7b18d86ba203311c04 Mon Sep 17 00:00:00 2001 From: Julia Ryan Date: Sat, 9 Aug 2025 05:37:28 -0500 Subject: [PATCH 031/109] Add sentry release step to ci (#35911) This should allow us to associate sha's from crashes and generate links to github source in sentry. Release Notes: - N/A --- .github/workflows/ci.yml | 9 +++++++++ .github/workflows/release_nightly.yml | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 928c47a4a7..3b70271e57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -851,3 +851,12 @@ jobs: run: gh release edit "$GITHUB_REF_NAME" --draft=false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Create Sentry release + uses: getsentry/action-release@526942b68292201ac6bbb99b9a0747d4abee354c # v3 + env: + SENTRY_ORG: zed-dev + SENTRY_PROJECT: zed + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + with: + environment: production diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index ed9f4c8450..0cc6737a45 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -316,3 +316,12 @@ jobs: git config user.email github-actions@github.com git tag -f nightly git push origin nightly --force + + - name: Create Sentry release + uses: getsentry/action-release@526942b68292201ac6bbb99b9a0747d4abee354c # v3 + env: + SENTRY_ORG: zed-dev + SENTRY_PROJECT: zed + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + with: + environment: production From 7862c0c94588a85809e5e31e06ca1dc69de0afe3 Mon Sep 17 00:00:00 2001 From: Julia Ryan Date: Sat, 9 Aug 2025 06:20:38 -0500 Subject: [PATCH 032/109] Add more info to crash reports (#35914) None of this is new info, we're just pulling more things out of the panic message to send with the minidump. We do want to add more fields like gpu version which will come in a subsequent change. Release Notes: - N/A --- crates/zed/src/reliability.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/zed/src/reliability.rs b/crates/zed/src/reliability.rs index 53539699cc..fde44344b1 100644 --- a/crates/zed/src/reliability.rs +++ b/crates/zed/src/reliability.rs @@ -603,11 +603,31 @@ async fn upload_minidump( .text("platform", "rust"); if let Some(panic) = panic { form = form + .text("sentry[tags][channel]", panic.release_channel.clone()) + .text("sentry[tags][version]", panic.app_version.clone()) + .text("sentry[context][os][name]", panic.os_name.clone()) .text( + "sentry[context][device][architecture]", + panic.architecture.clone(), + ) + .text("sentry[logentry][formatted]", panic.payload.clone()); + + if let Some(sha) = panic.app_commit_sha.clone() { + form = form.text("sentry[release]", sha) + } else { + form = form.text( "sentry[release]", format!("{}-{}", panic.release_channel, panic.app_version), ) - .text("sentry[logentry][formatted]", panic.payload.clone()); + } + if let Some(v) = panic.os_version.clone() { + form = form.text("sentry[context][os][release]", v); + } + if let Some(location) = panic.location_data.as_ref() { + form = form.text("span", format!("{}:{}", location.file, location.line)) + } + // TODO: add gpu-context, feature-flag-context, and more of device-context like gpu + // name, screen resolution, available ram, device model, etc } let mut response_text = String::new(); From 021681d4563df41cdc04a40cf4ef03c5afe0645a Mon Sep 17 00:00:00 2001 From: Julia Ryan Date: Sat, 9 Aug 2025 06:42:30 -0500 Subject: [PATCH 033/109] Don't generate crash reports on the Dev channel (#35915) We only want minidumps to be generated on actual release builds. Now we avoid spawning crash handler processes for dev builds. To test minidumping you can still set the `ZED_GENERATE_MINIDUMPS` env var which force-enable the feature. Release Notes: - N/A --- Cargo.lock | 1 + crates/crashes/Cargo.toml | 1 + crates/crashes/src/crashes.rs | 13 ++++++++++++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 6f434e8685..1ae4303c71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4014,6 +4014,7 @@ dependencies = [ "log", "minidumper", "paths", + "release_channel", "smol", "workspace-hack", ] diff --git a/crates/crashes/Cargo.toml b/crates/crashes/Cargo.toml index 641a97765a..afb4936b63 100644 --- a/crates/crashes/Cargo.toml +++ b/crates/crashes/Cargo.toml @@ -10,6 +10,7 @@ crash-handler.workspace = true log.workspace = true minidumper.workspace = true paths.workspace = true +release_channel.workspace = true smol.workspace = true workspace-hack.workspace = true diff --git a/crates/crashes/src/crashes.rs b/crates/crashes/src/crashes.rs index cfb4b57d5d..5b9ae0b546 100644 --- a/crates/crashes/src/crashes.rs +++ b/crates/crashes/src/crashes.rs @@ -1,6 +1,7 @@ use crash_handler::CrashHandler; use log::info; use minidumper::{Client, LoopAction, MinidumpBinary}; +use release_channel::{RELEASE_CHANNEL, ReleaseChannel}; use std::{ env, @@ -9,7 +10,7 @@ use std::{ path::{Path, PathBuf}, process::{self, Command}, sync::{ - OnceLock, + LazyLock, OnceLock, atomic::{AtomicBool, Ordering}, }, thread, @@ -22,7 +23,14 @@ pub static CRASH_HANDLER: AtomicBool = AtomicBool::new(false); pub static REQUESTED_MINIDUMP: AtomicBool = AtomicBool::new(false); const CRASH_HANDLER_TIMEOUT: Duration = Duration::from_secs(60); +pub static GENERATE_MINIDUMPS: LazyLock = LazyLock::new(|| { + *RELEASE_CHANNEL != ReleaseChannel::Dev || env::var("ZED_GENERATE_MINIDUMPS").is_ok() +}); + pub async fn init(id: String) { + if !*GENERATE_MINIDUMPS { + return; + } let exe = env::current_exe().expect("unable to find ourselves"); let zed_pid = process::id(); // TODO: we should be able to get away with using 1 crash-handler process per machine, @@ -138,6 +146,9 @@ impl minidumper::ServerHandler for CrashServer { } pub fn handle_panic() { + if !*GENERATE_MINIDUMPS { + return; + } // wait 500ms for the crash handler process to start up // if it's still not there just write panic info and no minidump let retry_frequency = Duration::from_millis(100); From ce39644cbd5e52efa80dfe4d320927afea13ec4b Mon Sep 17 00:00:00 2001 From: Umesh Yadav <23421535+imumesh18@users.noreply.github.com> Date: Sun, 10 Aug 2025 00:55:47 +0530 Subject: [PATCH 034/109] language_models: Add thinking to Mistral Provider (#32476) Tested prompt: John is one of 4 children. The first sister is 4 years old. Next year, the second sister will be twice as old as the first sister. The third sister is two years older than the second sister. The third sister is half the age of her older brother. How old is John? Return your thinking inside Release Notes: - Add thinking to Mistral Provider --------- Signed-off-by: Umesh Yadav Co-authored-by: Peter Tripp --- .../language_models/src/provider/mistral.rs | 135 +++++++++++------- crates/mistral/src/mistral.rs | 50 +++++-- 2 files changed, 126 insertions(+), 59 deletions(-) diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index 02e53cb99a..4a0d740334 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -47,6 +47,7 @@ pub struct AvailableModel { pub max_completion_tokens: Option, pub supports_tools: Option, pub supports_images: Option, + pub supports_thinking: Option, } pub struct MistralLanguageModelProvider { @@ -215,6 +216,7 @@ impl LanguageModelProvider for MistralLanguageModelProvider { max_completion_tokens: model.max_completion_tokens, supports_tools: model.supports_tools, supports_images: model.supports_images, + supports_thinking: model.supports_thinking, }, ); } @@ -366,11 +368,7 @@ impl LanguageModel for MistralLanguageModel { LanguageModelCompletionError, >, > { - let request = into_mistral( - request, - self.model.id().to_string(), - self.max_output_tokens(), - ); + let request = into_mistral(request, self.model.clone(), self.max_output_tokens()); let stream = self.stream_completion(request, cx); async move { @@ -384,7 +382,7 @@ impl LanguageModel for MistralLanguageModel { pub fn into_mistral( request: LanguageModelRequest, - model: String, + model: mistral::Model, max_output_tokens: Option, ) -> mistral::Request { let stream = true; @@ -401,13 +399,20 @@ pub fn into_mistral( .push_part(mistral::MessagePart::Text { text: text.clone() }); } MessageContent::Image(image_content) => { - message_content.push_part(mistral::MessagePart::ImageUrl { - image_url: image_content.to_base64_url(), - }); + if model.supports_images() { + message_content.push_part(mistral::MessagePart::ImageUrl { + image_url: image_content.to_base64_url(), + }); + } } MessageContent::Thinking { text, .. } => { - message_content - .push_part(mistral::MessagePart::Text { text: text.clone() }); + if model.supports_thinking() { + message_content.push_part(mistral::MessagePart::Thinking { + thinking: vec![mistral::ThinkingPart::Text { + text: text.clone(), + }], + }); + } } MessageContent::RedactedThinking(_) => {} MessageContent::ToolUse(_) => { @@ -437,12 +442,28 @@ pub fn into_mistral( Role::Assistant => { for content in &message.content { match content { - MessageContent::Text(text) | MessageContent::Thinking { text, .. } => { + MessageContent::Text(text) => { messages.push(mistral::RequestMessage::Assistant { - content: Some(text.clone()), + content: Some(mistral::MessageContent::Plain { + content: text.clone(), + }), tool_calls: Vec::new(), }); } + MessageContent::Thinking { text, .. } => { + if model.supports_thinking() { + messages.push(mistral::RequestMessage::Assistant { + content: Some(mistral::MessageContent::Multipart { + content: vec![mistral::MessagePart::Thinking { + thinking: vec![mistral::ThinkingPart::Text { + text: text.clone(), + }], + }], + }), + tool_calls: Vec::new(), + }); + } + } MessageContent::RedactedThinking(_) => {} MessageContent::Image(_) => {} MessageContent::ToolUse(tool_use) => { @@ -477,11 +498,26 @@ pub fn into_mistral( Role::System => { for content in &message.content { match content { - MessageContent::Text(text) | MessageContent::Thinking { text, .. } => { + MessageContent::Text(text) => { messages.push(mistral::RequestMessage::System { - content: text.clone(), + content: mistral::MessageContent::Plain { + content: text.clone(), + }, }); } + MessageContent::Thinking { text, .. } => { + if model.supports_thinking() { + messages.push(mistral::RequestMessage::System { + content: mistral::MessageContent::Multipart { + content: vec![mistral::MessagePart::Thinking { + thinking: vec![mistral::ThinkingPart::Text { + text: text.clone(), + }], + }], + }, + }); + } + } MessageContent::RedactedThinking(_) => {} MessageContent::Image(_) | MessageContent::ToolUse(_) @@ -494,37 +530,8 @@ pub fn into_mistral( } } - // The Mistral API requires that tool messages be followed by assistant messages, - // not user messages. When we have a tool->user sequence in the conversation, - // we need to insert a placeholder assistant message to maintain proper conversation - // flow and prevent API errors. This is a Mistral-specific requirement that differs - // from other language model APIs. - let messages = { - let mut fixed_messages = Vec::with_capacity(messages.len()); - let mut messages_iter = messages.into_iter().peekable(); - - while let Some(message) = messages_iter.next() { - let is_tool_message = matches!(message, mistral::RequestMessage::Tool { .. }); - fixed_messages.push(message); - - // Insert assistant message between tool and user messages - if is_tool_message { - if let Some(next_msg) = messages_iter.peek() { - if matches!(next_msg, mistral::RequestMessage::User { .. }) { - fixed_messages.push(mistral::RequestMessage::Assistant { - content: Some(" ".to_string()), - tool_calls: Vec::new(), - }); - } - } - } - } - - fixed_messages - }; - mistral::Request { - model, + model: model.id().to_string(), messages, stream, max_tokens: max_output_tokens, @@ -595,8 +602,38 @@ impl MistralEventMapper { }; let mut events = Vec::new(); - if let Some(content) = choice.delta.content.clone() { - events.push(Ok(LanguageModelCompletionEvent::Text(content))); + if let Some(content) = choice.delta.content.as_ref() { + match content { + mistral::MessageContentDelta::Text(text) => { + events.push(Ok(LanguageModelCompletionEvent::Text(text.clone()))); + } + mistral::MessageContentDelta::Parts(parts) => { + for part in parts { + match part { + mistral::MessagePart::Text { text } => { + events.push(Ok(LanguageModelCompletionEvent::Text(text.clone()))); + } + mistral::MessagePart::Thinking { thinking } => { + for tp in thinking.iter().cloned() { + match tp { + mistral::ThinkingPart::Text { text } => { + events.push(Ok( + LanguageModelCompletionEvent::Thinking { + text, + signature: None, + }, + )); + } + } + } + } + mistral::MessagePart::ImageUrl { .. } => { + // We currently don't emit a separate event for images in responses. + } + } + } + } + } } if let Some(tool_calls) = choice.delta.tool_calls.as_ref() { @@ -908,7 +945,7 @@ mod tests { thinking_allowed: true, }; - let mistral_request = into_mistral(request, "mistral-small-latest".into(), None); + let mistral_request = into_mistral(request, mistral::Model::MistralSmallLatest, None); assert_eq!(mistral_request.model, "mistral-small-latest"); assert_eq!(mistral_request.temperature, Some(0.5)); @@ -941,7 +978,7 @@ mod tests { thinking_allowed: true, }; - let mistral_request = into_mistral(request, "pixtral-12b-latest".into(), None); + let mistral_request = into_mistral(request, mistral::Model::Pixtral12BLatest, None); assert_eq!(mistral_request.messages.len(), 1); assert!(matches!( diff --git a/crates/mistral/src/mistral.rs b/crates/mistral/src/mistral.rs index c466a598a0..5b4d05377c 100644 --- a/crates/mistral/src/mistral.rs +++ b/crates/mistral/src/mistral.rs @@ -86,6 +86,7 @@ pub enum Model { max_completion_tokens: Option, supports_tools: Option, supports_images: Option, + supports_thinking: Option, }, } @@ -214,6 +215,16 @@ impl Model { } => supports_images.unwrap_or(false), } } + + pub fn supports_thinking(&self) -> bool { + match self { + Self::MagistralMediumLatest | Self::MagistralSmallLatest => true, + Self::Custom { + supports_thinking, .. + } => supports_thinking.unwrap_or(false), + _ => false, + } + } } #[derive(Debug, Serialize, Deserialize)] @@ -288,7 +299,9 @@ pub enum ToolChoice { #[serde(tag = "role", rename_all = "lowercase")] pub enum RequestMessage { Assistant { - content: Option, + #[serde(flatten)] + #[serde(default, skip_serializing_if = "Option::is_none")] + content: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] tool_calls: Vec, }, @@ -297,7 +310,8 @@ pub enum RequestMessage { content: MessageContent, }, System { - content: String, + #[serde(flatten)] + content: MessageContent, }, Tool { content: String, @@ -305,7 +319,7 @@ pub enum RequestMessage { }, } -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] #[serde(untagged)] pub enum MessageContent { #[serde(rename = "content")] @@ -346,11 +360,21 @@ impl MessageContent { } } -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] #[serde(tag = "type", rename_all = "snake_case")] pub enum MessagePart { Text { text: String }, ImageUrl { image_url: String }, + Thinking { thinking: Vec }, +} + +// Backwards-compatibility alias for provider code that refers to ContentPart +pub type ContentPart = MessagePart; + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ThinkingPart { + Text { text: String }, } #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] @@ -418,24 +442,30 @@ pub struct StreamChoice { pub finish_reason: Option, } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct StreamDelta { pub role: Option, - pub content: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_calls: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reasoning_content: Option, } -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] +#[serde(untagged)] +pub enum MessageContentDelta { + Text(String), + Parts(Vec), +} + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] pub struct ToolCallChunk { pub index: usize, pub id: Option, pub function: Option, } -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] pub struct FunctionChunk { pub name: Option, pub arguments: Option, From 5901aec40a154990451e7a0d5b5752695c78581a Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Sat, 9 Aug 2025 23:40:44 +0200 Subject: [PATCH 035/109] agent2: Remove model param from Thread::send method (#35936) It instead uses the currently selected model Release Notes: - N/A --- crates/agent2/src/agent.rs | 3 +-- crates/agent2/src/tests/mod.rs | 33 ++++++++++++++------------------- crates/agent2/src/thread.rs | 2 +- 3 files changed, 16 insertions(+), 22 deletions(-) diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index df061cd5ed..892469db47 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -491,8 +491,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection { // Send to thread log::info!("Sending message to thread with model: {:?}", model.name()); - let mut response_stream = - thread.update(cx, |thread, cx| thread.send(model, message, cx))?; + let mut response_stream = thread.update(cx, |thread, cx| thread.send(message, cx))?; // Handle response stream and forward to session.acp_thread while let Some(result) = response_stream.next().await { diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index 273da1dae5..6e0dc86091 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -29,11 +29,11 @@ use test_tools::*; #[gpui::test] #[ignore = "can't run on CI yet"] async fn test_echo(cx: &mut TestAppContext) { - let ThreadTest { model, thread, .. } = setup(cx, TestModel::Sonnet4).await; + let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await; let events = thread .update(cx, |thread, cx| { - thread.send(model.clone(), "Testing: Reply with 'Hello'", cx) + thread.send("Testing: Reply with 'Hello'", cx) }) .collect() .await; @@ -49,12 +49,11 @@ 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 { model, thread, .. } = setup(cx, TestModel::Sonnet4Thinking).await; + let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4Thinking).await; let events = thread .update(cx, |thread, cx| { thread.send( - model.clone(), indoc! {" Testing: @@ -91,7 +90,7 @@ async fn test_system_prompt(cx: &mut TestAppContext) { project_context.borrow_mut().shell = "test-shell".into(); thread.update(cx, |thread, _| thread.add_tool(EchoTool)); - thread.update(cx, |thread, cx| thread.send(model.clone(), "abc", cx)); + thread.update(cx, |thread, cx| thread.send("abc", cx)); cx.run_until_parked(); let mut pending_completions = fake_model.pending_completions(); assert_eq!( @@ -121,14 +120,13 @@ async fn test_system_prompt(cx: &mut TestAppContext) { #[gpui::test] #[ignore = "can't run on CI yet"] async fn test_basic_tool_calls(cx: &mut TestAppContext) { - let ThreadTest { model, thread, .. } = setup(cx, TestModel::Sonnet4).await; + let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await; // Test a tool call that's likely to complete *before* streaming stops. let events = thread .update(cx, |thread, cx| { thread.add_tool(EchoTool); thread.send( - model.clone(), "Now test the echo tool with 'Hello'. Does it work? Say 'Yes' or 'No'.", cx, ) @@ -143,7 +141,6 @@ async fn test_basic_tool_calls(cx: &mut TestAppContext) { thread.remove_tool(&AgentTool::name(&EchoTool)); thread.add_tool(DelayTool); thread.send( - model.clone(), "Now call the delay tool with 200ms. When the timer goes off, then you echo the output of the tool.", cx, ) @@ -171,12 +168,12 @@ async fn test_basic_tool_calls(cx: &mut TestAppContext) { #[gpui::test] #[ignore = "can't run on CI yet"] async fn test_streaming_tool_calls(cx: &mut TestAppContext) { - let ThreadTest { model, thread, .. } = setup(cx, TestModel::Sonnet4).await; + let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await; // Test a tool call that's likely to complete *before* streaming stops. let mut events = thread.update(cx, |thread, cx| { thread.add_tool(WordListTool); - thread.send(model.clone(), "Test the word_list tool.", cx) + thread.send("Test the word_list tool.", cx) }); let mut saw_partial_tool_use = false; @@ -223,7 +220,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { let mut events = thread.update(cx, |thread, cx| { thread.add_tool(ToolRequiringPermission); - thread.send(model.clone(), "abc", cx) + thread.send("abc", cx) }); cx.run_until_parked(); fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( @@ -290,7 +287,7 @@ async fn test_tool_hallucination(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; let fake_model = model.as_fake(); - let mut events = thread.update(cx, |thread, cx| thread.send(model.clone(), "abc", cx)); + let mut events = thread.update(cx, |thread, cx| thread.send("abc", cx)); cx.run_until_parked(); fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { @@ -375,14 +372,13 @@ async fn next_tool_call_authorization( #[gpui::test] #[ignore = "can't run on CI yet"] async fn test_concurrent_tool_calls(cx: &mut TestAppContext) { - let ThreadTest { model, thread, .. } = setup(cx, TestModel::Sonnet4).await; + let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await; // Test concurrent tool calls with different delay times let events = thread .update(cx, |thread, cx| { thread.add_tool(DelayTool); thread.send( - model.clone(), "Call the delay tool twice in the same message. Once with 100ms. Once with 300ms. When both timers are complete, describe the outputs.", cx, ) @@ -414,13 +410,12 @@ async fn test_concurrent_tool_calls(cx: &mut TestAppContext) { #[gpui::test] #[ignore = "can't run on CI yet"] async fn test_cancellation(cx: &mut TestAppContext) { - let ThreadTest { model, thread, .. } = setup(cx, TestModel::Sonnet4).await; + let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await; let mut events = thread.update(cx, |thread, cx| { thread.add_tool(InfiniteTool); thread.add_tool(EchoTool); thread.send( - model.clone(), "Call the echo tool and then call the infinite tool, then explain their output", cx, ) @@ -466,7 +461,7 @@ async fn test_cancellation(cx: &mut TestAppContext) { // Ensure we can still send a new message after cancellation. let events = thread .update(cx, |thread, cx| { - thread.send(model.clone(), "Testing: reply with 'Hello' then stop.", cx) + thread.send("Testing: reply with 'Hello' then stop.", cx) }) .collect::>() .await; @@ -484,7 +479,7 @@ async fn test_refusal(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.send(model.clone(), "Hello", cx)); + let events = thread.update(cx, |thread, cx| thread.send("Hello", cx)); cx.run_until_parked(); thread.read_with(cx, |thread, _| { assert_eq!( @@ -648,7 +643,7 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { thread.update(cx, |thread, _cx| thread.add_tool(ThinkingTool)); let fake_model = model.as_fake(); - let mut events = thread.update(cx, |thread, cx| thread.send(model.clone(), "Think", cx)); + let mut events = thread.update(cx, |thread, cx| thread.send("Think", cx)); cx.run_until_parked(); // Simulate streaming partial input. diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index f664e0f5d2..8ed200b56b 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -200,11 +200,11 @@ impl Thread { /// The returned channel will report all the occurrences in which the model stops before erroring or ending its turn. pub fn send( &mut self, - model: Arc, content: impl Into, cx: &mut Context, ) -> mpsc::UnboundedReceiver> { let content = content.into(); + let model = self.selected_model.clone(); log::info!("Thread::send called with model: {:?}", model.name()); log::debug!("Thread::send content: {:?}", content); From daa53f276148b6ddb265a67f95de5f9ed7d45e65 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Sat, 9 Aug 2025 23:48:58 +0200 Subject: [PATCH 036/109] Revert "Revert "chore: Bump Rust to 1.89 (#35788)"" (#35937) Reverts zed-industries/zed#35843 Docker image for 1.89 is now up. --- Dockerfile-collab | 2 +- crates/fs/src/fake_git_repo.rs | 4 +-- crates/git/src/repository.rs | 8 +++--- crates/gpui/src/keymap/context.rs | 30 +++++++++++--------- crates/gpui/src/platform/windows/wrapper.rs | 24 +--------------- crates/terminal_view/src/terminal_element.rs | 2 +- flake.lock | 18 ++++++------ rust-toolchain.toml | 2 +- 8 files changed, 35 insertions(+), 55 deletions(-) diff --git a/Dockerfile-collab b/Dockerfile-collab index 2dafe296c7..c1621d6ee6 100644 --- a/Dockerfile-collab +++ b/Dockerfile-collab @@ -1,6 +1,6 @@ # syntax = docker/dockerfile:1.2 -FROM rust:1.88-bookworm as builder +FROM rust:1.89-bookworm as builder WORKDIR app COPY . . diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 04ba656232..73da63fd47 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -402,11 +402,11 @@ impl GitRepository for FakeGitRepository { &self, _paths: Vec, _env: Arc>, - ) -> BoxFuture> { + ) -> BoxFuture<'_, Result<()>> { unimplemented!() } - fn stash_pop(&self, _env: Arc>) -> BoxFuture> { + fn stash_pop(&self, _env: Arc>) -> BoxFuture<'_, Result<()>> { unimplemented!() } diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index dc7ab0af65..518b6c4f46 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -399,9 +399,9 @@ pub trait GitRepository: Send + Sync { &self, paths: Vec, env: Arc>, - ) -> BoxFuture>; + ) -> BoxFuture<'_, Result<()>>; - fn stash_pop(&self, env: Arc>) -> BoxFuture>; + fn stash_pop(&self, env: Arc>) -> BoxFuture<'_, Result<()>>; fn push( &self, @@ -1203,7 +1203,7 @@ impl GitRepository for RealGitRepository { &self, paths: Vec, env: Arc>, - ) -> BoxFuture> { + ) -> BoxFuture<'_, Result<()>> { let working_directory = self.working_directory(); self.executor .spawn(async move { @@ -1227,7 +1227,7 @@ impl GitRepository for RealGitRepository { .boxed() } - fn stash_pop(&self, env: Arc>) -> BoxFuture> { + fn stash_pop(&self, env: Arc>) -> BoxFuture<'_, Result<()>> { let working_directory = self.working_directory(); self.executor .spawn(async move { diff --git a/crates/gpui/src/keymap/context.rs b/crates/gpui/src/keymap/context.rs index f4b878ae77..281035fe97 100644 --- a/crates/gpui/src/keymap/context.rs +++ b/crates/gpui/src/keymap/context.rs @@ -461,6 +461,8 @@ fn skip_whitespace(source: &str) -> &str { #[cfg(test)] mod tests { + use core::slice; + use super::*; use crate as gpui; use KeyBindingContextPredicate::*; @@ -674,11 +676,11 @@ mod tests { assert!(predicate.eval(&contexts)); assert!(!predicate.eval(&[])); - assert!(!predicate.eval(&[child_context.clone()])); + assert!(!predicate.eval(slice::from_ref(&child_context))); assert!(!predicate.eval(&[parent_context])); let zany_predicate = KeyBindingContextPredicate::parse("child > child").unwrap(); - assert!(!zany_predicate.eval(&[child_context.clone()])); + assert!(!zany_predicate.eval(slice::from_ref(&child_context))); assert!(zany_predicate.eval(&[child_context.clone(), child_context.clone()])); } @@ -690,13 +692,13 @@ mod tests { let parent_context = KeyContext::try_from("parent").unwrap(); let child_context = KeyContext::try_from("child").unwrap(); - assert!(not_predicate.eval(&[workspace_context.clone()])); - assert!(!not_predicate.eval(&[editor_context.clone()])); + assert!(not_predicate.eval(slice::from_ref(&workspace_context))); + assert!(!not_predicate.eval(slice::from_ref(&editor_context))); assert!(!not_predicate.eval(&[editor_context.clone(), workspace_context.clone()])); assert!(!not_predicate.eval(&[workspace_context.clone(), editor_context.clone()])); let complex_not = KeyBindingContextPredicate::parse("!editor && workspace").unwrap(); - assert!(complex_not.eval(&[workspace_context.clone()])); + assert!(complex_not.eval(slice::from_ref(&workspace_context))); assert!(!complex_not.eval(&[editor_context.clone(), workspace_context.clone()])); let not_mode_predicate = KeyBindingContextPredicate::parse("!(mode == full)").unwrap(); @@ -709,18 +711,18 @@ mod tests { assert!(not_mode_predicate.eval(&[other_mode_context])); let not_descendant = KeyBindingContextPredicate::parse("!(parent > child)").unwrap(); - assert!(not_descendant.eval(&[parent_context.clone()])); - assert!(not_descendant.eval(&[child_context.clone()])); + assert!(not_descendant.eval(slice::from_ref(&parent_context))); + assert!(not_descendant.eval(slice::from_ref(&child_context))); assert!(!not_descendant.eval(&[parent_context.clone(), child_context.clone()])); let not_descendant = KeyBindingContextPredicate::parse("parent > !child").unwrap(); - assert!(!not_descendant.eval(&[parent_context.clone()])); - assert!(!not_descendant.eval(&[child_context.clone()])); + assert!(!not_descendant.eval(slice::from_ref(&parent_context))); + assert!(!not_descendant.eval(slice::from_ref(&child_context))); assert!(!not_descendant.eval(&[parent_context.clone(), child_context.clone()])); let double_not = KeyBindingContextPredicate::parse("!!editor").unwrap(); - assert!(double_not.eval(&[editor_context.clone()])); - assert!(!double_not.eval(&[workspace_context.clone()])); + assert!(double_not.eval(slice::from_ref(&editor_context))); + assert!(!double_not.eval(slice::from_ref(&workspace_context))); // Test complex descendant cases let workspace_context = KeyContext::try_from("Workspace").unwrap(); @@ -754,9 +756,9 @@ mod tests { // !Workspace - shouldn't match when Workspace is in the context let not_workspace = KeyBindingContextPredicate::parse("!Workspace").unwrap(); - assert!(!not_workspace.eval(&[workspace_context.clone()])); - assert!(not_workspace.eval(&[pane_context.clone()])); - assert!(not_workspace.eval(&[editor_context.clone()])); + assert!(!not_workspace.eval(slice::from_ref(&workspace_context))); + assert!(not_workspace.eval(slice::from_ref(&pane_context))); + assert!(not_workspace.eval(slice::from_ref(&editor_context))); assert!(!not_workspace.eval(&workspace_pane_editor)); } } diff --git a/crates/gpui/src/platform/windows/wrapper.rs b/crates/gpui/src/platform/windows/wrapper.rs index 6015dffdab..a1fe98a392 100644 --- a/crates/gpui/src/platform/windows/wrapper.rs +++ b/crates/gpui/src/platform/windows/wrapper.rs @@ -1,28 +1,6 @@ use std::ops::Deref; -use windows::Win32::{Foundation::HANDLE, UI::WindowsAndMessaging::HCURSOR}; - -#[derive(Debug, Clone, Copy)] -pub(crate) struct SafeHandle { - raw: HANDLE, -} - -unsafe impl Send for SafeHandle {} -unsafe impl Sync for SafeHandle {} - -impl From for SafeHandle { - fn from(value: HANDLE) -> Self { - SafeHandle { raw: value } - } -} - -impl Deref for SafeHandle { - type Target = HANDLE; - - fn deref(&self) -> &Self::Target { - &self.raw - } -} +use windows::Win32::UI::WindowsAndMessaging::HCURSOR; #[derive(Debug, Clone, Copy)] pub(crate) struct SafeCursor { diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index 083c07de9c..6c1be9d5e7 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -136,7 +136,7 @@ impl BatchedTextRun { .shape_line( self.text.clone().into(), self.font_size.to_pixels(window.rem_size()), - &[self.style.clone()], + std::slice::from_ref(&self.style), Some(dimensions.cell_width), ) .paint(pos, dimensions.line_height, window, cx); diff --git a/flake.lock b/flake.lock index fa0d51d90d..80022f7b55 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1750266157, - "narHash": "sha256-tL42YoNg9y30u7zAqtoGDNdTyXTi8EALDeCB13FtbQA=", + "lastModified": 1754269165, + "narHash": "sha256-0tcS8FHd4QjbCVoxN9jI+PjHgA4vc/IjkUSp+N3zy0U=", "owner": "ipetkov", "repo": "crane", - "rev": "e37c943371b73ed87faf33f7583860f81f1d5a48", + "rev": "444e81206df3f7d92780680e45858e31d2f07a08", "type": "github" }, "original": { @@ -33,10 +33,10 @@ "nixpkgs": { "locked": { "lastModified": 315532800, - "narHash": "sha256-j+zO+IHQ7VwEam0pjPExdbLT2rVioyVS3iq4bLO3GEc=", - "rev": "61c0f513911459945e2cb8bf333dc849f1b976ff", + "narHash": "sha256-5VYevX3GccubYeccRGAXvCPA1ktrGmIX1IFC0icX07g=", + "rev": "a683adc19ff5228af548c6539dbc3440509bfed3", "type": "tarball", - "url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre821324.61c0f5139114/nixexprs.tar.xz" + "url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre840248.a683adc19ff5/nixexprs.tar.xz" }, "original": { "type": "tarball", @@ -58,11 +58,11 @@ ] }, "locked": { - "lastModified": 1750964660, - "narHash": "sha256-YQ6EyFetjH1uy5JhdhRdPe6cuNXlYpMAQePFfZj4W7M=", + "lastModified": 1754575663, + "narHash": "sha256-afOx8AG0KYtw7mlt6s6ahBBy7eEHZwws3iCRoiuRQS4=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "04f0fcfb1a50c63529805a798b4b5c21610ff390", + "rev": "6db0fb0e9cec2e9729dc52bf4898e6c135bb8a0f", "type": "github" }, "original": { diff --git a/rust-toolchain.toml b/rust-toolchain.toml index f80eab8fbc..3d87025a27 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,5 +1,5 @@ [toolchain] -channel = "1.88" +channel = "1.89" profile = "minimal" components = [ "rustfmt", "clippy" ] targets = [ From 2d9cd2ac8888a144ef41e59c9820ffbecee66ed1 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Sat, 9 Aug 2025 22:12:23 -0300 Subject: [PATCH 037/109] Update and refine some icons (#35938) Follow up to https://github.com/zed-industries/zed/pull/35856. Release Notes: - N/A --- assets/icons/arrow_circle.svg | 8 ++++---- assets/icons/blocks.svg | 4 +++- assets/icons/folder_search.svg | 2 +- assets/icons/maximize.svg | 4 ++-- assets/icons/minimize.svg | 8 ++++---- assets/icons/scissors.svg | 4 +++- crates/icons/README.md | 18 +++++++++--------- 7 files changed, 26 insertions(+), 22 deletions(-) diff --git a/assets/icons/arrow_circle.svg b/assets/icons/arrow_circle.svg index 790428702e..76363c6270 100644 --- a/assets/icons/arrow_circle.svg +++ b/assets/icons/arrow_circle.svg @@ -1,6 +1,6 @@ - - - - + + + + diff --git a/assets/icons/blocks.svg b/assets/icons/blocks.svg index 128ca84ef1..e1690e2642 100644 --- a/assets/icons/blocks.svg +++ b/assets/icons/blocks.svg @@ -1 +1,3 @@ - + + + diff --git a/assets/icons/folder_search.svg b/assets/icons/folder_search.svg index 15b0705dd6..d1bc537c98 100644 --- a/assets/icons/folder_search.svg +++ b/assets/icons/folder_search.svg @@ -1,5 +1,5 @@ - + diff --git a/assets/icons/maximize.svg b/assets/icons/maximize.svg index c51b71aaf0..ee03a2c021 100644 --- a/assets/icons/maximize.svg +++ b/assets/icons/maximize.svg @@ -1,6 +1,6 @@ - - + + diff --git a/assets/icons/minimize.svg b/assets/icons/minimize.svg index 97d4699687..ea825f054e 100644 --- a/assets/icons/minimize.svg +++ b/assets/icons/minimize.svg @@ -1,6 +1,6 @@ - - - - + + + + diff --git a/assets/icons/scissors.svg b/assets/icons/scissors.svg index 89d246841e..430293f913 100644 --- a/assets/icons/scissors.svg +++ b/assets/icons/scissors.svg @@ -1 +1,3 @@ - + + + diff --git a/crates/icons/README.md b/crates/icons/README.md index 5fbd6d4948..71bc5c8545 100644 --- a/crates/icons/README.md +++ b/crates/icons/README.md @@ -3,27 +3,27 @@ ## Guidelines Icons are a big part of Zed, and they're how we convey hundreds of actions without relying on labeled buttons. -When introducing a new icon to the set, it's important to ensure it is consistent with the whole set, which follows a few guidelines: +When introducing a new icon, it's important to ensure consistency with the existing set, which follows these guidelines: 1. The SVG view box should be 16x16. 2. For outlined icons, use a 1.5px stroke width. -3. Not all icons are mathematically aligned; there's quite a bit of optical adjustment. But try to keep the icon within an internal 12x12 bounding box as much as possible while ensuring proper visibility. -4. Use the `filled` and `outlined` terminology when introducing icons that will have the two variants. +3. Not all icons are mathematically aligned; there's quite a bit of optical adjustment. However, try to keep the icon within an internal 12x12 bounding box as much as possible while ensuring proper visibility. +4. Use the `filled` and `outlined` terminology when introducing icons that will have these two variants. 5. Icons that are deeply contextual may have the feature context as their name prefix. For example, `ToolWeb`, `ReplPlay`, `DebugStepInto`, etc. -6. Avoid complex layer structure in the icon SVG, like clipping masks and whatnot. When the shape ends up too complex, we recommend running the SVG in [SVGOMG](https://jakearchibald.github.io/svgomg/) to clean it up a bit. +6. Avoid complex layer structures in the icon SVG, like clipping masks and similar elements. When the shape becomes too complex, we recommend running the SVG through [SVGOMG](https://jakearchibald.github.io/svgomg/) to clean it up. ## Sourcing Most icons are created by sourcing them from [Lucide](https://lucide.dev/). Then, they're modified, adjusted, cleaned up, and simplified depending on their use and overall fit with Zed. -Sometimes, we may use other sources like [Phosphor](https://phosphoricons.com/), but we also design many of them completely from scratch. +Sometimes, we may use other sources like [Phosphor](https://phosphoricons.com/), but we also design many icons completely from scratch. ## Contributing -To introduce a new icon, add the `.svg` file in the `assets/icon` directory and then add its corresponding item in the `icons.rs` file within the `crates` directory. +To introduce a new icon, add the `.svg` file to the `assets/icon` directory and then add its corresponding item to the `icons.rs` file within the `crates` directory. -- SVG files in the assets folder follow a snake case name format. -- Icons in the `icons.rs` file follow the pascal case name format. +- SVG files in the assets folder follow a snake_case name format. +- Icons in the `icons.rs` file follow the PascalCase name format. -Ensure you tag a member of Zed's design team so we can adjust and double-check any newly introduced icon. +Make sure to tag a member of Zed's design team so we can review and adjust any newly introduced icon. From 8382afb2ba6f60ddd8d61a150bc97d92baeb209b Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Sun, 10 Aug 2025 17:43:48 +0300 Subject: [PATCH 038/109] evals: Run unit evals CI weekly (#35950) Release Notes: - N/A --- .github/workflows/unit_evals.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unit_evals.yml b/.github/workflows/unit_evals.yml index 2e03fb028f..c03cf8b087 100644 --- a/.github/workflows/unit_evals.yml +++ b/.github/workflows/unit_evals.yml @@ -3,7 +3,7 @@ name: Run Unit Evals on: schedule: # GitHub might drop jobs at busy times, so we choose a random time in the middle of the night. - - cron: "47 1 * * *" + - cron: "47 1 * * 2" workflow_dispatch: concurrency: From 9cd5c3656e831a30fe8ef606aff04adf4bba4a60 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Sun, 10 Aug 2025 17:19:06 +0200 Subject: [PATCH 039/109] util: Fix crate name extraction for `log_error_with_caller` (#35944) The paths can be absolute, meaning they would just log the initial segment of where the repo was cloned. Release Notes: - N/A --- crates/util/src/util.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/util/src/util.rs b/crates/util/src/util.rs index 932b519b18..b526f53ce4 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -669,9 +669,12 @@ where let file = caller.file(); #[cfg(target_os = "windows")] let file = caller.file().replace('\\', "/"); - // In this codebase, the first segment of the file path is - // the 'crates' folder, followed by the crate name. - let target = file.split('/').nth(1); + // In this codebase all crates reside in a `crates` directory, + // so discard the prefix up to that segment to find the crate name + let target = file + .split_once("crates/") + .and_then(|(_, s)| s.split_once('/')) + .map(|(p, _)| p); log::logger().log( &log::Record::builder() From 95e302fa68722b8af29e99ed8d3256e1585a8ede Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Sun, 10 Aug 2025 21:01:54 +0300 Subject: [PATCH 040/109] Properly use `static` instead of `const` for global types that need a single init (#35955) Release Notes: - N/A --- Cargo.toml | 1 + .../src/edit_agent/create_file_parser.rs | 13 ++++--- crates/docs_preprocessor/src/main.rs | 13 ++++--- crates/gpui/src/platform/mac/platform.rs | 34 +++++++++---------- crates/onboarding/src/theme_preview.rs | 29 ++++++++++------ crates/zeta/src/license_detection.rs | 6 ++-- 6 files changed, 55 insertions(+), 41 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 998e727602..d6ca4c664d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -839,6 +839,7 @@ style = { level = "allow", priority = -1 } module_inception = { level = "deny" } question_mark = { level = "deny" } redundant_closure = { level = "deny" } +declare_interior_mutable_const = { level = "deny" } # Individual rules that have violations in the codebase: type_complexity = "allow" # We often return trait objects from `new` functions. diff --git a/crates/assistant_tools/src/edit_agent/create_file_parser.rs b/crates/assistant_tools/src/edit_agent/create_file_parser.rs index 07c8fac7b9..0aad9ecb87 100644 --- a/crates/assistant_tools/src/edit_agent/create_file_parser.rs +++ b/crates/assistant_tools/src/edit_agent/create_file_parser.rs @@ -1,10 +1,11 @@ +use std::sync::OnceLock; + use regex::Regex; use smallvec::SmallVec; -use std::cell::LazyCell; use util::debug_panic; -const START_MARKER: LazyCell = LazyCell::new(|| Regex::new(r"\n?```\S*\n").unwrap()); -const END_MARKER: LazyCell = LazyCell::new(|| Regex::new(r"(^|\n)```\s*$").unwrap()); +static START_MARKER: OnceLock = OnceLock::new(); +static END_MARKER: OnceLock = OnceLock::new(); #[derive(Debug)] pub enum CreateFileParserEvent { @@ -43,10 +44,12 @@ impl CreateFileParser { self.buffer.push_str(chunk); let mut edit_events = SmallVec::new(); + let start_marker_regex = START_MARKER.get_or_init(|| Regex::new(r"\n?```\S*\n").unwrap()); + let end_marker_regex = END_MARKER.get_or_init(|| Regex::new(r"(^|\n)```\s*$").unwrap()); loop { match &mut self.state { ParserState::Pending => { - if let Some(m) = START_MARKER.find(&self.buffer) { + if let Some(m) = start_marker_regex.find(&self.buffer) { self.buffer.drain(..m.end()); self.state = ParserState::WithinText; } else { @@ -65,7 +68,7 @@ impl CreateFileParser { break; } ParserState::Finishing => { - if let Some(m) = END_MARKER.find(&self.buffer) { + if let Some(m) = end_marker_regex.find(&self.buffer) { self.buffer.drain(m.start()..); } if !self.buffer.is_empty() { diff --git a/crates/docs_preprocessor/src/main.rs b/crates/docs_preprocessor/src/main.rs index 1448f4cb52..17804b4281 100644 --- a/crates/docs_preprocessor/src/main.rs +++ b/crates/docs_preprocessor/src/main.rs @@ -8,7 +8,7 @@ use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::io::{self, Read}; use std::process; -use std::sync::LazyLock; +use std::sync::{LazyLock, OnceLock}; use util::paths::PathExt; static KEYMAP_MACOS: LazyLock = LazyLock::new(|| { @@ -388,7 +388,7 @@ fn handle_postprocessing() -> Result<()> { let meta_title = format!("{} | {}", page_title, meta_title); zlog::trace!(logger => "Updating {:?}", pretty_path(&file, &root_dir)); let contents = contents.replace("#description#", meta_description); - let contents = TITLE_REGEX + let contents = title_regex() .replace(&contents, |_: ®ex::Captures| { format!("{}", meta_title) }) @@ -404,10 +404,8 @@ fn handle_postprocessing() -> Result<()> { ) -> &'a std::path::Path { &path.strip_prefix(&root).unwrap_or(&path) } - const TITLE_REGEX: std::cell::LazyCell = - std::cell::LazyCell::new(|| Regex::new(r"\s*(.*?)\s*").unwrap()); fn extract_title_from_page(contents: &str, pretty_path: &std::path::Path) -> String { - let title_tag_contents = &TITLE_REGEX + let title_tag_contents = &title_regex() .captures(&contents) .with_context(|| format!("Failed to find title in {:?}", pretty_path)) .expect("Page has element")[1]; @@ -420,3 +418,8 @@ fn handle_postprocessing() -> Result<()> { title } } + +fn title_regex() -> &'static Regex { + static TITLE_REGEX: OnceLock<Regex> = OnceLock::new(); + TITLE_REGEX.get_or_init(|| Regex::new(r"<title>\s*(.*?)\s*").unwrap()) +} diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index 1d2146cf73..c71eb448c4 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -47,7 +47,7 @@ use objc::{ use parking_lot::Mutex; use ptr::null_mut; use std::{ - cell::{Cell, LazyCell}, + cell::Cell, convert::TryInto, ffi::{CStr, OsStr, c_void}, os::{raw::c_char, unix::ffi::OsStrExt}, @@ -56,7 +56,7 @@ use std::{ ptr, rc::Rc, slice, str, - sync::Arc, + sync::{Arc, OnceLock}, }; use strum::IntoEnumIterator; use util::ResultExt; @@ -296,18 +296,7 @@ impl MacPlatform { actions: &mut Vec>, keymap: &Keymap, ) -> id { - const DEFAULT_CONTEXT: LazyCell> = LazyCell::new(|| { - let mut workspace_context = KeyContext::new_with_defaults(); - workspace_context.add("Workspace"); - let mut pane_context = KeyContext::new_with_defaults(); - pane_context.add("Pane"); - let mut editor_context = KeyContext::new_with_defaults(); - editor_context.add("Editor"); - - pane_context.extend(&editor_context); - workspace_context.extend(&pane_context); - vec![workspace_context] - }); + static DEFAULT_CONTEXT: OnceLock> = OnceLock::new(); unsafe { match item { @@ -323,9 +312,20 @@ impl MacPlatform { let keystrokes = keymap .bindings_for_action(action.as_ref()) .find_or_first(|binding| { - binding - .predicate() - .is_none_or(|predicate| predicate.eval(&DEFAULT_CONTEXT)) + binding.predicate().is_none_or(|predicate| { + predicate.eval(DEFAULT_CONTEXT.get_or_init(|| { + let mut workspace_context = KeyContext::new_with_defaults(); + workspace_context.add("Workspace"); + let mut pane_context = KeyContext::new_with_defaults(); + pane_context.add("Pane"); + let mut editor_context = KeyContext::new_with_defaults(); + editor_context.add("Editor"); + + pane_context.extend(&editor_context); + workspace_context.extend(&pane_context); + vec![workspace_context] + })) + }) }) .map(|binding| binding.keystrokes()); diff --git a/crates/onboarding/src/theme_preview.rs b/crates/onboarding/src/theme_preview.rs index 81eb14ec4b..9d86137b0b 100644 --- a/crates/onboarding/src/theme_preview.rs +++ b/crates/onboarding/src/theme_preview.rs @@ -1,6 +1,9 @@ #![allow(unused, dead_code)] use gpui::{Hsla, Length}; -use std::sync::Arc; +use std::{ + cell::LazyCell, + sync::{Arc, OnceLock}, +}; use theme::{Theme, ThemeColors, ThemeRegistry}; use ui::{ IntoElement, RenderOnce, component_prelude::Documented, prelude::*, utils::inner_corner_radius, @@ -22,6 +25,18 @@ pub struct ThemePreviewTile { style: ThemePreviewStyle, } +fn child_radius() -> Pixels { + static CHILD_RADIUS: OnceLock = OnceLock::new(); + *CHILD_RADIUS.get_or_init(|| { + inner_corner_radius( + ThemePreviewTile::ROOT_RADIUS, + ThemePreviewTile::ROOT_BORDER, + ThemePreviewTile::ROOT_PADDING, + ThemePreviewTile::CHILD_BORDER, + ) + }) +} + impl ThemePreviewTile { pub const SKELETON_HEIGHT_DEFAULT: Pixels = px(2.); pub const SIDEBAR_SKELETON_ITEM_COUNT: usize = 8; @@ -30,14 +45,6 @@ impl ThemePreviewTile { pub const ROOT_BORDER: Pixels = px(2.0); pub const ROOT_PADDING: Pixels = px(2.0); pub const CHILD_BORDER: Pixels = px(1.0); - pub const CHILD_RADIUS: std::cell::LazyCell = std::cell::LazyCell::new(|| { - inner_corner_radius( - Self::ROOT_RADIUS, - Self::ROOT_BORDER, - Self::ROOT_PADDING, - Self::CHILD_BORDER, - ) - }); pub fn new(theme: Arc, seed: f32) -> Self { Self { @@ -222,7 +229,7 @@ impl ThemePreviewTile { .child( div() .size_full() - .rounded(*Self::CHILD_RADIUS) + .rounded(child_radius()) .border(Self::CHILD_BORDER) .border_color(theme.colors().border) .child(Self::render_editor( @@ -250,7 +257,7 @@ impl ThemePreviewTile { h_flex() .size_full() .relative() - .rounded(*Self::CHILD_RADIUS) + .rounded(child_radius()) .border(Self::CHILD_BORDER) .border_color(border_color) .overflow_hidden() diff --git a/crates/zeta/src/license_detection.rs b/crates/zeta/src/license_detection.rs index c55f8d5d08..fa1eabf524 100644 --- a/crates/zeta/src/license_detection.rs +++ b/crates/zeta/src/license_detection.rs @@ -14,7 +14,7 @@ use util::ResultExt as _; use worktree::ChildEntriesOptions; /// Matches the most common license locations, with US and UK English spelling. -const LICENSE_FILE_NAME_REGEX: LazyLock = LazyLock::new(|| { +static LICENSE_FILE_NAME_REGEX: LazyLock = LazyLock::new(|| { regex::bytes::RegexBuilder::new( "^ \ (?: license | licence) \ @@ -29,7 +29,7 @@ const LICENSE_FILE_NAME_REGEX: LazyLock = LazyLock::new(|| }); fn is_license_eligible_for_data_collection(license: &str) -> bool { - const LICENSE_REGEXES: LazyLock> = LazyLock::new(|| { + static LICENSE_REGEXES: LazyLock> = LazyLock::new(|| { [ include_str!("license_detection/apache.regex"), include_str!("license_detection/isc.regex"), @@ -47,7 +47,7 @@ fn is_license_eligible_for_data_collection(license: &str) -> bool { /// Canonicalizes the whitespace of license text and license regexes. fn canonicalize_license_text(license: &str) -> String { - const PARAGRAPH_SEPARATOR_REGEX: LazyLock = + static PARAGRAPH_SEPARATOR_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"\s*\n\s*\n\s*").unwrap()); PARAGRAPH_SEPARATOR_REGEX From f3d6deb5a319af86a68b797a37be86f2f4c288a9 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Sun, 10 Aug 2025 15:23:27 -0300 Subject: [PATCH 041/109] debugger: Add refinements to the UI (#35940) Took a little bit of time to add just a handful of small tweaks to the debugger UI so it looks slightly more polished. This PR includes adjustments to size, focus styles, and more in icon buttons, overall spacing nudges in each section pane, making tooltip labels title case (for overall consistency), and some icon SVG iteration. Release Notes: - N/A --- .../icons/debug_disabled_log_breakpoint.svg | 4 +- assets/icons/debug_ignore_breakpoints.svg | 4 +- assets/icons/debug_log_breakpoint.svg | 2 +- crates/debugger_ui/src/debugger_panel.rs | 143 +++++----- crates/debugger_ui/src/session/running.rs | 27 +- .../src/session/running/breakpoint_list.rs | 266 ++++++++++-------- .../src/session/running/console.rs | 15 +- .../src/session/running/memory_view.rs | 6 +- 8 files changed, 263 insertions(+), 204 deletions(-) diff --git a/assets/icons/debug_disabled_log_breakpoint.svg b/assets/icons/debug_disabled_log_breakpoint.svg index a028ead3a0..2ccc37623d 100644 --- a/assets/icons/debug_disabled_log_breakpoint.svg +++ b/assets/icons/debug_disabled_log_breakpoint.svg @@ -1,3 +1,5 @@ - + + + diff --git a/assets/icons/debug_ignore_breakpoints.svg b/assets/icons/debug_ignore_breakpoints.svg index a0bbabfb26..b2a345d314 100644 --- a/assets/icons/debug_ignore_breakpoints.svg +++ b/assets/icons/debug_ignore_breakpoints.svg @@ -1 +1,3 @@ - + + + diff --git a/assets/icons/debug_log_breakpoint.svg b/assets/icons/debug_log_breakpoint.svg index 7c652db1e9..22eae9d029 100644 --- a/assets/icons/debug_log_breakpoint.svg +++ b/assets/icons/debug_log_breakpoint.svg @@ -1,3 +1,3 @@ - + diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index 91382c74ae..1d44c5c244 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -36,7 +36,7 @@ use settings::Settings; use std::sync::{Arc, LazyLock}; use task::{DebugScenario, TaskContext}; use tree_sitter::{Query, StreamingIterator as _}; -use ui::{ContextMenu, Divider, PopoverMenuHandle, Tooltip, prelude::*}; +use ui::{ContextMenu, Divider, PopoverMenuHandle, Tab, Tooltip, prelude::*}; use util::{ResultExt, debug_panic, maybe}; use workspace::SplitDirection; use workspace::item::SaveOptions; @@ -642,12 +642,14 @@ impl DebugPanel { } }) }; + let documentation_button = || { IconButton::new("debug-open-documentation", IconName::CircleHelp) .icon_size(IconSize::Small) .on_click(move |_, _, cx| cx.open_url("https://zed.dev/docs/debugger")) .tooltip(Tooltip::text("Open Documentation")) }; + let logs_button = || { IconButton::new("debug-open-logs", IconName::Notepad) .icon_size(IconSize::Small) @@ -658,16 +660,18 @@ impl DebugPanel { }; Some( - div.border_b_1() - .border_color(cx.theme().colors().border) - .p_1() + div.w_full() + .py_1() + .px_1p5() .justify_between() - .w_full() + .border_b_1() + .border_color(cx.theme().colors().border) .when(is_side, |this| this.gap_1()) .child( h_flex() + .justify_between() .child( - h_flex().gap_2().w_full().when_some( + h_flex().gap_1().w_full().when_some( active_session .as_ref() .map(|session| session.read(cx).running_state()), @@ -679,6 +683,7 @@ impl DebugPanel { let capabilities = running_state.read(cx).capabilities(cx); let supports_detach = running_state.read(cx).session().read(cx).is_attached(); + this.map(|this| { if thread_status == ThreadStatus::Running { this.child( @@ -686,8 +691,7 @@ impl DebugPanel { "debug-pause", IconName::DebugPause, ) - .icon_size(IconSize::XSmall) - .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) .on_click(window.listener_for( &running_state, |this, _, _window, cx| { @@ -698,7 +702,7 @@ impl DebugPanel { let focus_handle = focus_handle.clone(); move |window, cx| { Tooltip::for_action_in( - "Pause program", + "Pause Program", &Pause, &focus_handle, window, @@ -713,8 +717,7 @@ impl DebugPanel { "debug-continue", IconName::DebugContinue, ) - .icon_size(IconSize::XSmall) - .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) .on_click(window.listener_for( &running_state, |this, _, _window, cx| this.continue_thread(cx), @@ -724,7 +727,7 @@ impl DebugPanel { let focus_handle = focus_handle.clone(); move |window, cx| { Tooltip::for_action_in( - "Continue program", + "Continue Program", &Continue, &focus_handle, window, @@ -737,8 +740,7 @@ impl DebugPanel { }) .child( IconButton::new("debug-step-over", IconName::ArrowRight) - .icon_size(IconSize::XSmall) - .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) .on_click(window.listener_for( &running_state, |this, _, _window, cx| { @@ -750,7 +752,7 @@ impl DebugPanel { let focus_handle = focus_handle.clone(); move |window, cx| { Tooltip::for_action_in( - "Step over", + "Step Over", &StepOver, &focus_handle, window, @@ -764,8 +766,7 @@ impl DebugPanel { "debug-step-into", IconName::ArrowDownRight, ) - .icon_size(IconSize::XSmall) - .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) .on_click(window.listener_for( &running_state, |this, _, _window, cx| { @@ -777,7 +778,7 @@ impl DebugPanel { let focus_handle = focus_handle.clone(); move |window, cx| { Tooltip::for_action_in( - "Step in", + "Step In", &StepInto, &focus_handle, window, @@ -789,7 +790,6 @@ impl DebugPanel { .child( IconButton::new("debug-step-out", IconName::ArrowUpRight) .icon_size(IconSize::Small) - .shape(ui::IconButtonShape::Square) .on_click(window.listener_for( &running_state, |this, _, _window, cx| { @@ -801,7 +801,7 @@ impl DebugPanel { let focus_handle = focus_handle.clone(); move |window, cx| { Tooltip::for_action_in( - "Step out", + "Step Out", &StepOut, &focus_handle, window, @@ -813,7 +813,7 @@ impl DebugPanel { .child(Divider::vertical()) .child( IconButton::new("debug-restart", IconName::RotateCcw) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .on_click(window.listener_for( &running_state, |this, _, window, cx| { @@ -835,7 +835,7 @@ impl DebugPanel { ) .child( IconButton::new("debug-stop", IconName::Power) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .on_click(window.listener_for( &running_state, |this, _, _window, cx| { @@ -890,7 +890,7 @@ impl DebugPanel { thread_status != ThreadStatus::Stopped && thread_status != ThreadStatus::Running, ) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .on_click(window.listener_for( &running_state, |this, _, _, cx| { @@ -915,7 +915,6 @@ impl DebugPanel { }, ), ) - .justify_around() .when(is_side, |this| { this.child(new_session_button()) .child(logs_button()) @@ -924,7 +923,7 @@ impl DebugPanel { ) .child( h_flex() - .gap_2() + .gap_0p5() .when(is_side, |this| this.justify_between()) .child( h_flex().when_some( @@ -954,12 +953,15 @@ impl DebugPanel { ) }) }) - .when(!is_side, |this| this.gap_2().child(Divider::vertical())) + .when(!is_side, |this| { + this.gap_0p5().child(Divider::vertical()) + }) }, ), ) .child( h_flex() + .gap_0p5() .children(self.render_session_menu( self.active_session(), self.running_state(cx), @@ -1702,6 +1704,7 @@ impl Render for DebugPanel { this.child(active_session) } else { let docked_to_bottom = self.position(window, cx) == DockPosition::Bottom; + let welcome_experience = v_flex() .when_else( docked_to_bottom, @@ -1767,54 +1770,58 @@ impl Render for DebugPanel { ); }), ); - let breakpoint_list = - v_flex() - .group("base-breakpoint-list") - .items_start() - .when_else( - docked_to_bottom, - |this| this.min_w_1_3().h_full(), - |this| this.w_full().h_2_3(), - ) - .p_1() - .child( - h_flex() - .pl_1() - .w_full() - .justify_between() - .child(Label::new("Breakpoints").size(LabelSize::Small)) - .child(h_flex().visible_on_hover("base-breakpoint-list").child( + + let breakpoint_list = v_flex() + .group("base-breakpoint-list") + .when_else( + docked_to_bottom, + |this| this.min_w_1_3().h_full(), + |this| this.size_full().h_2_3(), + ) + .child( + h_flex() + .track_focus(&self.breakpoint_list.focus_handle(cx)) + .h(Tab::container_height(cx)) + .p_1p5() + .w_full() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child(Label::new("Breakpoints").size(LabelSize::Small)) + .child( + h_flex().visible_on_hover("base-breakpoint-list").child( self.breakpoint_list.read(cx).render_control_strip(), - )) - .track_focus(&self.breakpoint_list.focus_handle(cx)), - ) - .child(Divider::horizontal()) - .child(self.breakpoint_list.clone()); + ), + ), + ) + .child(self.breakpoint_list.clone()); + this.child( v_flex() - .h_full() + .size_full() .gap_1() .items_center() .justify_center() - .child( - div() - .when_else(docked_to_bottom, Div::h_flex, Div::v_flex) - .size_full() - .map(|this| { - if docked_to_bottom { - this.items_start() - .child(breakpoint_list) - .child(Divider::vertical()) - .child(welcome_experience) - .child(Divider::vertical()) - } else { - this.items_end() - .child(welcome_experience) - .child(Divider::horizontal()) - .child(breakpoint_list) - } - }), - ), + .map(|this| { + if docked_to_bottom { + this.child( + h_flex() + .size_full() + .child(breakpoint_list) + .child(Divider::vertical()) + .child(welcome_experience) + .child(Divider::vertical()), + ) + } else { + this.child( + v_flex() + .size_full() + .child(welcome_experience) + .child(Divider::horizontal()) + .child(breakpoint_list), + ) + } + }), ) } }) diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index a3e2805e2b..c8bee42039 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -48,10 +48,8 @@ use task::{ }; use terminal_view::TerminalView; use ui::{ - ActiveTheme, AnyElement, App, ButtonCommon as _, Clickable as _, Context, FluentBuilder, - IconButton, IconName, IconSize, InteractiveElement, IntoElement, Label, LabelCommon as _, - ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Tab, Tooltip, - VisibleOnHover, VisualContext, Window, div, h_flex, v_flex, + FluentBuilder, IntoElement, Render, StatefulInteractiveElement, Tab, Tooltip, VisibleOnHover, + VisualContext, prelude::*, }; use util::ResultExt; use variable_list::VariableList; @@ -419,13 +417,14 @@ pub(crate) fn new_debugger_pane( .map_or(false, |item| item.read(cx).hovered); h_flex() - .group(pane_group_id.clone()) - .justify_between() - .bg(cx.theme().colors().tab_bar_background) - .border_b_1() - .px_2() - .border_color(cx.theme().colors().border) .track_focus(&focus_handle) + .group(pane_group_id.clone()) + .pl_1p5() + .pr_1() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().tab_bar_background) .on_action(|_: &menu::Cancel, window, cx| { if cx.stop_active_drag(window) { return; @@ -514,6 +513,7 @@ pub(crate) fn new_debugger_pane( ) .child({ let zoomed = pane.is_zoomed(); + h_flex() .visible_on_hover(pane_group_id) .when(is_hovered, |this| this.visible()) @@ -537,7 +537,7 @@ pub(crate) fn new_debugger_pane( IconName::Maximize }, ) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .on_click(cx.listener(move |pane, _, _, cx| { let is_zoomed = pane.is_zoomed(); pane.set_zoomed(!is_zoomed, cx); @@ -592,10 +592,11 @@ impl DebugTerminal { } impl gpui::Render for DebugTerminal { - fn render(&mut self, _window: &mut Window, _: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { div() - .size_full() .track_focus(&self.focus_handle) + .size_full() + .bg(cx.theme().colors().editor_background) .children(self.terminal.clone()) } } diff --git a/crates/debugger_ui/src/session/running/breakpoint_list.rs b/crates/debugger_ui/src/session/running/breakpoint_list.rs index 326fb84e20..38108dbfbc 100644 --- a/crates/debugger_ui/src/session/running/breakpoint_list.rs +++ b/crates/debugger_ui/src/session/running/breakpoint_list.rs @@ -23,11 +23,8 @@ use project::{ worktree_store::WorktreeStore, }; use ui::{ - ActiveTheme, AnyElement, App, ButtonCommon, Clickable, Color, Context, Disableable, Div, - Divider, FluentBuilder as _, Icon, IconButton, IconName, IconSize, InteractiveElement, - IntoElement, Label, LabelCommon, LabelSize, ListItem, ParentElement, Render, RenderOnce, - Scrollbar, ScrollbarState, SharedString, StatefulInteractiveElement, Styled, Toggleable, - Tooltip, Window, div, h_flex, px, v_flex, + Divider, DividerColor, FluentBuilder as _, Indicator, IntoElement, ListItem, Render, Scrollbar, + ScrollbarState, StatefulInteractiveElement, Tooltip, prelude::*, }; use workspace::Workspace; use zed_actions::{ToggleEnableBreakpoint, UnsetBreakpoint}; @@ -569,6 +566,7 @@ impl BreakpointList { .map(|session| SupportedBreakpointProperties::from(session.read(cx).capabilities())) .unwrap_or_else(SupportedBreakpointProperties::empty); let strip_mode = self.strip_mode; + uniform_list( "breakpoint-list", self.breakpoints.len(), @@ -591,7 +589,7 @@ impl BreakpointList { }), ) .track_scroll(self.scroll_handle.clone()) - .flex_grow() + .flex_1() } fn render_vertical_scrollbar(&self, cx: &mut Context) -> Stateful
{ @@ -630,6 +628,7 @@ impl BreakpointList { pub(crate) fn render_control_strip(&self) -> AnyElement { let selection_kind = self.selection_kind(); let focus_handle = self.focus_handle.clone(); + let remove_breakpoint_tooltip = selection_kind.map(|(kind, _)| match kind { SelectedBreakpointKind::Source => "Remove breakpoint from a breakpoint list", SelectedBreakpointKind::Exception => { @@ -637,6 +636,7 @@ impl BreakpointList { } SelectedBreakpointKind::Data => "Remove data breakpoint from a breakpoint list", }); + let toggle_label = selection_kind.map(|(_, is_enabled)| { if is_enabled { ( @@ -649,13 +649,12 @@ impl BreakpointList { }); h_flex() - .gap_2() .child( IconButton::new( "disable-breakpoint-breakpoint-list", IconName::DebugDisabledBreakpoint, ) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .when_some(toggle_label, |this, (label, meta)| { this.tooltip({ let focus_handle = focus_handle.clone(); @@ -681,9 +680,8 @@ impl BreakpointList { }), ) .child( - IconButton::new("remove-breakpoint-breakpoint-list", IconName::Close) - .icon_size(IconSize::XSmall) - .icon_color(ui::Color::Error) + IconButton::new("remove-breakpoint-breakpoint-list", IconName::Trash) + .icon_size(IconSize::Small) .when_some(remove_breakpoint_tooltip, |this, tooltip| { this.tooltip({ let focus_handle = focus_handle.clone(); @@ -710,7 +708,6 @@ impl BreakpointList { } }), ) - .mr_2() .into_any_element() } } @@ -791,6 +788,7 @@ impl Render for BreakpointList { .chain(data_breakpoints) .chain(exception_breakpoints), ); + v_flex() .id("breakpoint-list") .key_context("BreakpointList") @@ -806,35 +804,33 @@ impl Render for BreakpointList { .on_action(cx.listener(Self::next_breakpoint_property)) .on_action(cx.listener(Self::previous_breakpoint_property)) .size_full() - .m_0p5() - .child( - v_flex() - .size_full() - .child(self.render_list(cx)) - .child(self.render_vertical_scrollbar(cx)), - ) + .pt_1() + .child(self.render_list(cx)) + .child(self.render_vertical_scrollbar(cx)) .when_some(self.strip_mode, |this, _| { - this.child(Divider::horizontal()).child( - h_flex() - // .w_full() - .m_0p5() - .p_0p5() - .border_1() - .rounded_sm() - .when( - self.input.focus_handle(cx).contains_focused(window, cx), - |this| { - let colors = cx.theme().colors(); - let border = if self.input.read(cx).read_only(cx) { - colors.border_disabled - } else { - colors.border_focused - }; - this.border_color(border) - }, - ) - .child(self.input.clone()), - ) + this.child(Divider::horizontal().color(DividerColor::Border)) + .child( + h_flex() + .p_1() + .rounded_sm() + .bg(cx.theme().colors().editor_background) + .border_1() + .when( + self.input.focus_handle(cx).contains_focused(window, cx), + |this| { + let colors = cx.theme().colors(); + + let border_color = if self.input.read(cx).read_only(cx) { + colors.border_disabled + } else { + colors.border_transparent + }; + + this.border_color(border_color) + }, + ) + .child(self.input.clone()), + ) }) } } @@ -865,12 +861,17 @@ impl LineBreakpoint { let path = self.breakpoint.path.clone(); let row = self.breakpoint.row; let is_enabled = self.breakpoint.state.is_enabled(); + let indicator = div() .id(SharedString::from(format!( "breakpoint-ui-toggle-{:?}/{}:{}", self.dir, self.name, self.line ))) - .cursor_pointer() + .child( + Icon::new(icon_name) + .color(Color::Debugger) + .size(IconSize::XSmall), + ) .tooltip({ let focus_handle = focus_handle.clone(); move |window, cx| { @@ -902,17 +903,14 @@ impl LineBreakpoint { .ok(); } }) - .child( - Icon::new(icon_name) - .color(Color::Debugger) - .size(IconSize::XSmall), - ) .on_mouse_down(MouseButton::Left, move |_, _, _| {}); ListItem::new(SharedString::from(format!( "breakpoint-ui-item-{:?}/{}:{}", self.dir, self.name, self.line ))) + .toggle_state(is_selected) + .inset(true) .on_click({ let weak = weak.clone(); move |_, window, cx| { @@ -922,23 +920,20 @@ impl LineBreakpoint { .ok(); } }) - .start_slot(indicator) - .rounded() .on_secondary_mouse_down(|_, _, cx| { cx.stop_propagation(); }) + .start_slot(indicator) .child( h_flex() - .w_full() - .mr_4() - .py_0p5() - .gap_1() - .min_h(px(26.)) - .justify_between() .id(SharedString::from(format!( "breakpoint-ui-on-click-go-to-line-{:?}/{}:{}", self.dir, self.name, self.line ))) + .w_full() + .gap_1() + .min_h(rems_from_px(26.)) + .justify_between() .on_click({ let weak = weak.clone(); move |_, window, cx| { @@ -949,9 +944,9 @@ impl LineBreakpoint { .ok(); } }) - .cursor_pointer() .child( h_flex() + .id("label-container") .gap_0p5() .child( Label::new(format!("{}:{}", self.name, self.line)) @@ -971,11 +966,13 @@ impl LineBreakpoint { .line_height_style(ui::LineHeightStyle::UiLabel) .truncate(), ) - })), + })) + .when_some(self.dir.as_ref(), |this, parent_dir| { + this.tooltip(Tooltip::text(format!( + "Worktree parent path: {parent_dir}" + ))) + }), ) - .when_some(self.dir.as_ref(), |this, parent_dir| { - this.tooltip(Tooltip::text(format!("Worktree parent path: {parent_dir}"))) - }) .child(BreakpointOptionsStrip { props, breakpoint: BreakpointEntry { @@ -988,15 +985,16 @@ impl LineBreakpoint { index: ix, }), ) - .toggle_state(is_selected) } } + #[derive(Clone, Debug)] struct ExceptionBreakpoint { id: String, data: ExceptionBreakpointsFilter, is_enabled: bool, } + #[derive(Clone, Debug)] struct DataBreakpoint(project::debugger::session::DataBreakpointState); @@ -1017,17 +1015,24 @@ impl DataBreakpoint { }; let is_enabled = self.0.is_enabled; let id = self.0.dap.data_id.clone(); + ListItem::new(SharedString::from(format!( "data-breakpoint-ui-item-{}", self.0.dap.data_id ))) - .rounded() + .toggle_state(is_selected) + .inset(true) .start_slot( div() .id(SharedString::from(format!( "data-breakpoint-ui-item-{}-click-handler", self.0.dap.data_id ))) + .child( + Icon::new(IconName::Binary) + .color(color) + .size(IconSize::Small), + ) .tooltip({ let focus_handle = focus_handle.clone(); move |window, cx| { @@ -1052,25 +1057,18 @@ impl DataBreakpoint { }) .ok(); } - }) - .cursor_pointer() - .child( - Icon::new(IconName::Binary) - .color(color) - .size(IconSize::Small), - ), + }), ) .child( h_flex() .w_full() - .mr_4() - .py_0p5() + .gap_1() + .min_h(rems_from_px(26.)) .justify_between() .child( v_flex() .py_1() .gap_1() - .min_h(px(26.)) .justify_center() .id(("data-breakpoint-label", ix)) .child( @@ -1091,7 +1089,6 @@ impl DataBreakpoint { index: ix, }), ) - .toggle_state(is_selected) } } @@ -1113,10 +1110,13 @@ impl ExceptionBreakpoint { let id = SharedString::from(&self.id); let is_enabled = self.is_enabled; let weak = list.clone(); + ListItem::new(SharedString::from(format!( "exception-breakpoint-ui-item-{}", self.id ))) + .toggle_state(is_selected) + .inset(true) .on_click({ let list = list.clone(); move |_, window, cx| { @@ -1124,7 +1124,6 @@ impl ExceptionBreakpoint { .ok(); } }) - .rounded() .on_secondary_mouse_down(|_, _, cx| { cx.stop_propagation(); }) @@ -1134,6 +1133,11 @@ impl ExceptionBreakpoint { "exception-breakpoint-ui-item-{}-click-handler", self.id ))) + .child( + Icon::new(IconName::Flame) + .color(color) + .size(IconSize::Small), + ) .tooltip({ let focus_handle = focus_handle.clone(); move |window, cx| { @@ -1158,25 +1162,18 @@ impl ExceptionBreakpoint { }) .ok(); } - }) - .cursor_pointer() - .child( - Icon::new(IconName::Flame) - .color(color) - .size(IconSize::Small), - ), + }), ) .child( h_flex() .w_full() - .mr_4() - .py_0p5() + .gap_1() + .min_h(rems_from_px(26.)) .justify_between() .child( v_flex() .py_1() .gap_1() - .min_h(px(26.)) .justify_center() .id(("exception-breakpoint-label", ix)) .child( @@ -1200,7 +1197,6 @@ impl ExceptionBreakpoint { index: ix, }), ) - .toggle_state(is_selected) } } #[derive(Clone, Debug)] @@ -1302,6 +1298,7 @@ impl BreakpointEntry { } } } + bitflags::bitflags! { #[derive(Clone, Copy)] pub struct SupportedBreakpointProperties: u32 { @@ -1360,6 +1357,7 @@ impl BreakpointOptionsStrip { fn is_toggled(&self, expected_mode: ActiveBreakpointStripMode) -> bool { self.is_selected && self.strip_mode == Some(expected_mode) } + fn on_click_callback( &self, mode: ActiveBreakpointStripMode, @@ -1379,7 +1377,8 @@ impl BreakpointOptionsStrip { .ok(); } } - fn add_border( + + fn add_focus_styles( &self, kind: ActiveBreakpointStripMode, available: bool, @@ -1388,22 +1387,25 @@ impl BreakpointOptionsStrip { ) -> impl Fn(Div) -> Div { move |this: Div| { // Avoid layout shifts in case there's no colored border - let this = this.border_2().rounded_sm(); + let this = this.border_1().rounded_sm(); + let color = cx.theme().colors(); + if self.is_selected && self.strip_mode == Some(kind) { - let theme = cx.theme().colors(); if self.focus_handle.is_focused(window) { - this.border_color(theme.border_selected) + this.bg(color.editor_background) + .border_color(color.border_focused) } else { - this.border_color(theme.border_disabled) + this.border_color(color.border) } } else if !available { - this.border_color(cx.theme().colors().border_disabled) + this.border_color(color.border_transparent) } else { this } } } } + impl RenderOnce for BreakpointOptionsStrip { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let id = self.breakpoint.id(); @@ -1426,73 +1428,117 @@ impl RenderOnce for BreakpointOptionsStrip { }; let color_for_toggle = |is_enabled| { if is_enabled { - ui::Color::Default + Color::Default } else { - ui::Color::Muted + Color::Muted } }; h_flex() - .gap_1() + .gap_px() + .mr_3() // Space to avoid overlapping with the scrollbar .child( - div().map(self.add_border(ActiveBreakpointStripMode::Log, supports_logs, window, cx)) + div() + .map(self.add_focus_styles( + ActiveBreakpointStripMode::Log, + supports_logs, + window, + cx, + )) .child( IconButton::new( SharedString::from(format!("{id}-log-toggle")), IconName::Notepad, ) - .icon_size(IconSize::XSmall) + .shape(ui::IconButtonShape::Square) .style(style_for_toggle(ActiveBreakpointStripMode::Log, has_logs)) + .icon_size(IconSize::Small) .icon_color(color_for_toggle(has_logs)) + .when(has_logs, |this| this.indicator(Indicator::dot().color(Color::Info))) .disabled(!supports_logs) .toggle_state(self.is_toggled(ActiveBreakpointStripMode::Log)) - .on_click(self.on_click_callback(ActiveBreakpointStripMode::Log)).tooltip(|window, cx| Tooltip::with_meta("Set Log Message", None, "Set log message to display (instead of stopping) when a breakpoint is hit", window, cx)) + .on_click(self.on_click_callback(ActiveBreakpointStripMode::Log)) + .tooltip(|window, cx| { + Tooltip::with_meta( + "Set Log Message", + None, + "Set log message to display (instead of stopping) when a breakpoint is hit.", + window, + cx, + ) + }), ) .when(!has_logs && !self.is_selected, |this| this.invisible()), ) .child( - div().map(self.add_border( - ActiveBreakpointStripMode::Condition, - supports_condition, - window, cx - )) + div() + .map(self.add_focus_styles( + ActiveBreakpointStripMode::Condition, + supports_condition, + window, + cx, + )) .child( IconButton::new( SharedString::from(format!("{id}-condition-toggle")), IconName::SplitAlt, ) - .icon_size(IconSize::XSmall) + .shape(ui::IconButtonShape::Square) .style(style_for_toggle( ActiveBreakpointStripMode::Condition, - has_condition + has_condition, )) + .icon_size(IconSize::Small) .icon_color(color_for_toggle(has_condition)) + .when(has_condition, |this| this.indicator(Indicator::dot().color(Color::Info))) .disabled(!supports_condition) .toggle_state(self.is_toggled(ActiveBreakpointStripMode::Condition)) .on_click(self.on_click_callback(ActiveBreakpointStripMode::Condition)) - .tooltip(|window, cx| Tooltip::with_meta("Set Condition", None, "Set condition to evaluate when a breakpoint is hit. Program execution will stop only when the condition is met", window, cx)) + .tooltip(|window, cx| { + Tooltip::with_meta( + "Set Condition", + None, + "Set condition to evaluate when a breakpoint is hit. Program execution will stop only when the condition is met.", + window, + cx, + ) + }), ) .when(!has_condition && !self.is_selected, |this| this.invisible()), ) .child( - div().map(self.add_border( - ActiveBreakpointStripMode::HitCondition, - supports_hit_condition,window, cx - )) + div() + .map(self.add_focus_styles( + ActiveBreakpointStripMode::HitCondition, + supports_hit_condition, + window, + cx, + )) .child( IconButton::new( SharedString::from(format!("{id}-hit-condition-toggle")), IconName::ArrowDown10, ) - .icon_size(IconSize::XSmall) .style(style_for_toggle( ActiveBreakpointStripMode::HitCondition, has_hit_condition, )) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) .icon_color(color_for_toggle(has_hit_condition)) + .when(has_hit_condition, |this| this.indicator(Indicator::dot().color(Color::Info))) .disabled(!supports_hit_condition) .toggle_state(self.is_toggled(ActiveBreakpointStripMode::HitCondition)) - .on_click(self.on_click_callback(ActiveBreakpointStripMode::HitCondition)).tooltip(|window, cx| Tooltip::with_meta("Set Hit Condition", None, "Set expression that controls how many hits of the breakpoint are ignored.", window, cx)) + .on_click(self.on_click_callback(ActiveBreakpointStripMode::HitCondition)) + .tooltip(|window, cx| { + Tooltip::with_meta( + "Set Hit Condition", + None, + "Set expression that controls how many hits of the breakpoint are ignored.", + window, + cx, + ) + }), ) .when(!has_hit_condition && !self.is_selected, |this| { this.invisible() diff --git a/crates/debugger_ui/src/session/running/console.rs b/crates/debugger_ui/src/session/running/console.rs index daf4486f81..e6308518e4 100644 --- a/crates/debugger_ui/src/session/running/console.rs +++ b/crates/debugger_ui/src/session/running/console.rs @@ -367,7 +367,7 @@ impl Console { .when_some(keybinding_target.clone(), |el, keybinding_target| { el.context(keybinding_target.clone()) }) - .action("Watch expression", WatchExpression.boxed_clone()) + .action("Watch Expression", WatchExpression.boxed_clone()) })) }) }, @@ -452,18 +452,22 @@ impl Render for Console { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let query_focus_handle = self.query_bar.focus_handle(cx); self.update_output(window, cx); + v_flex() .track_focus(&self.focus_handle) .key_context("DebugConsole") .on_action(cx.listener(Self::evaluate)) .on_action(cx.listener(Self::watch_expression)) .size_full() + .border_2() + .bg(cx.theme().colors().editor_background) .child(self.render_console(cx)) .when(self.is_running(cx), |this| { this.child(Divider::horizontal()).child( h_flex() .on_action(cx.listener(Self::previous_query)) .on_action(cx.listener(Self::next_query)) + .p_1() .gap_1() .bg(cx.theme().colors().editor_background) .child(self.render_query_bar(cx)) @@ -474,6 +478,9 @@ impl Render for Console { .on_click(move |_, window, cx| { window.dispatch_action(Box::new(Confirm), cx) }) + .layer(ui::ElevationIndex::ModalSurface) + .size(ui::ButtonSize::Compact) + .child(Label::new("Evaluate")) .tooltip({ let query_focus_handle = query_focus_handle.clone(); @@ -486,10 +493,7 @@ impl Render for Console { cx, ) } - }) - .layer(ui::ElevationIndex::ModalSurface) - .size(ui::ButtonSize::Compact) - .child(Label::new("Evaluate")), + }), self.render_submit_menu( ElementId::Name("split-button-right-confirm-button".into()), Some(query_focus_handle.clone()), @@ -499,7 +503,6 @@ impl Render for Console { )), ) }) - .border_2() } } diff --git a/crates/debugger_ui/src/session/running/memory_view.rs b/crates/debugger_ui/src/session/running/memory_view.rs index 75b8938371..f936d908b1 100644 --- a/crates/debugger_ui/src/session/running/memory_view.rs +++ b/crates/debugger_ui/src/session/running/memory_view.rs @@ -18,10 +18,8 @@ use project::debugger::{MemoryCell, dap_command::DataBreakpointContext, session: use settings::Settings; use theme::ThemeSettings; use ui::{ - ActiveTheme, AnyElement, App, Color, Context, ContextMenu, Div, Divider, DropdownMenu, Element, - FluentBuilder, Icon, IconName, InteractiveElement, IntoElement, Label, LabelCommon, - ParentElement, Pixels, PopoverMenuHandle, Render, Scrollbar, ScrollbarState, SharedString, - StatefulInteractiveElement, Styled, TextSize, Tooltip, Window, div, h_flex, px, v_flex, + ContextMenu, Divider, DropdownMenu, FluentBuilder, IntoElement, PopoverMenuHandle, Render, + Scrollbar, ScrollbarState, StatefulInteractiveElement, Tooltip, prelude::*, }; use workspace::Workspace; From 6bd2f8758ee0d81aa6f31e0590f1f2270847ba9c Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Sun, 10 Aug 2025 22:32:25 +0300 Subject: [PATCH 042/109] Simplify the lock usage (#35957) Follow-up of https://github.com/zed-industries/zed/pull/35955 Release Notes: - N/A Co-authored-by: Piotr Osiewicz --- crates/onboarding/src/theme_preview.rs | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/crates/onboarding/src/theme_preview.rs b/crates/onboarding/src/theme_preview.rs index 9d86137b0b..9f299eb6ea 100644 --- a/crates/onboarding/src/theme_preview.rs +++ b/crates/onboarding/src/theme_preview.rs @@ -2,7 +2,7 @@ use gpui::{Hsla, Length}; use std::{ cell::LazyCell, - sync::{Arc, OnceLock}, + sync::{Arc, LazyLock, OnceLock}, }; use theme::{Theme, ThemeColors, ThemeRegistry}; use ui::{ @@ -25,17 +25,14 @@ pub struct ThemePreviewTile { style: ThemePreviewStyle, } -fn child_radius() -> Pixels { - static CHILD_RADIUS: OnceLock = OnceLock::new(); - *CHILD_RADIUS.get_or_init(|| { - inner_corner_radius( - ThemePreviewTile::ROOT_RADIUS, - ThemePreviewTile::ROOT_BORDER, - ThemePreviewTile::ROOT_PADDING, - ThemePreviewTile::CHILD_BORDER, - ) - }) -} +static CHILD_RADIUS: LazyLock = LazyLock::new(|| { + inner_corner_radius( + ThemePreviewTile::ROOT_RADIUS, + ThemePreviewTile::ROOT_BORDER, + ThemePreviewTile::ROOT_PADDING, + ThemePreviewTile::CHILD_BORDER, + ) +}); impl ThemePreviewTile { pub const SKELETON_HEIGHT_DEFAULT: Pixels = px(2.); @@ -229,7 +226,7 @@ impl ThemePreviewTile { .child( div() .size_full() - .rounded(child_radius()) + .rounded(*CHILD_RADIUS) .border(Self::CHILD_BORDER) .border_color(theme.colors().border) .child(Self::render_editor( @@ -257,7 +254,7 @@ impl ThemePreviewTile { h_flex() .size_full() .relative() - .rounded(child_radius()) + .rounded(*CHILD_RADIUS) .border(Self::CHILD_BORDER) .border_color(border_color) .overflow_hidden() From 72761797a25ced34a73c171d64d15378f8219914 Mon Sep 17 00:00:00 2001 From: jingyuexing <19589872+jingyuexing@users.noreply.github.com> Date: Mon, 11 Aug 2025 03:40:14 +0800 Subject: [PATCH 043/109] Fix SHA-256 verification mismatch when downloading language servers (#35953) Closes #35642 Release Notes: - Fixed: when the expected digest included a "sha256:" prefix while the computed digest has no prefix. --- crates/languages/src/github_download.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/languages/src/github_download.rs b/crates/languages/src/github_download.rs index a3cd0a964b..04f5ecfa08 100644 --- a/crates/languages/src/github_download.rs +++ b/crates/languages/src/github_download.rs @@ -62,6 +62,12 @@ pub(crate) async fn download_server_binary( format!("saving archive contents into the temporary file for {url}",) })?; let asset_sha_256 = format!("{:x}", writer.hasher.finalize()); + + // Strip "sha256:" prefix for comparison + let expected_sha_256 = expected_sha_256 + .strip_prefix("sha256:") + .unwrap_or(expected_sha_256); + anyhow::ensure!( asset_sha_256 == expected_sha_256, "{url} asset got SHA-256 mismatch. Expected: {expected_sha_256}, Got: {asset_sha_256}", From 308cb9e537eda81b35bfccef00e2ef7be8d070d1 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Sun, 10 Aug 2025 23:57:55 +0200 Subject: [PATCH 044/109] Pull action_log into its own crate (#35959) Release Notes: - N/A --- Cargo.lock | 36 +++++++++++++-- Cargo.toml | 2 + crates/acp_thread/Cargo.toml | 2 +- crates/acp_thread/src/acp_thread.rs | 2 +- crates/action_log/Cargo.toml | 45 +++++++++++++++++++ crates/action_log/LICENSE-GPL | 1 + .../src/action_log.rs | 0 crates/agent/Cargo.toml | 1 + crates/agent/src/agent_profile.rs | 2 +- crates/agent/src/context_server_tool.rs | 3 +- crates/agent/src/thread.rs | 3 +- crates/agent2/Cargo.toml | 5 ++- crates/agent2/src/agent.rs | 6 +-- crates/agent2/src/native_agent_server.rs | 2 +- crates/agent2/src/tests/mod.rs | 40 +++++++++-------- crates/agent2/src/thread.rs | 7 +-- crates/agent2/src/tools/edit_file_tool.rs | 4 +- crates/agent2/src/tools/find_path_tool.rs | 2 +- crates/agent2/src/tools/read_file_tool.rs | 12 ++--- crates/agent_ui/Cargo.toml | 1 + crates/agent_ui/src/acp/thread_view.rs | 2 +- crates/agent_ui/src/agent_diff.rs | 2 +- crates/assistant_tool/Cargo.toml | 5 +-- crates/assistant_tool/src/assistant_tool.rs | 3 +- crates/assistant_tool/src/outline.rs | 2 +- crates/assistant_tools/Cargo.toml | 1 + crates/assistant_tools/src/copy_path_tool.rs | 3 +- .../src/create_directory_tool.rs | 3 +- .../assistant_tools/src/delete_path_tool.rs | 3 +- .../assistant_tools/src/diagnostics_tool.rs | 3 +- crates/assistant_tools/src/edit_agent.rs | 2 +- crates/assistant_tools/src/edit_file_tool.rs | 4 +- crates/assistant_tools/src/fetch_tool.rs | 3 +- crates/assistant_tools/src/find_path_tool.rs | 3 +- crates/assistant_tools/src/grep_tool.rs | 3 +- .../src/list_directory_tool.rs | 3 +- crates/assistant_tools/src/move_path_tool.rs | 3 +- crates/assistant_tools/src/now_tool.rs | 3 +- crates/assistant_tools/src/open_tool.rs | 3 +- .../src/project_notifications_tool.rs | 3 +- crates/assistant_tools/src/read_file_tool.rs | 5 ++- crates/assistant_tools/src/terminal_tool.rs | 3 +- crates/assistant_tools/src/thinking_tool.rs | 3 +- crates/assistant_tools/src/web_search_tool.rs | 3 +- crates/remote_server/Cargo.toml | 1 + .../remote_server/src/remote_editing_tests.rs | 2 +- tooling/workspace-hack/Cargo.toml | 4 +- 47 files changed, 177 insertions(+), 77 deletions(-) create mode 100644 crates/action_log/Cargo.toml create mode 120000 crates/action_log/LICENSE-GPL rename crates/{assistant_tool => action_log}/src/action_log.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 1ae4303c71..4bb36fdeee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6,9 +6,9 @@ version = 4 name = "acp_thread" version = "0.1.0" dependencies = [ + "action_log", "agent-client-protocol", "anyhow", - "assistant_tool", "buffer_diff", "editor", "env_logger 0.11.8", @@ -32,6 +32,32 @@ dependencies = [ "workspace-hack", ] +[[package]] +name = "action_log" +version = "0.1.0" +dependencies = [ + "anyhow", + "buffer_diff", + "clock", + "collections", + "ctor", + "futures 0.3.31", + "gpui", + "indoc", + "language", + "log", + "pretty_assertions", + "project", + "rand 0.8.5", + "serde_json", + "settings", + "text", + "util", + "watch", + "workspace-hack", + "zlog", +] + [[package]] name = "activity_indicator" version = "0.1.0" @@ -84,6 +110,7 @@ dependencies = [ name = "agent" version = "0.1.0" dependencies = [ + "action_log", "agent_settings", "anyhow", "assistant_context", @@ -156,6 +183,7 @@ name = "agent2" version = "0.1.0" dependencies = [ "acp_thread", + "action_log", "agent-client-protocol", "agent_servers", "agent_settings", @@ -261,6 +289,7 @@ name = "agent_ui" version = "0.1.0" dependencies = [ "acp_thread", + "action_log", "agent", "agent-client-protocol", "agent2", @@ -842,13 +871,13 @@ dependencies = [ name = "assistant_tool" version = "0.1.0" dependencies = [ + "action_log", "anyhow", "buffer_diff", "clock", "collections", "ctor", "derive_more 0.99.19", - "futures 0.3.31", "gpui", "icons", "indoc", @@ -865,7 +894,6 @@ dependencies = [ "settings", "text", "util", - "watch", "workspace", "workspace-hack", "zlog", @@ -875,6 +903,7 @@ dependencies = [ name = "assistant_tools" version = "0.1.0" dependencies = [ + "action_log", "agent_settings", "anyhow", "assistant_tool", @@ -13523,6 +13552,7 @@ dependencies = [ name = "remote_server" version = "0.1.0" dependencies = [ + "action_log", "anyhow", "askpass", "assistant_tool", diff --git a/Cargo.toml b/Cargo.toml index d6ca4c664d..48a11c27da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "crates/acp_thread", + "crates/action_log", "crates/activity_indicator", "crates/agent", "crates/agent2", @@ -229,6 +230,7 @@ edition = "2024" # acp_thread = { path = "crates/acp_thread" } +action_log = { path = "crates/action_log" } agent = { path = "crates/agent" } agent2 = { path = "crates/agent2" } activity_indicator = { path = "crates/activity_indicator" } diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index 1831c7e473..37d2920045 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -16,9 +16,9 @@ doctest = false test-support = ["gpui/test-support", "project/test-support"] [dependencies] +action_log.workspace = true agent-client-protocol.workspace = true anyhow.workspace = true -assistant_tool.workspace = true buffer_diff.workspace = true editor.workspace = true futures.workspace = true diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 1df0e1def7..f2bebf7391 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -4,9 +4,9 @@ mod diff; pub use connection::*; pub use diff::*; +use action_log::ActionLog; use agent_client_protocol as acp; use anyhow::{Context as _, Result}; -use assistant_tool::ActionLog; use editor::Bias; use futures::{FutureExt, channel::oneshot, future::BoxFuture}; use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Task}; diff --git a/crates/action_log/Cargo.toml b/crates/action_log/Cargo.toml new file mode 100644 index 0000000000..1a389e8859 --- /dev/null +++ b/crates/action_log/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "action_log" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lib] +path = "src/action_log.rs" + +[lints] +workspace = true + +[dependencies] +anyhow.workspace = true +buffer_diff.workspace = true +clock.workspace = true +collections.workspace = true +futures.workspace = true +gpui.workspace = true +language.workspace = true +project.workspace = true +text.workspace = true +util.workspace = true +watch.workspace = true +workspace-hack.workspace = true + + +[dev-dependencies] +buffer_diff = { workspace = true, features = ["test-support"] } +collections = { workspace = true, features = ["test-support"] } +clock = { workspace = true, features = ["test-support"] } +ctor.workspace = true +gpui = { workspace = true, features = ["test-support"] } +indoc.workspace = true +language = { workspace = true, features = ["test-support"] } +log.workspace = true +pretty_assertions.workspace = true +project = { workspace = true, features = ["test-support"] } +rand.workspace = true +serde_json.workspace = true +settings = { workspace = true, features = ["test-support"] } +text = { workspace = true, features = ["test-support"] } +util = { workspace = true, features = ["test-support"] } +zlog.workspace = true diff --git a/crates/action_log/LICENSE-GPL b/crates/action_log/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/action_log/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/assistant_tool/src/action_log.rs b/crates/action_log/src/action_log.rs similarity index 100% rename from crates/assistant_tool/src/action_log.rs rename to crates/action_log/src/action_log.rs diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 7bc0e82cad..53ad2f4967 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -19,6 +19,7 @@ test-support = [ ] [dependencies] +action_log.workspace = true agent_settings.workspace = true anyhow.workspace = true assistant_context.workspace = true diff --git a/crates/agent/src/agent_profile.rs b/crates/agent/src/agent_profile.rs index 34ea1c8df7..38e697dd9b 100644 --- a/crates/agent/src/agent_profile.rs +++ b/crates/agent/src/agent_profile.rs @@ -326,7 +326,7 @@ mod tests { _input: serde_json::Value, _request: Arc, _project: Entity, - _action_log: Entity, + _action_log: Entity, _model: Arc, _window: Option, _cx: &mut App, diff --git a/crates/agent/src/context_server_tool.rs b/crates/agent/src/context_server_tool.rs index 85e8ac7451..22d1a72bf5 100644 --- a/crates/agent/src/context_server_tool.rs +++ b/crates/agent/src/context_server_tool.rs @@ -1,7 +1,8 @@ use std::sync::Arc; +use action_log::ActionLog; use anyhow::{Result, anyhow, bail}; -use assistant_tool::{ActionLog, Tool, ToolResult, ToolSource}; +use assistant_tool::{Tool, ToolResult, ToolSource}; use context_server::{ContextServerId, types}; use gpui::{AnyWindowHandle, App, Entity, Task}; use icons::IconName; diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 048aa4245d..20d482f60d 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -8,9 +8,10 @@ use crate::{ }, tool_use::{PendingToolUse, ToolUse, ToolUseMetadata, ToolUseState}, }; +use action_log::ActionLog; use agent_settings::{AgentProfileId, AgentSettings, CompletionMode, SUMMARIZE_THREAD_PROMPT}; use anyhow::{Result, anyhow}; -use assistant_tool::{ActionLog, AnyToolCard, Tool, ToolWorkingSet}; +use assistant_tool::{AnyToolCard, Tool, ToolWorkingSet}; use chrono::{DateTime, Utc}; use client::{ModelRequestUsage, RequestUsage}; use cloud_llm_client::{CompletionIntent, CompletionRequestStatus, Plan, UsageLimit}; diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml index 3e19895a31..c1c3f2d459 100644 --- a/crates/agent2/Cargo.toml +++ b/crates/agent2/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "agent2" version = "0.1.0" -edition = "2021" +edition.workspace = true +publish.workspace = true license = "GPL-3.0-or-later" -publish = false [lib] path = "src/agent2.rs" @@ -13,6 +13,7 @@ workspace = true [dependencies] acp_thread.workspace = true +action_log.workspace = true agent-client-protocol.workspace = true agent_servers.workspace = true agent_settings.workspace = true diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index 892469db47..5be3892d60 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -1,9 +1,9 @@ -use crate::{templates::Templates, AgentResponseEvent, Thread}; +use crate::{AgentResponseEvent, Thread, templates::Templates}; use crate::{EditFileTool, FindPathTool, ReadFileTool, ThinkingTool, ToolCallAuthorization}; use acp_thread::ModelSelector; use agent_client_protocol as acp; -use anyhow::{anyhow, Context as _, Result}; -use futures::{future, StreamExt}; +use anyhow::{Context as _, Result, anyhow}; +use futures::{StreamExt, future}; use gpui::{ App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity, }; diff --git a/crates/agent2/src/native_agent_server.rs b/crates/agent2/src/native_agent_server.rs index dd0188b548..58f6d37c54 100644 --- a/crates/agent2/src/native_agent_server.rs +++ b/crates/agent2/src/native_agent_server.rs @@ -7,7 +7,7 @@ use gpui::{App, Entity, Task}; use project::Project; use prompt_store::PromptStore; -use crate::{templates::Templates, NativeAgent, NativeAgentConnection}; +use crate::{NativeAgent, NativeAgentConnection, templates::Templates}; #[derive(Clone)] pub struct NativeAgentServer; diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index 6e0dc86091..b47816f35c 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -1,17 +1,17 @@ use super::*; use acp_thread::AgentConnection; +use action_log::ActionLog; use agent_client_protocol::{self as acp}; use anyhow::Result; -use assistant_tool::ActionLog; use client::{Client, UserStore}; use fs::FakeFs; use futures::channel::mpsc::UnboundedReceiver; -use gpui::{http_client::FakeHttpClient, AppContext, Entity, Task, TestAppContext}; +use gpui::{AppContext, Entity, Task, TestAppContext, http_client::FakeHttpClient}; use indoc::indoc; use language_model::{ - fake_provider::FakeLanguageModel, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelId, LanguageModelRegistry, LanguageModelToolResult, - LanguageModelToolUse, MessageContent, Role, StopReason, + LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, + LanguageModelRegistry, LanguageModelToolResult, LanguageModelToolUse, MessageContent, Role, + StopReason, fake_provider::FakeLanguageModel, }; use project::Project; use prompt_store::ProjectContext; @@ -149,19 +149,21 @@ async fn test_basic_tool_calls(cx: &mut TestAppContext) { .await; assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]); thread.update(cx, |thread, _cx| { - assert!(thread - .messages() - .last() - .unwrap() - .content - .iter() - .any(|content| { - if let MessageContent::Text(text) = content { - text.contains("Ding") - } else { - false - } - })); + assert!( + thread + .messages() + .last() + .unwrap() + .content + .iter() + .any(|content| { + if let MessageContent::Text(text) = content { + text.contains("Ding") + } else { + false + } + }) + ); }); } @@ -333,7 +335,7 @@ async fn expect_tool_call_update_fields( .unwrap(); match event { AgentResponseEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update)) => { - return update + return update; } event => { panic!("Unexpected event {event:?}"); diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 8ed200b56b..a0a2a3a2b0 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -1,8 +1,9 @@ use crate::{SystemPromptTemplate, Template, Templates}; use acp_thread::Diff; +use action_log::ActionLog; use agent_client_protocol as acp; -use anyhow::{anyhow, Context as _, Result}; -use assistant_tool::{adapt_schema_to_format, ActionLog}; +use anyhow::{Context as _, Result, anyhow}; +use assistant_tool::adapt_schema_to_format; use cloud_llm_client::{CompletionIntent, CompletionMode}; use collections::HashMap; use futures::{ @@ -23,7 +24,7 @@ use schemars::{JsonSchema, Schema}; use serde::{Deserialize, Serialize}; use smol::stream::StreamExt; use std::{cell::RefCell, collections::BTreeMap, fmt::Write, future::Future, rc::Rc, sync::Arc}; -use util::{markdown::MarkdownCodeBlock, ResultExt}; +use util::{ResultExt, markdown::MarkdownCodeBlock}; #[derive(Debug, Clone)] pub struct AgentMessage { diff --git a/crates/agent2/src/tools/edit_file_tool.rs b/crates/agent2/src/tools/edit_file_tool.rs index 0858bb501c..48e5d37586 100644 --- a/crates/agent2/src/tools/edit_file_tool.rs +++ b/crates/agent2/src/tools/edit_file_tool.rs @@ -1,7 +1,7 @@ use crate::{AgentTool, Thread, ToolCallEventStream}; use acp_thread::Diff; use agent_client_protocol as acp; -use anyhow::{anyhow, Context as _, Result}; +use anyhow::{Context as _, Result, anyhow}; use assistant_tools::edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat}; use cloud_llm_client::CompletionIntent; use collections::HashSet; @@ -457,7 +457,7 @@ mod tests { use crate::Templates; use super::*; - use assistant_tool::ActionLog; + use action_log::ActionLog; use client::TelemetrySettings; use fs::Fs; use gpui::{TestAppContext, UpdateGlobal}; diff --git a/crates/agent2/src/tools/find_path_tool.rs b/crates/agent2/src/tools/find_path_tool.rs index f4589e5600..611d34e701 100644 --- a/crates/agent2/src/tools/find_path_tool.rs +++ b/crates/agent2/src/tools/find_path_tool.rs @@ -1,6 +1,6 @@ use crate::{AgentTool, ToolCallEventStream}; use agent_client_protocol as acp; -use anyhow::{anyhow, Result}; +use anyhow::{Result, anyhow}; use gpui::{App, AppContext, Entity, SharedString, Task}; use language_model::LanguageModelToolResultContent; use project::Project; diff --git a/crates/agent2/src/tools/read_file_tool.rs b/crates/agent2/src/tools/read_file_tool.rs index 7bbe3ac4c1..fac637d838 100644 --- a/crates/agent2/src/tools/read_file_tool.rs +++ b/crates/agent2/src/tools/read_file_tool.rs @@ -1,16 +1,16 @@ +use action_log::ActionLog; use agent_client_protocol::{self as acp}; -use anyhow::{anyhow, Context, Result}; -use assistant_tool::{outline, ActionLog}; -use gpui::{Entity, Task}; +use anyhow::{Context as _, Result, anyhow}; +use assistant_tool::outline; +use gpui::{App, Entity, SharedString, Task}; use indoc::formatdoc; use language::{Anchor, Point}; use language_model::{LanguageModelImage, LanguageModelToolResultContent}; -use project::{image_store, AgentLocation, ImageItem, Project, WorktreeSettings}; +use project::{AgentLocation, ImageItem, Project, WorktreeSettings, image_store}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::Settings; use std::sync::Arc; -use ui::{App, SharedString}; use crate::{AgentTool, ToolCallEventStream}; @@ -270,7 +270,7 @@ impl AgentTool for ReadFileTool { mod test { use super::*; use gpui::{AppContext, TestAppContext, UpdateGlobal as _}; - use language::{tree_sitter_rust, Language, LanguageConfig, LanguageMatcher}; + use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_rust}; use project::{FakeFs, Project}; use serde_json::json; use settings::SettingsStore; diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index c145df0eae..de0a27c2cb 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -17,6 +17,7 @@ test-support = ["gpui/test-support", "language/test-support"] [dependencies] acp_thread.workspace = true +action_log.workspace = true agent-client-protocol.workspace = true agent.workspace = true agent2.workspace = true diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index c811878c21..01980b8fb7 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -10,8 +10,8 @@ use std::rc::Rc; use std::sync::Arc; use std::time::Duration; +use action_log::ActionLog; use agent_client_protocol as acp; -use assistant_tool::ActionLog; use buffer_diff::BufferDiff; use collections::{HashMap, HashSet}; use editor::{ diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index e1ceaf761d..0abc5280f4 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -1,9 +1,9 @@ use crate::{Keep, KeepAll, OpenAgentDiff, Reject, RejectAll}; use acp_thread::{AcpThread, AcpThreadEvent}; +use action_log::ActionLog; use agent::{Thread, ThreadEvent, ThreadSummary}; use agent_settings::AgentSettings; use anyhow::Result; -use assistant_tool::ActionLog; use buffer_diff::DiffHunkStatus; use collections::{HashMap, HashSet}; use editor::{ diff --git a/crates/assistant_tool/Cargo.toml b/crates/assistant_tool/Cargo.toml index acbe674b02..c95695052a 100644 --- a/crates/assistant_tool/Cargo.toml +++ b/crates/assistant_tool/Cargo.toml @@ -12,12 +12,10 @@ workspace = true path = "src/assistant_tool.rs" [dependencies] +action_log.workspace = true anyhow.workspace = true -buffer_diff.workspace = true -clock.workspace = true collections.workspace = true derive_more.workspace = true -futures.workspace = true gpui.workspace = true icons.workspace = true language.workspace = true @@ -30,7 +28,6 @@ serde.workspace = true serde_json.workspace = true text.workspace = true util.workspace = true -watch.workspace = true workspace.workspace = true workspace-hack.workspace = true diff --git a/crates/assistant_tool/src/assistant_tool.rs b/crates/assistant_tool/src/assistant_tool.rs index 22cbaac3f8..9c5825d0f0 100644 --- a/crates/assistant_tool/src/assistant_tool.rs +++ b/crates/assistant_tool/src/assistant_tool.rs @@ -1,4 +1,3 @@ -mod action_log; pub mod outline; mod tool_registry; mod tool_schema; @@ -10,6 +9,7 @@ use std::fmt::Formatter; use std::ops::Deref; use std::sync::Arc; +use action_log::ActionLog; use anyhow::Result; use gpui::AnyElement; use gpui::AnyWindowHandle; @@ -25,7 +25,6 @@ use language_model::LanguageModelToolSchemaFormat; use project::Project; use workspace::Workspace; -pub use crate::action_log::*; pub use crate::tool_registry::*; pub use crate::tool_schema::*; pub use crate::tool_working_set::*; diff --git a/crates/assistant_tool/src/outline.rs b/crates/assistant_tool/src/outline.rs index 6af204d79a..4f8bde5456 100644 --- a/crates/assistant_tool/src/outline.rs +++ b/crates/assistant_tool/src/outline.rs @@ -1,4 +1,4 @@ -use crate::ActionLog; +use action_log::ActionLog; use anyhow::{Context as _, Result}; use gpui::{AsyncApp, Entity}; use language::{OutlineItem, ParseStatus}; diff --git a/crates/assistant_tools/Cargo.toml b/crates/assistant_tools/Cargo.toml index d4b8fa3afc..5a8ca8a5e9 100644 --- a/crates/assistant_tools/Cargo.toml +++ b/crates/assistant_tools/Cargo.toml @@ -15,6 +15,7 @@ path = "src/assistant_tools.rs" eval = [] [dependencies] +action_log.workspace = true agent_settings.workspace = true anyhow.workspace = true assistant_tool.workspace = true diff --git a/crates/assistant_tools/src/copy_path_tool.rs b/crates/assistant_tools/src/copy_path_tool.rs index e34ae9ff93..c56a864bd4 100644 --- a/crates/assistant_tools/src/copy_path_tool.rs +++ b/crates/assistant_tools/src/copy_path_tool.rs @@ -1,6 +1,7 @@ use crate::schema::json_schema_for; +use action_log::ActionLog; use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{ActionLog, Tool, ToolResult}; +use assistant_tool::{Tool, ToolResult}; use gpui::AnyWindowHandle; use gpui::{App, AppContext, Entity, Task}; use language_model::LanguageModel; diff --git a/crates/assistant_tools/src/create_directory_tool.rs b/crates/assistant_tools/src/create_directory_tool.rs index 11d969d234..85eea463dc 100644 --- a/crates/assistant_tools/src/create_directory_tool.rs +++ b/crates/assistant_tools/src/create_directory_tool.rs @@ -1,6 +1,7 @@ use crate::schema::json_schema_for; +use action_log::ActionLog; use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{ActionLog, Tool, ToolResult}; +use assistant_tool::{Tool, ToolResult}; use gpui::AnyWindowHandle; use gpui::{App, Entity, Task}; use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; diff --git a/crates/assistant_tools/src/delete_path_tool.rs b/crates/assistant_tools/src/delete_path_tool.rs index 9e69c18b65..b181eeff5c 100644 --- a/crates/assistant_tools/src/delete_path_tool.rs +++ b/crates/assistant_tools/src/delete_path_tool.rs @@ -1,6 +1,7 @@ use crate::schema::json_schema_for; +use action_log::ActionLog; use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{ActionLog, Tool, ToolResult}; +use assistant_tool::{Tool, ToolResult}; use futures::{SinkExt, StreamExt, channel::mpsc}; use gpui::{AnyWindowHandle, App, AppContext, Entity, Task}; use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; diff --git a/crates/assistant_tools/src/diagnostics_tool.rs b/crates/assistant_tools/src/diagnostics_tool.rs index 12ab97f820..bc479eb596 100644 --- a/crates/assistant_tools/src/diagnostics_tool.rs +++ b/crates/assistant_tools/src/diagnostics_tool.rs @@ -1,6 +1,7 @@ use crate::schema::json_schema_for; +use action_log::ActionLog; use anyhow::{Result, anyhow}; -use assistant_tool::{ActionLog, Tool, ToolResult}; +use assistant_tool::{Tool, ToolResult}; use gpui::{AnyWindowHandle, App, Entity, Task}; use language::{DiagnosticSeverity, OffsetRangeExt}; use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; diff --git a/crates/assistant_tools/src/edit_agent.rs b/crates/assistant_tools/src/edit_agent.rs index dcb14a48f3..9305f584cb 100644 --- a/crates/assistant_tools/src/edit_agent.rs +++ b/crates/assistant_tools/src/edit_agent.rs @@ -5,8 +5,8 @@ mod evals; mod streaming_fuzzy_matcher; use crate::{Template, Templates}; +use action_log::ActionLog; use anyhow::Result; -use assistant_tool::ActionLog; use cloud_llm_client::CompletionIntent; use create_file_parser::{CreateFileParser, CreateFileParserEvent}; pub use edit_parser::EditFormat; diff --git a/crates/assistant_tools/src/edit_file_tool.rs b/crates/assistant_tools/src/edit_file_tool.rs index 54431ee1d7..b5712415ec 100644 --- a/crates/assistant_tools/src/edit_file_tool.rs +++ b/crates/assistant_tools/src/edit_file_tool.rs @@ -4,11 +4,11 @@ use crate::{ schema::json_schema_for, ui::{COLLAPSED_LINES, ToolOutputPreview}, }; +use action_log::ActionLog; use agent_settings; use anyhow::{Context as _, Result, anyhow}; use assistant_tool::{ - ActionLog, AnyToolCard, Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, - ToolUseStatus, + AnyToolCard, Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, ToolUseStatus, }; use buffer_diff::{BufferDiff, BufferDiffSnapshot}; use editor::{Editor, EditorMode, MinimapVisibility, MultiBuffer, PathKey}; diff --git a/crates/assistant_tools/src/fetch_tool.rs b/crates/assistant_tools/src/fetch_tool.rs index a31ec39268..79e205f205 100644 --- a/crates/assistant_tools/src/fetch_tool.rs +++ b/crates/assistant_tools/src/fetch_tool.rs @@ -3,8 +3,9 @@ use std::sync::Arc; use std::{borrow::Cow, cell::RefCell}; use crate::schema::json_schema_for; +use action_log::ActionLog; use anyhow::{Context as _, Result, anyhow, bail}; -use assistant_tool::{ActionLog, Tool, ToolResult}; +use assistant_tool::{Tool, ToolResult}; use futures::AsyncReadExt as _; use gpui::{AnyWindowHandle, App, AppContext as _, Entity, Task}; use html_to_markdown::{TagHandler, convert_html_to_markdown, markdown}; diff --git a/crates/assistant_tools/src/find_path_tool.rs b/crates/assistant_tools/src/find_path_tool.rs index 6cdf58eac8..6b62638a4c 100644 --- a/crates/assistant_tools/src/find_path_tool.rs +++ b/crates/assistant_tools/src/find_path_tool.rs @@ -1,7 +1,8 @@ use crate::{schema::json_schema_for, ui::ToolCallCardHeader}; +use action_log::ActionLog; use anyhow::{Result, anyhow}; use assistant_tool::{ - ActionLog, Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, ToolUseStatus, + Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, ToolUseStatus, }; use editor::Editor; use futures::channel::oneshot::{self, Receiver}; diff --git a/crates/assistant_tools/src/grep_tool.rs b/crates/assistant_tools/src/grep_tool.rs index 43c3d1d990..a5ce07823f 100644 --- a/crates/assistant_tools/src/grep_tool.rs +++ b/crates/assistant_tools/src/grep_tool.rs @@ -1,6 +1,7 @@ use crate::schema::json_schema_for; +use action_log::ActionLog; use anyhow::{Result, anyhow}; -use assistant_tool::{ActionLog, Tool, ToolResult}; +use assistant_tool::{Tool, ToolResult}; use futures::StreamExt; use gpui::{AnyWindowHandle, App, Entity, Task}; use language::{OffsetRangeExt, ParseStatus, Point}; diff --git a/crates/assistant_tools/src/list_directory_tool.rs b/crates/assistant_tools/src/list_directory_tool.rs index b1980615d6..5471d8923b 100644 --- a/crates/assistant_tools/src/list_directory_tool.rs +++ b/crates/assistant_tools/src/list_directory_tool.rs @@ -1,6 +1,7 @@ use crate::schema::json_schema_for; +use action_log::ActionLog; use anyhow::{Result, anyhow}; -use assistant_tool::{ActionLog, Tool, ToolResult}; +use assistant_tool::{Tool, ToolResult}; use gpui::{AnyWindowHandle, App, Entity, Task}; use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; use project::{Project, WorktreeSettings}; diff --git a/crates/assistant_tools/src/move_path_tool.rs b/crates/assistant_tools/src/move_path_tool.rs index c1cbbf848d..2c065488ce 100644 --- a/crates/assistant_tools/src/move_path_tool.rs +++ b/crates/assistant_tools/src/move_path_tool.rs @@ -1,6 +1,7 @@ use crate::schema::json_schema_for; +use action_log::ActionLog; use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{ActionLog, Tool, ToolResult}; +use assistant_tool::{Tool, ToolResult}; use gpui::{AnyWindowHandle, App, AppContext, Entity, Task}; use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; use project::Project; diff --git a/crates/assistant_tools/src/now_tool.rs b/crates/assistant_tools/src/now_tool.rs index b51b91d3d5..f50ad065d1 100644 --- a/crates/assistant_tools/src/now_tool.rs +++ b/crates/assistant_tools/src/now_tool.rs @@ -1,8 +1,9 @@ use std::sync::Arc; use crate::schema::json_schema_for; +use action_log::ActionLog; use anyhow::{Result, anyhow}; -use assistant_tool::{ActionLog, Tool, ToolResult}; +use assistant_tool::{Tool, ToolResult}; use chrono::{Local, Utc}; use gpui::{AnyWindowHandle, App, Entity, Task}; use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; diff --git a/crates/assistant_tools/src/open_tool.rs b/crates/assistant_tools/src/open_tool.rs index 8fddbb0431..6dbf66749b 100644 --- a/crates/assistant_tools/src/open_tool.rs +++ b/crates/assistant_tools/src/open_tool.rs @@ -1,6 +1,7 @@ use crate::schema::json_schema_for; +use action_log::ActionLog; use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{ActionLog, Tool, ToolResult}; +use assistant_tool::{Tool, ToolResult}; use gpui::{AnyWindowHandle, App, AppContext, Entity, Task}; use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; use project::Project; diff --git a/crates/assistant_tools/src/project_notifications_tool.rs b/crates/assistant_tools/src/project_notifications_tool.rs index 03487e5419..c65cfd0ca7 100644 --- a/crates/assistant_tools/src/project_notifications_tool.rs +++ b/crates/assistant_tools/src/project_notifications_tool.rs @@ -1,6 +1,7 @@ use crate::schema::json_schema_for; +use action_log::ActionLog; use anyhow::Result; -use assistant_tool::{ActionLog, Tool, ToolResult}; +use assistant_tool::{Tool, ToolResult}; use gpui::{AnyWindowHandle, App, Entity, Task}; use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; use project::Project; diff --git a/crates/assistant_tools/src/read_file_tool.rs b/crates/assistant_tools/src/read_file_tool.rs index ee38273cc0..68b870e40f 100644 --- a/crates/assistant_tools/src/read_file_tool.rs +++ b/crates/assistant_tools/src/read_file_tool.rs @@ -1,6 +1,7 @@ use crate::schema::json_schema_for; +use action_log::ActionLog; use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{ActionLog, Tool, ToolResult}; +use assistant_tool::{Tool, ToolResult}; use assistant_tool::{ToolResultContent, outline}; use gpui::{AnyWindowHandle, App, Entity, Task}; use project::{ImageItem, image_store}; @@ -286,7 +287,7 @@ impl Tool for ReadFileTool { Using the line numbers in this outline, you can call this tool again while specifying the start_line and end_line fields to see the implementations of symbols in the outline. - + Alternatively, you can fall back to the `grep` tool (if available) to search the file for specific content." } diff --git a/crates/assistant_tools/src/terminal_tool.rs b/crates/assistant_tools/src/terminal_tool.rs index 8add60f09a..46227f130d 100644 --- a/crates/assistant_tools/src/terminal_tool.rs +++ b/crates/assistant_tools/src/terminal_tool.rs @@ -2,9 +2,10 @@ use crate::{ schema::json_schema_for, ui::{COLLAPSED_LINES, ToolOutputPreview}, }; +use action_log::ActionLog; use agent_settings; use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{ActionLog, Tool, ToolCard, ToolResult, ToolUseStatus}; +use assistant_tool::{Tool, ToolCard, ToolResult, ToolUseStatus}; use futures::{FutureExt as _, future::Shared}; use gpui::{ Animation, AnimationExt, AnyWindowHandle, App, AppContext, Empty, Entity, EntityId, Task, diff --git a/crates/assistant_tools/src/thinking_tool.rs b/crates/assistant_tools/src/thinking_tool.rs index 76c6e6c0ba..17ce4afc2e 100644 --- a/crates/assistant_tools/src/thinking_tool.rs +++ b/crates/assistant_tools/src/thinking_tool.rs @@ -1,8 +1,9 @@ use std::sync::Arc; use crate::schema::json_schema_for; +use action_log::ActionLog; use anyhow::{Result, anyhow}; -use assistant_tool::{ActionLog, Tool, ToolResult}; +use assistant_tool::{Tool, ToolResult}; use gpui::{AnyWindowHandle, App, Entity, Task}; use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; use project::Project; diff --git a/crates/assistant_tools/src/web_search_tool.rs b/crates/assistant_tools/src/web_search_tool.rs index c6c37de472..47a6958b7a 100644 --- a/crates/assistant_tools/src/web_search_tool.rs +++ b/crates/assistant_tools/src/web_search_tool.rs @@ -2,9 +2,10 @@ use std::{sync::Arc, time::Duration}; use crate::schema::json_schema_for; use crate::ui::ToolCallCardHeader; +use action_log::ActionLog; use anyhow::{Context as _, Result, anyhow}; use assistant_tool::{ - ActionLog, Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, ToolUseStatus, + Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, ToolUseStatus, }; use cloud_llm_client::{WebSearchResponse, WebSearchResult}; use futures::{Future, FutureExt, TryFutureExt}; diff --git a/crates/remote_server/Cargo.toml b/crates/remote_server/Cargo.toml index c6a546f345..dcec9f6fe0 100644 --- a/crates/remote_server/Cargo.toml +++ b/crates/remote_server/Cargo.toml @@ -74,6 +74,7 @@ libc.workspace = true minidumper.workspace = true [dev-dependencies] +action_log.workspace = true assistant_tool.workspace = true assistant_tools.workspace = true client = { workspace = true, features = ["test-support"] } diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 9730984f26..514e5ce4c0 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -1724,7 +1724,7 @@ async fn test_remote_agent_fs_tool_calls(cx: &mut TestAppContext, server_cx: &mu .await .unwrap(); - let action_log = cx.new(|_| assistant_tool::ActionLog::new(project.clone())); + let action_log = cx.new(|_| action_log::ActionLog::new(project.clone())); let model = Arc::new(FakeLanguageModel::default()); let request = Arc::new(LanguageModelRequest::default()); diff --git a/tooling/workspace-hack/Cargo.toml b/tooling/workspace-hack/Cargo.toml index 338985ed95..054e757056 100644 --- a/tooling/workspace-hack/Cargo.toml +++ b/tooling/workspace-hack/Cargo.toml @@ -6,9 +6,9 @@ [package] name = "workspace-hack" version = "0.1.0" -edition = "2021" description = "workspace-hack package, managed by hakari" -publish = false +edition.workspace = true +publish.workspace = true # The parts of the file between the BEGIN HAKARI SECTION and END HAKARI SECTION comments # are managed by hakari. From c82cd0c6b1939a8638ef2a9d1e085d1584313509 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Sun, 10 Aug 2025 23:28:28 -0300 Subject: [PATCH 045/109] docs: Clarify storage of AI API keys (#35963) Previous docs was inaccurate as Zed doesn't store LLM API keys in the `settings.json`. Release Notes: - N/A --- docs/src/ai/llm-providers.md | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index 8fdb7ea325..64995e6eb8 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -6,29 +6,29 @@ You can do that by either subscribing to [one of Zed's plans](./plans-and-usage. ## Use Your Own Keys {#use-your-own-keys} -If you already have an API key for an existing LLM provider—say Anthropic or OpenAI, for example—you can insert them in Zed and use the Agent Panel **_for free_**. +If you already have an API key for an existing LLM provider—say Anthropic or OpenAI, for example—you can insert them into Zed and use the full power of the Agent Panel **_for free_**. -You can add your API key to a given provider either via the Agent Panel's settings UI or directly via the `settings.json` through the `language_models` key. +To add an existing API key to a given provider, go to the Agent Panel settings (`agent: open settings`), look for the desired provider, paste the key into the input, and hit enter. + +> Note: API keys are _not_ stored as plain text in your `settings.json`, but rather in your OS's secure credential storage. ## Supported Providers Here's all the supported LLM providers for which you can use your own API keys: -| Provider | -| ----------------------------------------------- | -| [Amazon Bedrock](#amazon-bedrock) | -| [Anthropic](#anthropic) | -| [DeepSeek](#deepseek) | -| [GitHub Copilot Chat](#github-copilot-chat) | -| [Google AI](#google-ai) | -| [LM Studio](#lmstudio) | -| [Mistral](#mistral) | -| [Ollama](#ollama) | -| [OpenAI](#openai) | -| [OpenAI API Compatible](#openai-api-compatible) | -| [OpenRouter](#openrouter) | -| [Vercel](#vercel-v0) | -| [xAI](#xai) | +- [Amazon Bedrock](#amazon-bedrock) +- [Anthropic](#anthropic) +- [DeepSeek](#deepseek) +- [GitHub Copilot Chat](#github-copilot-chat) +- [Google AI](#google-ai) +- [LM Studio](#lmstudio) +- [Mistral](#mistral) +- [Ollama](#ollama) +- [OpenAI](#openai) +- [OpenAI API Compatible](#openai-api-compatible) +- [OpenRouter](#openrouter) +- [Vercel](#vercel-v0) +- [xAI](#xai) ### Amazon Bedrock {#amazon-bedrock} From 8d332da4c5d61c11ab64667d2dad5c4199217119 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Mon, 11 Aug 2025 09:20:03 +0200 Subject: [PATCH 046/109] languages: Don't remove old artifacts on download failure (#35967) Release Notes: - N/A --- crates/http_client/src/github.rs | 12 +++++++++-- crates/languages/src/c.rs | 18 ++++++++-------- crates/languages/src/github_download.rs | 10 ++------- crates/languages/src/rust.rs | 28 ++++++++++++------------- 4 files changed, 35 insertions(+), 33 deletions(-) diff --git a/crates/http_client/src/github.rs b/crates/http_client/src/github.rs index a19c13b0ff..89309ff344 100644 --- a/crates/http_client/src/github.rs +++ b/crates/http_client/src/github.rs @@ -71,11 +71,19 @@ pub async fn latest_github_release( } }; - releases + let mut release = releases .into_iter() .filter(|release| !require_assets || !release.assets.is_empty()) .find(|release| release.pre_release == pre_release) - .context("finding a prerelease") + .context("finding a prerelease")?; + release.assets.iter_mut().for_each(|asset| { + if let Some(digest) = &mut asset.digest { + if let Some(stripped) = digest.strip_prefix("sha256:") { + *digest = stripped.to_owned(); + } + } + }); + Ok(release) } pub async fn get_release_by_tag_name( diff --git a/crates/languages/src/c.rs b/crates/languages/src/c.rs index df93e51760..aee1abee95 100644 --- a/crates/languages/src/c.rs +++ b/crates/languages/src/c.rs @@ -71,13 +71,13 @@ impl super::LspAdapter for CLspAdapter { container_dir: PathBuf, delegate: &dyn LspAdapterDelegate, ) -> Result { - let GitHubLspBinaryVersion { name, url, digest } = - &*version.downcast::().unwrap(); + let GitHubLspBinaryVersion { + name, + url, + digest: expected_digest, + } = *version.downcast::().unwrap(); let version_dir = container_dir.join(format!("clangd_{name}")); let binary_path = version_dir.join("bin/clangd"); - let expected_digest = digest - .as_ref() - .and_then(|digest| digest.strip_prefix("sha256:")); let binary = LanguageServerBinary { path: binary_path.clone(), @@ -103,7 +103,7 @@ impl super::LspAdapter for CLspAdapter { }) }; if let (Some(actual_digest), Some(expected_digest)) = - (&metadata.digest, expected_digest) + (&metadata.digest, &expected_digest) { if actual_digest == expected_digest { if validity_check().await.is_ok() { @@ -120,8 +120,8 @@ impl super::LspAdapter for CLspAdapter { } download_server_binary( delegate, - url, - digest.as_deref(), + &url, + expected_digest.as_deref(), &container_dir, AssetKind::Zip, ) @@ -130,7 +130,7 @@ impl super::LspAdapter for CLspAdapter { GithubBinaryMetadata::write_to_file( &GithubBinaryMetadata { metadata_version: 1, - digest: digest.clone(), + digest: expected_digest, }, &metadata_path, ) diff --git a/crates/languages/src/github_download.rs b/crates/languages/src/github_download.rs index 04f5ecfa08..5b0f1d0729 100644 --- a/crates/languages/src/github_download.rs +++ b/crates/languages/src/github_download.rs @@ -18,9 +18,8 @@ impl GithubBinaryMetadata { let metadata_content = async_fs::read_to_string(metadata_path) .await .with_context(|| format!("reading metadata file at {metadata_path:?}"))?; - let metadata: GithubBinaryMetadata = serde_json::from_str(&metadata_content) - .with_context(|| format!("parsing metadata file at {metadata_path:?}"))?; - Ok(metadata) + serde_json::from_str(&metadata_content) + .with_context(|| format!("parsing metadata file at {metadata_path:?}")) } pub(crate) async fn write_to_file(&self, metadata_path: &Path) -> Result<()> { @@ -63,11 +62,6 @@ pub(crate) async fn download_server_binary( })?; let asset_sha_256 = format!("{:x}", writer.hasher.finalize()); - // Strip "sha256:" prefix for comparison - let expected_sha_256 = expected_sha_256 - .strip_prefix("sha256:") - .unwrap_or(expected_sha_256); - anyhow::ensure!( asset_sha_256 == expected_sha_256, "{url} asset got SHA-256 mismatch. Expected: {expected_sha_256}, Got: {asset_sha_256}", diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index b52b1e7d55..1d489052e6 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -23,7 +23,7 @@ use std::{ sync::{Arc, LazyLock}, }; use task::{TaskTemplate, TaskTemplates, TaskVariables, VariableName}; -use util::fs::make_file_executable; +use util::fs::{make_file_executable, remove_matching}; use util::merge_json_value_into; use util::{ResultExt, maybe}; @@ -162,13 +162,13 @@ impl LspAdapter for RustLspAdapter { let asset_name = Self::build_asset_name(); let asset = release .assets - .iter() + .into_iter() .find(|asset| asset.name == asset_name) .with_context(|| format!("no asset found matching `{asset_name:?}`"))?; Ok(Box::new(GitHubLspBinaryVersion { name: release.tag_name, - url: asset.browser_download_url.clone(), - digest: asset.digest.clone(), + url: asset.browser_download_url, + digest: asset.digest, })) } @@ -178,11 +178,11 @@ impl LspAdapter for RustLspAdapter { container_dir: PathBuf, delegate: &dyn LspAdapterDelegate, ) -> Result { - let GitHubLspBinaryVersion { name, url, digest } = - &*version.downcast::().unwrap(); - let expected_digest = digest - .as_ref() - .and_then(|digest| digest.strip_prefix("sha256:")); + let GitHubLspBinaryVersion { + name, + url, + digest: expected_digest, + } = *version.downcast::().unwrap(); let destination_path = container_dir.join(format!("rust-analyzer-{name}")); let server_path = match Self::GITHUB_ASSET_KIND { AssetKind::TarGz | AssetKind::Gz => destination_path.clone(), // Tar and gzip extract in place. @@ -213,7 +213,7 @@ impl LspAdapter for RustLspAdapter { }) }; if let (Some(actual_digest), Some(expected_digest)) = - (&metadata.digest, expected_digest) + (&metadata.digest, &expected_digest) { if actual_digest == expected_digest { if validity_check().await.is_ok() { @@ -229,20 +229,20 @@ impl LspAdapter for RustLspAdapter { } } - _ = fs::remove_dir_all(&destination_path).await; download_server_binary( delegate, - url, - expected_digest, + &url, + expected_digest.as_deref(), &destination_path, Self::GITHUB_ASSET_KIND, ) .await?; make_file_executable(&server_path).await?; + remove_matching(&container_dir, |path| server_path == path).await; GithubBinaryMetadata::write_to_file( &GithubBinaryMetadata { metadata_version: 1, - digest: expected_digest.map(ToString::to_string), + digest: expected_digest, }, &metadata_path, ) From e132c7cad9728ee1b82eaa801a750e207dfa7212 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Mon, 11 Aug 2025 10:15:59 +0200 Subject: [PATCH 047/109] dap_adapters: Log CodeLldb version fetching errors (#35943) Release Notes: - N/A --- crates/dap_adapters/src/codelldb.rs | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/crates/dap_adapters/src/codelldb.rs b/crates/dap_adapters/src/codelldb.rs index 5b88db4432..842bb264a8 100644 --- a/crates/dap_adapters/src/codelldb.rs +++ b/crates/dap_adapters/src/codelldb.rs @@ -338,8 +338,8 @@ impl DebugAdapter for CodeLldbDebugAdapter { if command.is_none() { delegate.output_to_console(format!("Checking latest version of {}...", self.name())); let adapter_path = paths::debug_adapters_dir().join(&Self::ADAPTER_NAME); - let version_path = - if let Ok(version) = self.fetch_latest_adapter_version(delegate).await { + let version_path = match self.fetch_latest_adapter_version(delegate).await { + Ok(version) => { adapters::download_adapter_from_github( self.name(), version.clone(), @@ -351,10 +351,26 @@ impl DebugAdapter for CodeLldbDebugAdapter { adapter_path.join(format!("{}_{}", Self::ADAPTER_NAME, version.tag_name)); remove_matching(&adapter_path, |entry| entry != version_path).await; version_path - } else { - let mut paths = delegate.fs().read_dir(&adapter_path).await?; - paths.next().await.context("No adapter found")?? - }; + } + Err(e) => { + delegate.output_to_console("Unable to fetch latest version".to_string()); + log::error!("Error fetching latest version of {}: {}", self.name(), e); + delegate.output_to_console(format!( + "Searching for adapters in: {}", + adapter_path.display() + )); + let mut paths = delegate + .fs() + .read_dir(&adapter_path) + .await + .context("No cached adapter directory")?; + paths + .next() + .await + .context("No cached adapter found")? + .context("No cached adapter found")? + } + }; let adapter_dir = version_path.join("extension").join("adapter"); let path = adapter_dir.join("codelldb").to_string_lossy().to_string(); self.path_to_codelldb.set(path.clone()).ok(); From 422e0a2eb74eb5ca86d1864c2ef28add2949133c Mon Sep 17 00:00:00 2001 From: smit Date: Mon, 11 Aug 2025 15:29:41 +0530 Subject: [PATCH 048/109] project: Add more dynamic capability registrations for LSP (#35306) Closes #34204 Adds the ability to dynamically register and unregister code actions for language servers such as Biome. See more: https://github.com/zed-industries/zed/issues/34204#issuecomment-3134227856 Release Notes: - Fixed an issue where the Biome formatter was always used even when `require_config_file` was set to true and the project had no config file. --------- Co-authored-by: Kirill Bulatov --- crates/lsp/src/lsp.rs | 44 ++- crates/project/src/lsp_store.rs | 614 +++++++++++++++++++++----------- 2 files changed, 435 insertions(+), 223 deletions(-) diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index a92787cd3e..22a227c231 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -651,7 +651,7 @@ impl LanguageServer { capabilities: ClientCapabilities { general: Some(GeneralClientCapabilities { position_encodings: Some(vec![PositionEncodingKind::UTF16]), - ..Default::default() + ..GeneralClientCapabilities::default() }), workspace: Some(WorkspaceClientCapabilities { configuration: Some(true), @@ -665,6 +665,7 @@ impl LanguageServer { workspace_folders: Some(true), symbol: Some(WorkspaceSymbolClientCapabilities { resolve_support: None, + dynamic_registration: Some(true), ..WorkspaceSymbolClientCapabilities::default() }), inlay_hint: Some(InlayHintWorkspaceClientCapabilities { @@ -688,21 +689,21 @@ impl LanguageServer { ..WorkspaceEditClientCapabilities::default() }), file_operations: Some(WorkspaceFileOperationsClientCapabilities { - dynamic_registration: Some(false), + dynamic_registration: Some(true), did_rename: Some(true), will_rename: Some(true), - ..Default::default() + ..WorkspaceFileOperationsClientCapabilities::default() }), apply_edit: Some(true), execute_command: Some(ExecuteCommandClientCapabilities { - dynamic_registration: Some(false), + dynamic_registration: Some(true), }), - ..Default::default() + ..WorkspaceClientCapabilities::default() }), text_document: Some(TextDocumentClientCapabilities { definition: Some(GotoCapability { link_support: Some(true), - dynamic_registration: None, + dynamic_registration: Some(true), }), code_action: Some(CodeActionClientCapabilities { code_action_literal_support: Some(CodeActionLiteralSupport { @@ -725,7 +726,8 @@ impl LanguageServer { "command".to_string(), ], }), - ..Default::default() + dynamic_registration: Some(true), + ..CodeActionClientCapabilities::default() }), completion: Some(CompletionClientCapabilities { completion_item: Some(CompletionItemCapability { @@ -751,7 +753,7 @@ impl LanguageServer { MarkupKind::Markdown, MarkupKind::PlainText, ]), - ..Default::default() + ..CompletionItemCapability::default() }), insert_text_mode: Some(InsertTextMode::ADJUST_INDENTATION), completion_list: Some(CompletionListCapability { @@ -764,18 +766,20 @@ impl LanguageServer { ]), }), context_support: Some(true), - ..Default::default() + dynamic_registration: Some(true), + ..CompletionClientCapabilities::default() }), rename: Some(RenameClientCapabilities { prepare_support: Some(true), prepare_support_default_behavior: Some( PrepareSupportDefaultBehavior::IDENTIFIER, ), - ..Default::default() + dynamic_registration: Some(true), + ..RenameClientCapabilities::default() }), hover: Some(HoverClientCapabilities { content_format: Some(vec![MarkupKind::Markdown]), - dynamic_registration: None, + dynamic_registration: Some(true), }), inlay_hint: Some(InlayHintClientCapabilities { resolve_support: Some(InlayHintResolveClientCapabilities { @@ -787,7 +791,7 @@ impl LanguageServer { "label.command".to_string(), ], }), - dynamic_registration: Some(false), + dynamic_registration: Some(true), }), publish_diagnostics: Some(PublishDiagnosticsClientCapabilities { related_information: Some(true), @@ -818,26 +822,29 @@ impl LanguageServer { }), active_parameter_support: Some(true), }), + dynamic_registration: Some(true), ..SignatureHelpClientCapabilities::default() }), synchronization: Some(TextDocumentSyncClientCapabilities { did_save: Some(true), + dynamic_registration: Some(true), ..TextDocumentSyncClientCapabilities::default() }), code_lens: Some(CodeLensClientCapabilities { - dynamic_registration: Some(false), + dynamic_registration: Some(true), }), document_symbol: Some(DocumentSymbolClientCapabilities { hierarchical_document_symbol_support: Some(true), + dynamic_registration: Some(true), ..DocumentSymbolClientCapabilities::default() }), diagnostic: Some(DiagnosticClientCapabilities { - dynamic_registration: Some(false), + dynamic_registration: Some(true), related_document_support: Some(true), }) .filter(|_| pull_diagnostics), color_provider: Some(DocumentColorClientCapabilities { - dynamic_registration: Some(false), + dynamic_registration: Some(true), }), ..TextDocumentClientCapabilities::default() }), @@ -850,7 +857,7 @@ impl LanguageServer { show_message: Some(ShowMessageRequestClientCapabilities { message_action_item: None, }), - ..Default::default() + ..WindowClientCapabilities::default() }), }, trace: None, @@ -862,8 +869,7 @@ impl LanguageServer { } }), locale: None, - - ..Default::default() + ..InitializeParams::default() } } @@ -1672,7 +1678,7 @@ impl LanguageServer { workspace_symbol_provider: Some(OneOf::Left(true)), implementation_provider: Some(ImplementationProviderCapability::Simple(true)), type_definition_provider: Some(TypeDefinitionProviderCapability::Simple(true)), - ..Default::default() + ..ServerCapabilities::default() } } } diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index d3843bc4ea..de6544f5a2 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -638,139 +638,27 @@ impl LocalLspStore { language_server .on_request::({ - let this = this.clone(); + let lsp_store = this.clone(); move |params, cx| { - let lsp_store = this.clone(); + let lsp_store = lsp_store.clone(); let mut cx = cx.clone(); async move { - for reg in params.registrations { - match reg.method.as_str() { - "workspace/didChangeWatchedFiles" => { - if let Some(options) = reg.register_options { - let options = serde_json::from_value(options)?; - lsp_store.update(&mut cx, |this, cx| { - this.as_local_mut()?.on_lsp_did_change_watched_files( - server_id, ®.id, options, cx, + lsp_store + .update(&mut cx, |lsp_store, cx| { + if lsp_store.as_local().is_some() { + match lsp_store + .register_server_capabilities(server_id, params, cx) + { + Ok(()) => {} + Err(e) => { + log::error!( + "Failed to register server capabilities: {e:#}" ); - Some(()) - })?; - } - } - "textDocument/rangeFormatting" => { - lsp_store.update(&mut cx, |lsp_store, cx| { - if let Some(server) = - lsp_store.language_server_for_id(server_id) - { - let options = reg - .register_options - .map(|options| { - serde_json::from_value::< - lsp::DocumentRangeFormattingOptions, - >( - options - ) - }) - .transpose()?; - let provider = match options { - None => OneOf::Left(true), - Some(options) => OneOf::Right(options), - }; - server.update_capabilities(|capabilities| { - capabilities.document_range_formatting_provider = - Some(provider); - }); - notify_server_capabilities_updated(&server, cx); } - anyhow::Ok(()) - })??; + }; } - "textDocument/onTypeFormatting" => { - lsp_store.update(&mut cx, |lsp_store, cx| { - if let Some(server) = - lsp_store.language_server_for_id(server_id) - { - let options = reg - .register_options - .map(|options| { - serde_json::from_value::< - lsp::DocumentOnTypeFormattingOptions, - >( - options - ) - }) - .transpose()?; - if let Some(options) = options { - server.update_capabilities(|capabilities| { - capabilities - .document_on_type_formatting_provider = - Some(options); - }); - notify_server_capabilities_updated(&server, cx); - } - } - anyhow::Ok(()) - })??; - } - "textDocument/formatting" => { - lsp_store.update(&mut cx, |lsp_store, cx| { - if let Some(server) = - lsp_store.language_server_for_id(server_id) - { - let options = reg - .register_options - .map(|options| { - serde_json::from_value::< - lsp::DocumentFormattingOptions, - >( - options - ) - }) - .transpose()?; - let provider = match options { - None => OneOf::Left(true), - Some(options) => OneOf::Right(options), - }; - server.update_capabilities(|capabilities| { - capabilities.document_formatting_provider = - Some(provider); - }); - notify_server_capabilities_updated(&server, cx); - } - anyhow::Ok(()) - })??; - } - "workspace/didChangeConfiguration" => { - // Ignore payload since we notify clients of setting changes unconditionally, relying on them pulling the latest settings. - } - "textDocument/rename" => { - lsp_store.update(&mut cx, |lsp_store, cx| { - if let Some(server) = - lsp_store.language_server_for_id(server_id) - { - let options = reg - .register_options - .map(|options| { - serde_json::from_value::( - options, - ) - }) - .transpose()?; - let options = match options { - None => OneOf::Left(true), - Some(options) => OneOf::Right(options), - }; - - server.update_capabilities(|capabilities| { - capabilities.rename_provider = Some(options); - }); - notify_server_capabilities_updated(&server, cx); - } - anyhow::Ok(()) - })??; - } - _ => log::warn!("unhandled capability registration: {reg:?}"), - } - } + }) + .ok(); Ok(()) } } @@ -779,79 +667,27 @@ impl LocalLspStore { language_server .on_request::({ - let this = this.clone(); + let lsp_store = this.clone(); move |params, cx| { - let lsp_store = this.clone(); + let lsp_store = lsp_store.clone(); let mut cx = cx.clone(); async move { - for unreg in params.unregisterations.iter() { - match unreg.method.as_str() { - "workspace/didChangeWatchedFiles" => { - lsp_store.update(&mut cx, |lsp_store, cx| { - lsp_store - .as_local_mut()? - .on_lsp_unregister_did_change_watched_files( - server_id, &unreg.id, cx, + lsp_store + .update(&mut cx, |lsp_store, cx| { + if lsp_store.as_local().is_some() { + match lsp_store + .unregister_server_capabilities(server_id, params, cx) + { + Ok(()) => {} + Err(e) => { + log::error!( + "Failed to unregister server capabilities: {e:#}" ); - Some(()) - })?; - } - "workspace/didChangeConfiguration" => { - // Ignore payload since we notify clients of setting changes unconditionally, relying on them pulling the latest settings. - } - "textDocument/rename" => { - lsp_store.update(&mut cx, |lsp_store, cx| { - if let Some(server) = - lsp_store.language_server_for_id(server_id) - { - server.update_capabilities(|capabilities| { - capabilities.rename_provider = None - }); - notify_server_capabilities_updated(&server, cx); } - })?; + } } - "textDocument/rangeFormatting" => { - lsp_store.update(&mut cx, |lsp_store, cx| { - if let Some(server) = - lsp_store.language_server_for_id(server_id) - { - server.update_capabilities(|capabilities| { - capabilities.document_range_formatting_provider = - None - }); - notify_server_capabilities_updated(&server, cx); - } - })?; - } - "textDocument/onTypeFormatting" => { - lsp_store.update(&mut cx, |lsp_store, cx| { - if let Some(server) = - lsp_store.language_server_for_id(server_id) - { - server.update_capabilities(|capabilities| { - capabilities.document_on_type_formatting_provider = - None; - }); - notify_server_capabilities_updated(&server, cx); - } - })?; - } - "textDocument/formatting" => { - lsp_store.update(&mut cx, |lsp_store, cx| { - if let Some(server) = - lsp_store.language_server_for_id(server_id) - { - server.update_capabilities(|capabilities| { - capabilities.document_formatting_provider = None; - }); - notify_server_capabilities_updated(&server, cx); - } - })?; - } - _ => log::warn!("unhandled capability unregistration: {unreg:?}"), - } - } + }) + .ok(); Ok(()) } } @@ -3519,6 +3355,30 @@ impl LocalLspStore { Ok(workspace_config) } + + fn language_server_for_id(&self, id: LanguageServerId) -> Option> { + if let Some(LanguageServerState::Running { server, .. }) = self.language_servers.get(&id) { + Some(server.clone()) + } else if let Some((_, server)) = self.supplementary_language_servers.get(&id) { + Some(Arc::clone(server)) + } else { + None + } + } +} + +fn parse_register_capabilities( + reg: lsp::Registration, +) -> anyhow::Result> { + let caps = match reg + .register_options + .map(|options| serde_json::from_value::(options)) + .transpose()? + { + None => OneOf::Left(true), + Some(options) => OneOf::Right(options), + }; + Ok(caps) } fn notify_server_capabilities_updated(server: &LanguageServer, cx: &mut Context) { @@ -9434,16 +9294,7 @@ impl LspStore { } pub fn language_server_for_id(&self, id: LanguageServerId) -> Option> { - let local_lsp_store = self.as_local()?; - if let Some(LanguageServerState::Running { server, .. }) = - local_lsp_store.language_servers.get(&id) - { - Some(server.clone()) - } else if let Some((_, server)) = local_lsp_store.supplementary_language_servers.get(&id) { - Some(Arc::clone(server)) - } else { - None - } + self.as_local()?.language_server_for_id(id) } fn on_lsp_progress( @@ -11808,6 +11659,361 @@ impl LspStore { .log_err(); } } + + fn register_server_capabilities( + &mut self, + server_id: LanguageServerId, + params: lsp::RegistrationParams, + cx: &mut Context, + ) -> anyhow::Result<()> { + let server = self + .language_server_for_id(server_id) + .with_context(|| format!("no server {server_id} found"))?; + for reg in params.registrations { + match reg.method.as_str() { + "workspace/didChangeWatchedFiles" => { + if let Some(options) = reg.register_options { + let notify = if let Some(local_lsp_store) = self.as_local_mut() { + let caps = serde_json::from_value(options)?; + local_lsp_store + .on_lsp_did_change_watched_files(server_id, ®.id, caps, cx); + true + } else { + false + }; + if notify { + notify_server_capabilities_updated(&server, cx); + } + } + } + "workspace/didChangeConfiguration" => { + // Ignore payload since we notify clients of setting changes unconditionally, relying on them pulling the latest settings. + } + "workspace/symbol" => { + let options = parse_register_capabilities(reg)?; + server.update_capabilities(|capabilities| { + capabilities.workspace_symbol_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); + } + "workspace/fileOperations" => { + let caps = reg + .register_options + .map(serde_json::from_value) + .transpose()? + .unwrap_or_default(); + server.update_capabilities(|capabilities| { + capabilities + .workspace + .get_or_insert_default() + .file_operations = Some(caps); + }); + notify_server_capabilities_updated(&server, cx); + } + "workspace/executeCommand" => { + let options = reg + .register_options + .map(serde_json::from_value) + .transpose()? + .unwrap_or_default(); + server.update_capabilities(|capabilities| { + capabilities.execute_command_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/rangeFormatting" => { + let options = parse_register_capabilities(reg)?; + server.update_capabilities(|capabilities| { + capabilities.document_range_formatting_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/onTypeFormatting" => { + let options = reg + .register_options + .map(serde_json::from_value) + .transpose()? + .unwrap_or_default(); + server.update_capabilities(|capabilities| { + capabilities.document_on_type_formatting_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/formatting" => { + let options = parse_register_capabilities(reg)?; + server.update_capabilities(|capabilities| { + capabilities.document_formatting_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/rename" => { + let options = parse_register_capabilities(reg)?; + server.update_capabilities(|capabilities| { + capabilities.rename_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/inlayHint" => { + let options = parse_register_capabilities(reg)?; + server.update_capabilities(|capabilities| { + capabilities.inlay_hint_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/documentSymbol" => { + let options = parse_register_capabilities(reg)?; + server.update_capabilities(|capabilities| { + capabilities.document_symbol_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/codeAction" => { + let options = reg + .register_options + .map(serde_json::from_value) + .transpose()?; + let provider_capability = match options { + None => lsp::CodeActionProviderCapability::Simple(true), + Some(options) => lsp::CodeActionProviderCapability::Options(options), + }; + server.update_capabilities(|capabilities| { + capabilities.code_action_provider = Some(provider_capability); + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/definition" => { + let caps = parse_register_capabilities(reg)?; + server.update_capabilities(|capabilities| { + capabilities.definition_provider = Some(caps); + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/completion" => { + let caps = reg + .register_options + .map(serde_json::from_value) + .transpose()? + .unwrap_or_default(); + server.update_capabilities(|capabilities| { + capabilities.completion_provider = Some(caps); + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/hover" => { + let caps = reg + .register_options + .map(serde_json::from_value) + .transpose()? + .unwrap_or_else(|| lsp::HoverProviderCapability::Simple(true)); + server.update_capabilities(|capabilities| { + capabilities.hover_provider = Some(caps); + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/signatureHelp" => { + let caps = reg + .register_options + .map(serde_json::from_value) + .transpose()? + .unwrap_or_default(); + server.update_capabilities(|capabilities| { + capabilities.signature_help_provider = Some(caps); + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/synchronization" => { + let caps = reg + .register_options + .map(serde_json::from_value) + .transpose()? + .unwrap_or_else(|| { + lsp::TextDocumentSyncCapability::Options( + lsp::TextDocumentSyncOptions::default(), + ) + }); + server.update_capabilities(|capabilities| { + capabilities.text_document_sync = Some(caps); + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/codeLens" => { + let caps = reg + .register_options + .map(serde_json::from_value) + .transpose()? + .unwrap_or_else(|| lsp::CodeLensOptions { + resolve_provider: None, + }); + server.update_capabilities(|capabilities| { + capabilities.code_lens_provider = Some(caps); + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/diagnostic" => { + let caps = reg + .register_options + .map(serde_json::from_value) + .transpose()? + .unwrap_or_else(|| { + lsp::DiagnosticServerCapabilities::RegistrationOptions( + lsp::DiagnosticRegistrationOptions::default(), + ) + }); + server.update_capabilities(|capabilities| { + capabilities.diagnostic_provider = Some(caps); + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/colorProvider" => { + let caps = reg + .register_options + .map(serde_json::from_value) + .transpose()? + .unwrap_or_else(|| lsp::ColorProviderCapability::Simple(true)); + server.update_capabilities(|capabilities| { + capabilities.color_provider = Some(caps); + }); + notify_server_capabilities_updated(&server, cx); + } + _ => log::warn!("unhandled capability registration: {reg:?}"), + } + } + + Ok(()) + } + + fn unregister_server_capabilities( + &mut self, + server_id: LanguageServerId, + params: lsp::UnregistrationParams, + cx: &mut Context, + ) -> anyhow::Result<()> { + let server = self + .language_server_for_id(server_id) + .with_context(|| format!("no server {server_id} found"))?; + for unreg in params.unregisterations.iter() { + match unreg.method.as_str() { + "workspace/didChangeWatchedFiles" => { + let notify = if let Some(local_lsp_store) = self.as_local_mut() { + local_lsp_store + .on_lsp_unregister_did_change_watched_files(server_id, &unreg.id, cx); + true + } else { + false + }; + if notify { + notify_server_capabilities_updated(&server, cx); + } + } + "workspace/didChangeConfiguration" => { + // Ignore payload since we notify clients of setting changes unconditionally, relying on them pulling the latest settings. + } + "workspace/symbol" => { + server.update_capabilities(|capabilities| { + capabilities.workspace_symbol_provider = None + }); + notify_server_capabilities_updated(&server, cx); + } + "workspace/fileOperations" => { + server.update_capabilities(|capabilities| { + capabilities + .workspace + .get_or_insert_with(|| lsp::WorkspaceServerCapabilities { + workspace_folders: None, + file_operations: None, + }) + .file_operations = None; + }); + notify_server_capabilities_updated(&server, cx); + } + "workspace/executeCommand" => { + server.update_capabilities(|capabilities| { + capabilities.execute_command_provider = None; + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/rangeFormatting" => { + server.update_capabilities(|capabilities| { + capabilities.document_range_formatting_provider = None + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/onTypeFormatting" => { + server.update_capabilities(|capabilities| { + capabilities.document_on_type_formatting_provider = None; + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/formatting" => { + server.update_capabilities(|capabilities| { + capabilities.document_formatting_provider = None; + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/rename" => { + server.update_capabilities(|capabilities| capabilities.rename_provider = None); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/codeAction" => { + server.update_capabilities(|capabilities| { + capabilities.code_action_provider = None; + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/definition" => { + server.update_capabilities(|capabilities| { + capabilities.definition_provider = None; + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/completion" => { + server.update_capabilities(|capabilities| { + capabilities.completion_provider = None; + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/hover" => { + server.update_capabilities(|capabilities| { + capabilities.hover_provider = None; + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/signatureHelp" => { + server.update_capabilities(|capabilities| { + capabilities.signature_help_provider = None; + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/synchronization" => { + server.update_capabilities(|capabilities| { + capabilities.text_document_sync = None; + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/codeLens" => { + server.update_capabilities(|capabilities| { + capabilities.code_lens_provider = None; + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/diagnostic" => { + server.update_capabilities(|capabilities| { + capabilities.diagnostic_provider = None; + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/colorProvider" => { + server.update_capabilities(|capabilities| { + capabilities.color_provider = None; + }); + notify_server_capabilities_updated(&server, cx); + } + _ => log::warn!("unhandled capability unregistration: {unreg:?}"), + } + } + + Ok(()) + } } fn subscribe_to_binary_statuses( From 086ea3c61939f1329473cc9d0537f4b78bfacc0a Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 11 Aug 2025 12:31:13 +0200 Subject: [PATCH 049/109] Port `terminal` tool to agent2 (#35918) Release Notes: - N/A --------- Co-authored-by: Ben Brandt --- Cargo.lock | 8 + crates/acp_thread/Cargo.toml | 1 + crates/acp_thread/src/acp_thread.rs | 67 ++- crates/acp_thread/src/terminal.rs | 87 ++++ crates/agent2/Cargo.toml | 10 +- crates/agent2/src/agent.rs | 5 +- crates/agent2/src/thread.rs | 138 +++--- crates/agent2/src/tools.rs | 2 + crates/agent2/src/tools/edit_file_tool.rs | 16 +- crates/agent2/src/tools/terminal_tool.rs | 489 ++++++++++++++++++++++ crates/agent_ui/src/acp/thread_view.rs | 106 ++++- crates/terminal/Cargo.toml | 8 + crates/terminal/src/terminal.rs | 57 ++- 13 files changed, 882 insertions(+), 112 deletions(-) create mode 100644 crates/acp_thread/src/terminal.rs create mode 100644 crates/agent2/src/tools/terminal_tool.rs diff --git a/Cargo.lock b/Cargo.lock index 4bb36fdeee..634bacd0f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,6 +27,7 @@ dependencies = [ "settings", "smol", "tempfile", + "terminal", "ui", "util", "workspace-hack", @@ -195,6 +196,7 @@ dependencies = [ "cloud_llm_client", "collections", "ctor", + "editor", "env_logger 0.11.8", "fs", "futures 0.3.31", @@ -209,6 +211,7 @@ dependencies = [ "log", "lsp", "paths", + "portable-pty", "pretty_assertions", "project", "prompt_store", @@ -219,12 +222,17 @@ dependencies = [ "serde_json", "settings", "smol", + "task", + "terminal", + "theme", "ui", "util", "uuid", "watch", + "which 6.0.3", "workspace-hack", "worktree", + "zlog", ] [[package]] diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index 37d2920045..33e88df761 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -32,6 +32,7 @@ serde.workspace = true serde_json.workspace = true settings.workspace = true smol.workspace = true +terminal.workspace = true ui.workspace = true util.workspace = true workspace-hack.workspace = true diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index f2bebf7391..d632e6e570 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -1,8 +1,10 @@ mod connection; mod diff; +mod terminal; pub use connection::*; pub use diff::*; +pub use terminal::*; use action_log::ActionLog; use agent_client_protocol as acp; @@ -147,6 +149,14 @@ impl AgentThreadEntry { } } + pub fn terminals(&self) -> impl Iterator> { + if let AgentThreadEntry::ToolCall(call) = self { + itertools::Either::Left(call.terminals()) + } else { + itertools::Either::Right(std::iter::empty()) + } + } + pub fn locations(&self) -> Option<&[acp::ToolCallLocation]> { if let AgentThreadEntry::ToolCall(ToolCall { locations, .. }) = self { Some(locations) @@ -250,8 +260,17 @@ impl ToolCall { pub fn diffs(&self) -> impl Iterator> { self.content.iter().filter_map(|content| match content { - ToolCallContent::ContentBlock { .. } => None, - ToolCallContent::Diff { diff } => Some(diff), + ToolCallContent::Diff(diff) => Some(diff), + ToolCallContent::ContentBlock(_) => None, + ToolCallContent::Terminal(_) => None, + }) + } + + pub fn terminals(&self) -> impl Iterator> { + self.content.iter().filter_map(|content| match content { + ToolCallContent::Terminal(terminal) => Some(terminal), + ToolCallContent::ContentBlock(_) => None, + ToolCallContent::Diff(_) => None, }) } @@ -387,8 +406,9 @@ impl ContentBlock { #[derive(Debug)] pub enum ToolCallContent { - ContentBlock { content: ContentBlock }, - Diff { diff: Entity }, + ContentBlock(ContentBlock), + Diff(Entity), + Terminal(Entity), } impl ToolCallContent { @@ -398,19 +418,20 @@ impl ToolCallContent { cx: &mut App, ) -> Self { match content { - acp::ToolCallContent::Content { content } => Self::ContentBlock { - content: ContentBlock::new(content, &language_registry, cx), - }, - acp::ToolCallContent::Diff { diff } => Self::Diff { - diff: cx.new(|cx| Diff::from_acp(diff, language_registry, cx)), - }, + acp::ToolCallContent::Content { content } => { + Self::ContentBlock(ContentBlock::new(content, &language_registry, cx)) + } + acp::ToolCallContent::Diff { diff } => { + Self::Diff(cx.new(|cx| Diff::from_acp(diff, language_registry, cx))) + } } } pub fn to_markdown(&self, cx: &App) -> String { match self { - Self::ContentBlock { content } => content.to_markdown(cx).to_string(), - Self::Diff { diff } => diff.read(cx).to_markdown(cx), + Self::ContentBlock(content) => content.to_markdown(cx).to_string(), + Self::Diff(diff) => diff.read(cx).to_markdown(cx), + Self::Terminal(terminal) => terminal.read(cx).to_markdown(cx), } } } @@ -419,6 +440,7 @@ impl ToolCallContent { pub enum ToolCallUpdate { UpdateFields(acp::ToolCallUpdate), UpdateDiff(ToolCallUpdateDiff), + UpdateTerminal(ToolCallUpdateTerminal), } impl ToolCallUpdate { @@ -426,6 +448,7 @@ impl ToolCallUpdate { match self { Self::UpdateFields(update) => &update.id, Self::UpdateDiff(diff) => &diff.id, + Self::UpdateTerminal(terminal) => &terminal.id, } } } @@ -448,6 +471,18 @@ pub struct ToolCallUpdateDiff { pub diff: Entity, } +impl From for ToolCallUpdate { + fn from(terminal: ToolCallUpdateTerminal) -> Self { + Self::UpdateTerminal(terminal) + } +} + +#[derive(Debug, PartialEq)] +pub struct ToolCallUpdateTerminal { + pub id: acp::ToolCallId, + pub terminal: Entity, +} + #[derive(Debug, Default)] pub struct Plan { pub entries: Vec, @@ -760,7 +795,13 @@ impl AcpThread { current_call.content.clear(); current_call .content - .push(ToolCallContent::Diff { diff: update.diff }); + .push(ToolCallContent::Diff(update.diff)); + } + ToolCallUpdate::UpdateTerminal(update) => { + current_call.content.clear(); + current_call + .content + .push(ToolCallContent::Terminal(update.terminal)); } } diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs new file mode 100644 index 0000000000..b800873737 --- /dev/null +++ b/crates/acp_thread/src/terminal.rs @@ -0,0 +1,87 @@ +use gpui::{App, AppContext, Context, Entity}; +use language::LanguageRegistry; +use markdown::Markdown; +use std::{path::PathBuf, process::ExitStatus, sync::Arc, time::Instant}; + +pub struct Terminal { + command: Entity, + working_dir: Option, + terminal: Entity, + started_at: Instant, + output: Option, +} + +pub struct TerminalOutput { + pub ended_at: Instant, + pub exit_status: Option, + pub was_content_truncated: bool, + pub original_content_len: usize, + pub content_line_count: usize, + pub finished_with_empty_output: bool, +} + +impl Terminal { + pub fn new( + command: String, + working_dir: Option, + terminal: Entity, + language_registry: Arc, + cx: &mut Context, + ) -> Self { + Self { + command: cx + .new(|cx| Markdown::new(command.into(), Some(language_registry.clone()), None, cx)), + working_dir, + terminal, + started_at: Instant::now(), + output: None, + } + } + + pub fn finish( + &mut self, + exit_status: Option, + original_content_len: usize, + truncated_content_len: usize, + content_line_count: usize, + finished_with_empty_output: bool, + cx: &mut Context, + ) { + self.output = Some(TerminalOutput { + ended_at: Instant::now(), + exit_status, + was_content_truncated: truncated_content_len < original_content_len, + original_content_len, + content_line_count, + finished_with_empty_output, + }); + cx.notify(); + } + + pub fn command(&self) -> &Entity { + &self.command + } + + pub fn working_dir(&self) -> &Option { + &self.working_dir + } + + pub fn started_at(&self) -> Instant { + self.started_at + } + + pub fn output(&self) -> Option<&TerminalOutput> { + self.output.as_ref() + } + + pub fn inner(&self) -> &Entity { + &self.terminal + } + + pub fn to_markdown(&self, cx: &App) -> String { + format!( + "Terminal:\n```\n{}\n```\n", + self.terminal.read(cx).get_content() + ) + } +} diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml index c1c3f2d459..65452f60fc 100644 --- a/crates/agent2/Cargo.toml +++ b/crates/agent2/Cargo.toml @@ -33,6 +33,7 @@ language_model.workspace = true language_models.workspace = true log.workspace = true paths.workspace = true +portable-pty.workspace = true project.workspace = true prompt_store.workspace = true rust-embed.workspace = true @@ -41,16 +42,20 @@ serde.workspace = true serde_json.workspace = true settings.workspace = true smol.workspace = true +task.workspace = true +terminal.workspace = true ui.workspace = true util.workspace = true uuid.workspace = true watch.workspace = true +which.workspace = true workspace-hack.workspace = true [dev-dependencies] ctor.workspace = true client = { workspace = true, "features" = ["test-support"] } clock = { workspace = true, "features" = ["test-support"] } +editor = { workspace = true, "features" = ["test-support"] } env_logger.workspace = true fs = { workspace = true, "features" = ["test-support"] } gpui = { workspace = true, "features" = ["test-support"] } @@ -58,8 +63,11 @@ gpui_tokio.workspace = true language = { workspace = true, "features" = ["test-support"] } language_model = { workspace = true, "features" = ["test-support"] } lsp = { workspace = true, "features" = ["test-support"] } +pretty_assertions.workspace = true project = { workspace = true, "features" = ["test-support"] } reqwest_client.workspace = true settings = { workspace = true, "features" = ["test-support"] } +terminal = { workspace = true, "features" = ["test-support"] } +theme = { workspace = true, "features" = ["test-support"] } worktree = { workspace = true, "features" = ["test-support"] } -pretty_assertions.workspace = true +zlog.workspace = true diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index 5be3892d60..edb79003b4 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -1,5 +1,7 @@ use crate::{AgentResponseEvent, Thread, templates::Templates}; -use crate::{EditFileTool, FindPathTool, ReadFileTool, ThinkingTool, ToolCallAuthorization}; +use crate::{ + EditFileTool, FindPathTool, ReadFileTool, TerminalTool, ThinkingTool, ToolCallAuthorization, +}; use acp_thread::ModelSelector; use agent_client_protocol as acp; use anyhow::{Context as _, Result, anyhow}; @@ -418,6 +420,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection { thread.add_tool(FindPathTool::new(project.clone())); thread.add_tool(ReadFileTool::new(project.clone(), action_log)); thread.add_tool(EditFileTool::new(cx.entity())); + thread.add_tool(TerminalTool::new(project.clone(), cx)); thread }); diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index a0a2a3a2b0..dd8e5476ab 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -1,5 +1,4 @@ use crate::{SystemPromptTemplate, Template, Templates}; -use acp_thread::Diff; use action_log::ActionLog; use agent_client_protocol as acp; use anyhow::{Context as _, Result, anyhow}; @@ -802,47 +801,6 @@ impl AgentResponseEventStream { .ok(); } - fn authorize_tool_call( - &self, - id: &LanguageModelToolUseId, - title: String, - kind: acp::ToolKind, - input: serde_json::Value, - ) -> impl use<> + Future> { - let (response_tx, response_rx) = oneshot::channel(); - self.0 - .unbounded_send(Ok(AgentResponseEvent::ToolCallAuthorization( - ToolCallAuthorization { - tool_call: Self::initial_tool_call(id, title, kind, input), - options: vec![ - acp::PermissionOption { - id: acp::PermissionOptionId("always_allow".into()), - name: "Always Allow".into(), - kind: acp::PermissionOptionKind::AllowAlways, - }, - acp::PermissionOption { - id: acp::PermissionOptionId("allow".into()), - name: "Allow".into(), - kind: acp::PermissionOptionKind::AllowOnce, - }, - acp::PermissionOption { - id: acp::PermissionOptionId("deny".into()), - name: "Deny".into(), - kind: acp::PermissionOptionKind::RejectOnce, - }, - ], - response: response_tx, - }, - ))) - .ok(); - async move { - match response_rx.await?.0.as_ref() { - "allow" | "always_allow" => Ok(()), - _ => Err(anyhow!("Permission to run tool denied by user")), - } - } - } - fn send_tool_call( &self, id: &LanguageModelToolUseId, @@ -894,18 +852,6 @@ impl AgentResponseEventStream { .ok(); } - fn update_tool_call_diff(&self, tool_use_id: &LanguageModelToolUseId, diff: Entity) { - self.0 - .unbounded_send(Ok(AgentResponseEvent::ToolCallUpdate( - acp_thread::ToolCallUpdateDiff { - id: acp::ToolCallId(tool_use_id.to_string().into()), - diff, - } - .into(), - ))) - .ok(); - } - fn send_stop(&self, reason: StopReason) { match reason { StopReason::EndTurn => { @@ -979,17 +925,71 @@ impl ToolCallEventStream { .update_tool_call_fields(&self.tool_use_id, fields); } - pub fn update_diff(&self, diff: Entity) { - self.stream.update_tool_call_diff(&self.tool_use_id, diff); + pub fn update_diff(&self, diff: Entity) { + self.stream + .0 + .unbounded_send(Ok(AgentResponseEvent::ToolCallUpdate( + acp_thread::ToolCallUpdateDiff { + id: acp::ToolCallId(self.tool_use_id.to_string().into()), + diff, + } + .into(), + ))) + .ok(); + } + + pub fn update_terminal(&self, terminal: Entity) { + self.stream + .0 + .unbounded_send(Ok(AgentResponseEvent::ToolCallUpdate( + acp_thread::ToolCallUpdateTerminal { + id: acp::ToolCallId(self.tool_use_id.to_string().into()), + terminal, + } + .into(), + ))) + .ok(); } pub fn authorize(&self, title: String) -> impl use<> + Future> { - self.stream.authorize_tool_call( - &self.tool_use_id, - title, - self.kind.clone(), - self.input.clone(), - ) + let (response_tx, response_rx) = oneshot::channel(); + self.stream + .0 + .unbounded_send(Ok(AgentResponseEvent::ToolCallAuthorization( + ToolCallAuthorization { + tool_call: AgentResponseEventStream::initial_tool_call( + &self.tool_use_id, + title, + self.kind.clone(), + self.input.clone(), + ), + options: vec![ + acp::PermissionOption { + id: acp::PermissionOptionId("always_allow".into()), + name: "Always Allow".into(), + kind: acp::PermissionOptionKind::AllowAlways, + }, + acp::PermissionOption { + id: acp::PermissionOptionId("allow".into()), + name: "Allow".into(), + kind: acp::PermissionOptionKind::AllowOnce, + }, + acp::PermissionOption { + id: acp::PermissionOptionId("deny".into()), + name: "Deny".into(), + kind: acp::PermissionOptionKind::RejectOnce, + }, + ], + response: response_tx, + }, + ))) + .ok(); + async move { + match response_rx.await?.0.as_ref() { + "allow" | "always_allow" => Ok(()), + _ => Err(anyhow!("Permission to run tool denied by user")), + } + } } } @@ -1000,7 +1000,7 @@ pub struct ToolCallEventStreamReceiver( #[cfg(test)] impl ToolCallEventStreamReceiver { - pub async fn expect_tool_authorization(&mut self) -> ToolCallAuthorization { + pub async fn expect_authorization(&mut self) -> ToolCallAuthorization { let event = self.0.next().await; if let Some(Ok(AgentResponseEvent::ToolCallAuthorization(auth))) = event { auth @@ -1008,6 +1008,18 @@ impl ToolCallEventStreamReceiver { panic!("Expected ToolCallAuthorization but got: {:?}", event); } } + + pub async fn expect_terminal(&mut self) -> Entity { + let event = self.0.next().await; + if let Some(Ok(AgentResponseEvent::ToolCallUpdate( + acp_thread::ToolCallUpdate::UpdateTerminal(update), + ))) = event + { + update.terminal + } else { + panic!("Expected terminal but got: {:?}", event); + } + } } #[cfg(test)] diff --git a/crates/agent2/src/tools.rs b/crates/agent2/src/tools.rs index 5fe13db854..df4a7a9580 100644 --- a/crates/agent2/src/tools.rs +++ b/crates/agent2/src/tools.rs @@ -1,9 +1,11 @@ mod edit_file_tool; mod find_path_tool; mod read_file_tool; +mod terminal_tool; mod thinking_tool; pub use edit_file_tool::*; pub use find_path_tool::*; pub use read_file_tool::*; +pub use terminal_tool::*; pub use thinking_tool::*; diff --git a/crates/agent2/src/tools/edit_file_tool.rs b/crates/agent2/src/tools/edit_file_tool.rs index 48e5d37586..d9a4cdf8ba 100644 --- a/crates/agent2/src/tools/edit_file_tool.rs +++ b/crates/agent2/src/tools/edit_file_tool.rs @@ -942,7 +942,7 @@ mod tests { ) }); - let event = stream_rx.expect_tool_authorization().await; + let event = stream_rx.expect_authorization().await; assert_eq!(event.tool_call.title, "test 1 (local settings)"); // Test 2: Path outside project should require confirmation @@ -959,7 +959,7 @@ mod tests { ) }); - let event = stream_rx.expect_tool_authorization().await; + let event = stream_rx.expect_authorization().await; assert_eq!(event.tool_call.title, "test 2"); // Test 3: Relative path without .zed should not require confirmation @@ -992,7 +992,7 @@ mod tests { cx, ) }); - let event = stream_rx.expect_tool_authorization().await; + let event = stream_rx.expect_authorization().await; assert_eq!(event.tool_call.title, "test 4 (local settings)"); // Test 5: When always_allow_tool_actions is enabled, no confirmation needed @@ -1088,7 +1088,7 @@ mod tests { }); if should_confirm { - stream_rx.expect_tool_authorization().await; + stream_rx.expect_authorization().await; } else { auth.await.unwrap(); assert!( @@ -1192,7 +1192,7 @@ mod tests { }); if should_confirm { - stream_rx.expect_tool_authorization().await; + stream_rx.expect_authorization().await; } else { auth.await.unwrap(); assert!( @@ -1276,7 +1276,7 @@ mod tests { }); if should_confirm { - stream_rx.expect_tool_authorization().await; + stream_rx.expect_authorization().await; } else { auth.await.unwrap(); assert!( @@ -1339,7 +1339,7 @@ mod tests { ) }); - stream_rx.expect_tool_authorization().await; + stream_rx.expect_authorization().await; // Test outside path with different modes let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); @@ -1355,7 +1355,7 @@ mod tests { ) }); - stream_rx.expect_tool_authorization().await; + stream_rx.expect_authorization().await; // Test normal path with different modes let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); diff --git a/crates/agent2/src/tools/terminal_tool.rs b/crates/agent2/src/tools/terminal_tool.rs new file mode 100644 index 0000000000..c0b34444dd --- /dev/null +++ b/crates/agent2/src/tools/terminal_tool.rs @@ -0,0 +1,489 @@ +use agent_client_protocol as acp; +use anyhow::Result; +use futures::{FutureExt as _, future::Shared}; +use gpui::{App, AppContext, Entity, SharedString, Task}; +use project::{Project, terminals::TerminalKind}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::Settings; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; +use util::{ResultExt, get_system_shell, markdown::MarkdownInlineCode}; + +use crate::{AgentTool, ToolCallEventStream}; + +const COMMAND_OUTPUT_LIMIT: usize = 16 * 1024; + +/// Executes a shell one-liner and returns the combined output. +/// +/// This tool spawns a process using the user's shell, reads from stdout and stderr (preserving the order of writes), and returns a string with the combined output result. +/// +/// The output results will be shown to the user already, only list it again if necessary, avoid being redundant. +/// +/// Make sure you use the `cd` parameter to navigate to one of the root directories of the project. NEVER do it as part of the `command` itself, otherwise it will error. +/// +/// Do not use this tool for commands that run indefinitely, such as servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers that don't terminate on their own. +/// +/// Remember that each invocation of this tool will spawn a new shell process, so you can't rely on any state from previous invocations. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct TerminalToolInput { + /// The one-liner command to execute. + command: String, + /// Working directory for the command. This must be one of the root directories of the project. + cd: String, +} + +pub struct TerminalTool { + project: Entity, + determine_shell: Shared>, +} + +impl TerminalTool { + pub fn new(project: Entity, cx: &mut App) -> Self { + let determine_shell = cx.background_spawn(async move { + if cfg!(windows) { + return get_system_shell(); + } + + if which::which("bash").is_ok() { + log::info!("agent selected bash for terminal tool"); + "bash".into() + } else { + let shell = get_system_shell(); + log::info!("agent selected {shell} for terminal tool"); + shell + } + }); + Self { + project, + determine_shell: determine_shell.shared(), + } + } + + fn authorize( + &self, + input: &TerminalToolInput, + event_stream: &ToolCallEventStream, + cx: &App, + ) -> Task> { + if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions { + return Task::ready(Ok(())); + } + + // TODO: do we want to have a special title here? + cx.foreground_executor() + .spawn(event_stream.authorize(self.initial_title(Ok(input.clone())).to_string())) + } +} + +impl AgentTool for TerminalTool { + type Input = TerminalToolInput; + type Output = String; + + fn name(&self) -> SharedString { + "terminal".into() + } + + fn kind(&self) -> acp::ToolKind { + acp::ToolKind::Execute + } + + fn initial_title(&self, input: Result) -> SharedString { + if let Ok(input) = input { + let mut lines = input.command.lines(); + let first_line = lines.next().unwrap_or_default(); + let remaining_line_count = lines.count(); + match remaining_line_count { + 0 => MarkdownInlineCode(&first_line).to_string().into(), + 1 => MarkdownInlineCode(&format!( + "{} - {} more line", + first_line, remaining_line_count + )) + .to_string() + .into(), + n => MarkdownInlineCode(&format!("{} - {} more lines", first_line, n)) + .to_string() + .into(), + } + } else { + "Run terminal command".into() + } + } + + fn run( + self: Arc, + input: Self::Input, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let language_registry = self.project.read(cx).languages().clone(); + let working_dir = match working_dir(&input, &self.project, cx) { + Ok(dir) => dir, + Err(err) => return Task::ready(Err(err)), + }; + let program = self.determine_shell.clone(); + let command = if cfg!(windows) { + format!("$null | & {{{}}}", input.command.replace("\"", "'")) + } else if let Some(cwd) = working_dir + .as_ref() + .and_then(|cwd| cwd.as_os_str().to_str()) + { + // Make sure once we're *inside* the shell, we cd into `cwd` + format!("(cd {cwd}; {}) self.project.update(cx, |project, cx| { + project.directory_environment(dir.as_path().into(), cx) + }), + None => Task::ready(None).shared(), + }; + + let env = cx.spawn(async move |_| { + let mut env = env.await.unwrap_or_default(); + if cfg!(unix) { + env.insert("PAGER".into(), "cat".into()); + } + env + }); + + let authorize = self.authorize(&input, &event_stream, cx); + + cx.spawn({ + async move |cx| { + authorize.await?; + + let program = program.await; + let env = env.await; + let terminal = self + .project + .update(cx, |project, cx| { + project.create_terminal( + TerminalKind::Task(task::SpawnInTerminal { + command: Some(program), + args, + cwd: working_dir.clone(), + env, + ..Default::default() + }), + cx, + ) + })? + .await?; + let acp_terminal = cx.new(|cx| { + acp_thread::Terminal::new( + input.command.clone(), + working_dir.clone(), + terminal.clone(), + language_registry, + cx, + ) + })?; + event_stream.update_terminal(acp_terminal.clone()); + + let exit_status = terminal + .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))? + .await; + let (content, content_line_count) = terminal.read_with(cx, |terminal, _| { + (terminal.get_content(), terminal.total_lines()) + })?; + + let (processed_content, finished_with_empty_output) = process_content( + &content, + &input.command, + exit_status.map(portable_pty::ExitStatus::from), + ); + + acp_terminal + .update(cx, |terminal, cx| { + terminal.finish( + exit_status, + content.len(), + processed_content.len(), + content_line_count, + finished_with_empty_output, + cx, + ); + }) + .log_err(); + + Ok(processed_content) + } + }) + } +} + +fn process_content( + content: &str, + command: &str, + exit_status: Option, +) -> (String, bool) { + let should_truncate = content.len() > COMMAND_OUTPUT_LIMIT; + + let content = if should_truncate { + let mut end_ix = COMMAND_OUTPUT_LIMIT.min(content.len()); + while !content.is_char_boundary(end_ix) { + end_ix -= 1; + } + // Don't truncate mid-line, clear the remainder of the last line + end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix); + &content[..end_ix] + } else { + content + }; + let content = content.trim(); + let is_empty = content.is_empty(); + let content = format!("```\n{content}\n```"); + let content = if should_truncate { + format!( + "Command output too long. The first {} bytes:\n\n{content}", + content.len(), + ) + } else { + content + }; + + let content = match exit_status { + Some(exit_status) if exit_status.success() => { + if is_empty { + "Command executed successfully.".to_string() + } else { + content.to_string() + } + } + Some(exit_status) => { + if is_empty { + format!( + "Command \"{command}\" failed with exit code {}.", + exit_status.exit_code() + ) + } else { + format!( + "Command \"{command}\" failed with exit code {}.\n\n{content}", + exit_status.exit_code() + ) + } + } + None => { + format!( + "Command failed or was interrupted.\nPartial output captured:\n\n{}", + content, + ) + } + }; + (content, is_empty) +} + +fn working_dir( + input: &TerminalToolInput, + project: &Entity, + cx: &mut App, +) -> Result> { + let project = project.read(cx); + let cd = &input.cd; + + if cd == "." || cd == "" { + // Accept "." or "" as meaning "the one worktree" if we only have one worktree. + let mut worktrees = project.worktrees(cx); + + match worktrees.next() { + Some(worktree) => { + anyhow::ensure!( + worktrees.next().is_none(), + "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.", + ); + Ok(Some(worktree.read(cx).abs_path().to_path_buf())) + } + None => Ok(None), + } + } else { + let input_path = Path::new(cd); + + if input_path.is_absolute() { + // Absolute paths are allowed, but only if they're in one of the project's worktrees. + if project + .worktrees(cx) + .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path())) + { + return Ok(Some(input_path.into())); + } + } else { + if let Some(worktree) = project.worktree_for_root_name(cd, cx) { + return Ok(Some(worktree.read(cx).abs_path().to_path_buf())); + } + } + + anyhow::bail!("`cd` directory {cd:?} was not in any of the project's worktrees."); + } +} + +#[cfg(test)] +mod tests { + use agent_settings::AgentSettings; + use editor::EditorSettings; + use fs::RealFs; + use gpui::{BackgroundExecutor, TestAppContext}; + use pretty_assertions::assert_eq; + use serde_json::json; + use settings::{Settings, SettingsStore}; + use terminal::terminal_settings::TerminalSettings; + use theme::ThemeSettings; + use util::test::TempTree; + + use crate::AgentResponseEvent; + + use super::*; + + fn init_test(executor: &BackgroundExecutor, cx: &mut TestAppContext) { + zlog::init_test(); + + executor.allow_parking(); + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + language::init(cx); + Project::init_settings(cx); + ThemeSettings::register(cx); + TerminalSettings::register(cx); + EditorSettings::register(cx); + AgentSettings::register(cx); + }); + } + + #[gpui::test] + async fn test_interactive_command(executor: BackgroundExecutor, cx: &mut TestAppContext) { + if cfg!(windows) { + return; + } + + init_test(&executor, cx); + + let fs = Arc::new(RealFs::new(None, executor)); + let tree = TempTree::new(json!({ + "project": {}, + })); + let project: Entity = + Project::test(fs, [tree.path().join("project").as_path()], cx).await; + + let input = TerminalToolInput { + command: "cat".to_owned(), + cd: tree + .path() + .join("project") + .as_path() + .to_string_lossy() + .to_string(), + }; + let (event_stream_tx, mut event_stream_rx) = ToolCallEventStream::test(); + let result = cx + .update(|cx| Arc::new(TerminalTool::new(project, cx)).run(input, event_stream_tx, cx)); + + let auth = event_stream_rx.expect_authorization().await; + auth.response.send(auth.options[0].id.clone()).unwrap(); + event_stream_rx.expect_terminal().await; + assert_eq!(result.await.unwrap(), "Command executed successfully."); + } + + #[gpui::test] + async fn test_working_directory(executor: BackgroundExecutor, cx: &mut TestAppContext) { + if cfg!(windows) { + return; + } + + init_test(&executor, cx); + + let fs = Arc::new(RealFs::new(None, executor)); + let tree = TempTree::new(json!({ + "project": {}, + "other-project": {}, + })); + let project: Entity = + Project::test(fs, [tree.path().join("project").as_path()], cx).await; + + let check = |input, expected, cx: &mut TestAppContext| { + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let result = cx.update(|cx| { + Arc::new(TerminalTool::new(project.clone(), cx)).run(input, stream_tx, cx) + }); + cx.run_until_parked(); + let event = stream_rx.try_next(); + if let Ok(Some(Ok(AgentResponseEvent::ToolCallAuthorization(auth)))) = event { + auth.response.send(auth.options[0].id.clone()).unwrap(); + } + + cx.spawn(async move |_| { + let output = result.await; + assert_eq!(output.ok(), expected); + }) + }; + + check( + TerminalToolInput { + command: "pwd".into(), + cd: ".".into(), + }, + Some(format!( + "```\n{}\n```", + tree.path().join("project").display() + )), + cx, + ) + .await; + + check( + TerminalToolInput { + command: "pwd".into(), + cd: "other-project".into(), + }, + None, // other-project is a dir, but *not* a worktree (yet) + cx, + ) + .await; + + // Absolute path above the worktree root + check( + TerminalToolInput { + command: "pwd".into(), + cd: tree.path().to_string_lossy().into(), + }, + None, + cx, + ) + .await; + + project + .update(cx, |project, cx| { + project.create_worktree(tree.path().join("other-project"), true, cx) + }) + .await + .unwrap(); + + check( + TerminalToolInput { + command: "pwd".into(), + cd: "other-project".into(), + }, + Some(format!( + "```\n{}\n```", + tree.path().join("other-project").display() + )), + cx, + ) + .await; + + check( + TerminalToolInput { + command: "pwd".into(), + cd: ".".into(), + }, + None, + cx, + ) + .await; + } +} diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 01980b8fb7..2536612ece 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -1,17 +1,13 @@ +use acp_thread::{ + AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk, + LoadError, MentionPath, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus, +}; use acp_thread::{AgentConnection, Plan}; +use action_log::ActionLog; +use agent_client_protocol as acp; use agent_servers::AgentServer; use agent_settings::{AgentSettings, NotifyWhenAgentWaiting}; use audio::{Audio, Sound}; -use std::cell::RefCell; -use std::collections::BTreeMap; -use std::path::Path; -use std::process::ExitStatus; -use std::rc::Rc; -use std::sync::Arc; -use std::time::Duration; - -use action_log::ActionLog; -use agent_client_protocol as acp; use buffer_diff::BufferDiff; use collections::{HashMap, HashSet}; use editor::{ @@ -32,6 +28,11 @@ use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle}; use parking_lot::Mutex; use project::{CompletionIntent, Project}; use settings::{Settings as _, SettingsStore}; +use std::{ + cell::RefCell, collections::BTreeMap, path::Path, process::ExitStatus, rc::Rc, sync::Arc, + time::Duration, +}; +use terminal_view::TerminalView; use text::{Anchor, BufferSnapshot}; use theme::ThemeSettings; use ui::{ @@ -41,11 +42,6 @@ use util::ResultExt; use workspace::{CollaboratorId, Workspace}; use zed_actions::agent::{Chat, NextHistoryMessage, PreviousHistoryMessage}; -use ::acp_thread::{ - AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk, - LoadError, MentionPath, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus, -}; - use crate::acp::completion_provider::{ContextPickerCompletionProvider, MentionSet}; use crate::acp::message_history::MessageHistory; use crate::agent_diff::AgentDiff; @@ -63,6 +59,7 @@ pub struct AcpThreadView { project: Entity, thread_state: ThreadState, diff_editors: HashMap>, + terminal_views: HashMap>, message_editor: Entity, message_set_from_history: Option, _message_editor_subscription: Subscription, @@ -193,6 +190,7 @@ impl AcpThreadView { notifications: Vec::new(), notification_subscriptions: HashMap::default(), diff_editors: Default::default(), + terminal_views: Default::default(), list_state: list_state.clone(), scrollbar_state: ScrollbarState::new(list_state).parent_entity(&cx.entity()), last_error: None, @@ -676,6 +674,16 @@ impl AcpThreadView { entry_ix: usize, window: &mut Window, cx: &mut Context, + ) { + self.sync_diff_multibuffers(entry_ix, window, cx); + self.sync_terminals(entry_ix, window, cx); + } + + fn sync_diff_multibuffers( + &mut self, + entry_ix: usize, + window: &mut Window, + cx: &mut Context, ) { let Some(multibuffers) = self.entry_diff_multibuffers(entry_ix, cx) else { return; @@ -739,6 +747,50 @@ impl AcpThreadView { ) } + fn sync_terminals(&mut self, entry_ix: usize, window: &mut Window, cx: &mut Context) { + let Some(terminals) = self.entry_terminals(entry_ix, cx) else { + return; + }; + + let terminals = terminals.collect::>(); + + for terminal in terminals { + if self.terminal_views.contains_key(&terminal.entity_id()) { + return; + } + + let terminal_view = cx.new(|cx| { + let mut view = TerminalView::new( + terminal.read(cx).inner().clone(), + self.workspace.clone(), + None, + self.project.downgrade(), + window, + cx, + ); + view.set_embedded_mode(None, cx); + view + }); + + let entity_id = terminal.entity_id(); + cx.observe_release(&terminal, move |this, _, _| { + this.terminal_views.remove(&entity_id); + }) + .detach(); + + self.terminal_views.insert(entity_id, terminal_view); + } + } + + fn entry_terminals( + &self, + entry_ix: usize, + cx: &App, + ) -> Option>> { + let entry = self.thread()?.read(cx).entries().get(entry_ix)?; + Some(entry.terminals().map(|terminal| terminal.clone())) + } + fn authenticate( &mut self, method: acp::AuthMethodId, @@ -1106,7 +1158,7 @@ impl AcpThreadView { _ => tool_call .content .iter() - .any(|content| matches!(content, ToolCallContent::Diff { .. })), + .any(|content| matches!(content, ToolCallContent::Diff(_))), }; let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation; @@ -1303,7 +1355,7 @@ impl AcpThreadView { cx: &Context, ) -> AnyElement { match content { - ToolCallContent::ContentBlock { content } => { + ToolCallContent::ContentBlock(content) => { if let Some(md) = content.markdown() { div() .p_2() @@ -1318,9 +1370,8 @@ impl AcpThreadView { Empty.into_any_element() } } - ToolCallContent::Diff { diff, .. } => { - self.render_diff_editor(&diff.read(cx).multibuffer()) - } + ToolCallContent::Diff(diff) => self.render_diff_editor(&diff.read(cx).multibuffer()), + ToolCallContent::Terminal(terminal) => self.render_terminal(terminal), } } @@ -1389,6 +1440,21 @@ impl AcpThreadView { .into_any() } + fn render_terminal(&self, terminal: &Entity) -> AnyElement { + v_flex() + .h_72() + .child( + if let Some(terminal_view) = self.terminal_views.get(&terminal.entity_id()) { + // TODO: terminal has all the state we need to reproduce + // what we had in the terminal card. + terminal_view.clone().into_any_element() + } else { + Empty.into_any() + }, + ) + .into_any() + } + fn render_agent_logo(&self) -> AnyElement { Icon::new(self.agent.logo()) .color(Color::Muted) diff --git a/crates/terminal/Cargo.toml b/crates/terminal/Cargo.toml index 93f61622c8..b1c0dd693f 100644 --- a/crates/terminal/Cargo.toml +++ b/crates/terminal/Cargo.toml @@ -5,6 +5,13 @@ edition.workspace = true publish.workspace = true license = "GPL-3.0-or-later" +[features] +test-support = [ + "collections/test-support", + "gpui/test-support", + "settings/test-support", +] + [lints] workspace = true @@ -39,5 +46,6 @@ workspace-hack.workspace = true windows.workspace = true [dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } rand.workspace = true url.workspace = true diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index d6a09a590f..3e7d9c0ad4 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -58,7 +58,7 @@ use std::{ path::PathBuf, process::ExitStatus, sync::Arc, - time::{Duration, Instant}, + time::Instant, }; use thiserror::Error; @@ -534,10 +534,15 @@ impl TerminalBuilder { 'outer: loop { let mut events = Vec::new(); + + #[cfg(any(test, feature = "test-support"))] + let mut timer = cx.background_executor().simulate_random_delay().fuse(); + #[cfg(not(any(test, feature = "test-support")))] let mut timer = cx .background_executor() - .timer(Duration::from_millis(4)) + .timer(std::time::Duration::from_millis(4)) .fuse(); + let mut wakeup = false; loop { futures::select_biased! { @@ -2104,16 +2109,56 @@ pub fn rgba_color(r: u8, g: u8, b: u8) -> Hsla { #[cfg(test)] mod tests { + use super::*; + use crate::{ + IndexedCell, TerminalBounds, TerminalBuilder, TerminalContent, content_index_for_mouse, + rgb_for_index, + }; use alacritty_terminal::{ index::{Column, Line, Point as AlacPoint}, term::cell::Cell, }; - use gpui::{Pixels, Point, bounds, point, size}; + use collections::HashMap; + use gpui::{Pixels, Point, TestAppContext, bounds, point, size}; use rand::{Rng, distributions::Alphanumeric, rngs::ThreadRng, thread_rng}; - use crate::{ - IndexedCell, TerminalBounds, TerminalContent, content_index_for_mouse, rgb_for_index, - }; + #[cfg_attr(windows, ignore = "TODO: fix on windows")] + #[gpui::test] + async fn test_basic_terminal(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + + let (completion_tx, completion_rx) = smol::channel::unbounded(); + let terminal = cx.new(|cx| { + TerminalBuilder::new( + None, + None, + None, + task::Shell::WithArguments { + program: "echo".into(), + args: vec!["hello".into()], + title_override: None, + }, + HashMap::default(), + CursorShape::default(), + AlternateScroll::On, + None, + false, + 0, + completion_tx, + cx, + ) + .unwrap() + .subscribe(cx) + }); + assert_eq!( + completion_rx.recv().await.unwrap(), + Some(ExitStatus::default()) + ); + assert_eq!( + terminal.update(cx, |term, _| term.get_content()).trim(), + "hello" + ); + } #[test] fn test_rgb_for_index() { From 702a95ffb22fc0d36b74ca1fad683defd8dbc54e Mon Sep 17 00:00:00 2001 From: localcc Date: Mon, 11 Aug 2025 13:57:30 +0200 Subject: [PATCH 050/109] Fix underline DPI (#35816) Release Notes: - Fixed wavy underlines looking inconsistent on different displays --- crates/gpui/src/platform/blade/shaders.wgsl | 9 +++++++-- crates/gpui/src/platform/mac/shaders.metal | 9 +++++++-- crates/gpui/src/platform/windows/shaders.hlsl | 11 ++++++++--- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/crates/gpui/src/platform/blade/shaders.wgsl b/crates/gpui/src/platform/blade/shaders.wgsl index b1ffb1812e..95980b54fe 100644 --- a/crates/gpui/src/platform/blade/shaders.wgsl +++ b/crates/gpui/src/platform/blade/shaders.wgsl @@ -1057,6 +1057,9 @@ fn vs_underline(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index) @fragment fn fs_underline(input: UnderlineVarying) -> @location(0) vec4 { + const WAVE_FREQUENCY: f32 = 2.0; + const WAVE_HEIGHT_RATIO: f32 = 0.8; + // Alpha clip first, since we don't have `clip_distance`. if (any(input.clip_distances < vec4(0.0))) { return vec4(0.0); @@ -1069,9 +1072,11 @@ fn fs_underline(input: UnderlineVarying) -> @location(0) vec4 { } let half_thickness = underline.thickness * 0.5; + let st = (input.position.xy - underline.bounds.origin) / underline.bounds.size.y - vec2(0.0, 0.5); - let frequency = M_PI_F * 3.0 * underline.thickness / 3.0; - let amplitude = 1.0 / (4.0 * underline.thickness); + let frequency = M_PI_F * WAVE_FREQUENCY * underline.thickness / underline.bounds.size.y; + let amplitude = (underline.thickness * WAVE_HEIGHT_RATIO) / underline.bounds.size.y; + let sine = sin(st.x * frequency) * amplitude; let dSine = cos(st.x * frequency) * amplitude * frequency; let distance = (st.y - sine) / sqrt(1.0 + dSine * dSine); diff --git a/crates/gpui/src/platform/mac/shaders.metal b/crates/gpui/src/platform/mac/shaders.metal index f9d5bdbf4c..83c978b853 100644 --- a/crates/gpui/src/platform/mac/shaders.metal +++ b/crates/gpui/src/platform/mac/shaders.metal @@ -567,15 +567,20 @@ vertex UnderlineVertexOutput underline_vertex( fragment float4 underline_fragment(UnderlineFragmentInput input [[stage_in]], constant Underline *underlines [[buffer(UnderlineInputIndex_Underlines)]]) { + const float WAVE_FREQUENCY = 2.0; + const float WAVE_HEIGHT_RATIO = 0.8; + Underline underline = underlines[input.underline_id]; if (underline.wavy) { float half_thickness = underline.thickness * 0.5; float2 origin = float2(underline.bounds.origin.x, underline.bounds.origin.y); + float2 st = ((input.position.xy - origin) / underline.bounds.size.height) - float2(0., 0.5); - float frequency = (M_PI_F * (3. * underline.thickness)) / 8.; - float amplitude = 1. / (2. * underline.thickness); + float frequency = (M_PI_F * WAVE_FREQUENCY * underline.thickness) / underline.bounds.size.height; + float amplitude = (underline.thickness * WAVE_HEIGHT_RATIO) / underline.bounds.size.height; + float sine = sin(st.x * frequency) * amplitude; float dSine = cos(st.x * frequency) * amplitude * frequency; float distance = (st.y - sine) / sqrt(1. + dSine * dSine); diff --git a/crates/gpui/src/platform/windows/shaders.hlsl b/crates/gpui/src/platform/windows/shaders.hlsl index 25830e4b6c..6fabe859e3 100644 --- a/crates/gpui/src/platform/windows/shaders.hlsl +++ b/crates/gpui/src/platform/windows/shaders.hlsl @@ -914,7 +914,7 @@ float4 path_rasterization_fragment(PathFragmentInput input): SV_Target { float2 dx = ddx(input.st_position); float2 dy = ddy(input.st_position); PathRasterizationSprite sprite = path_rasterization_sprites[input.vertex_id]; - + Background background = sprite.color; Bounds bounds = sprite.bounds; @@ -1021,13 +1021,18 @@ UnderlineVertexOutput underline_vertex(uint vertex_id: SV_VertexID, uint underli } float4 underline_fragment(UnderlineFragmentInput input): SV_Target { + const float WAVE_FREQUENCY = 2.0; + const float WAVE_HEIGHT_RATIO = 0.8; + Underline underline = underlines[input.underline_id]; if (underline.wavy) { float half_thickness = underline.thickness * 0.5; float2 origin = underline.bounds.origin; + float2 st = ((input.position.xy - origin) / underline.bounds.size.y) - float2(0., 0.5); - float frequency = (M_PI_F * (3. * underline.thickness)) / 8.; - float amplitude = 1. / (2. * underline.thickness); + float frequency = (M_PI_F * WAVE_FREQUENCY * underline.thickness) / underline.bounds.size.y; + float amplitude = (underline.thickness * WAVE_HEIGHT_RATIO) / underline.bounds.size.y; + float sine = sin(st.x * frequency) * amplitude; float dSine = cos(st.x * frequency) * amplitude * frequency; float distance = (st.y - sine) / sqrt(1. + dSine * dSine); From a88c533ffc4563ccd2403ace36884753cd3a8db2 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Mon, 11 Aug 2025 14:24:53 +0200 Subject: [PATCH 051/109] language: Fix rust-analyzer removing itself on download (#35971) Release Notes: - N/A\ --- crates/languages/src/rust.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index 1d489052e6..e79f0c9e8e 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -238,7 +238,7 @@ impl LspAdapter for RustLspAdapter { ) .await?; make_file_executable(&server_path).await?; - remove_matching(&container_dir, |path| server_path == path).await; + remove_matching(&container_dir, |path| server_path != path).await; GithubBinaryMetadata::write_to_file( &GithubBinaryMetadata { metadata_version: 1, From d5ed569fad878c838753c2ad11f868afc0eaa893 Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Mon, 11 Aug 2025 15:33:16 +0300 Subject: [PATCH 052/109] zeta: Reduce request payload (#35968) 1. Don't send diagnostics if there are more than 10 of them. This fixes an issue with sending 100kb requests for projects with many warnings. 2. Don't send speculated_output and outline, as those are currently unused. Release Notes: - Improved edit prediction latency --- crates/zeta/src/input_excerpt.rs | 9 ------- crates/zeta/src/zeta.rs | 41 +++++--------------------------- 2 files changed, 6 insertions(+), 44 deletions(-) diff --git a/crates/zeta/src/input_excerpt.rs b/crates/zeta/src/input_excerpt.rs index 5949e713e9..8ca6d39407 100644 --- a/crates/zeta/src/input_excerpt.rs +++ b/crates/zeta/src/input_excerpt.rs @@ -9,7 +9,6 @@ use std::{fmt::Write, ops::Range}; pub struct InputExcerpt { pub editable_range: Range, pub prompt: String, - pub speculated_output: String, } pub fn excerpt_for_cursor_position( @@ -46,7 +45,6 @@ pub fn excerpt_for_cursor_position( let context_range = expand_range(snapshot, editable_range.clone(), context_token_limit); let mut prompt = String::new(); - let mut speculated_output = String::new(); writeln!(&mut prompt, "```{path}").unwrap(); if context_range.start == Point::zero() { @@ -58,12 +56,6 @@ pub fn excerpt_for_cursor_position( } push_editable_range(position, snapshot, editable_range.clone(), &mut prompt); - push_editable_range( - position, - snapshot, - editable_range.clone(), - &mut speculated_output, - ); for chunk in snapshot.chunks(editable_range.end..context_range.end, false) { prompt.push_str(chunk.text); @@ -73,7 +65,6 @@ pub fn excerpt_for_cursor_position( InputExcerpt { editable_range, prompt, - speculated_output, } } diff --git a/crates/zeta/src/zeta.rs b/crates/zeta/src/zeta.rs index 1ddbd25cb8..6900082003 100644 --- a/crates/zeta/src/zeta.rs +++ b/crates/zeta/src/zeta.rs @@ -37,7 +37,6 @@ use release_channel::AppVersion; use settings::WorktreeId; use std::str::FromStr; use std::{ - borrow::Cow, cmp, fmt::Write, future::Future, @@ -66,6 +65,7 @@ const ZED_PREDICT_DATA_COLLECTION_CHOICE: &str = "zed_predict_data_collection_ch const MAX_CONTEXT_TOKENS: usize = 150; const MAX_REWRITE_TOKENS: usize = 350; const MAX_EVENT_TOKENS: usize = 500; +const MAX_DIAGNOSTIC_GROUPS: usize = 10; /// Maximum number of events to track. const MAX_EVENT_COUNT: usize = 16; @@ -1175,7 +1175,9 @@ pub fn gather_context( cx.background_spawn({ let snapshot = snapshot.clone(); async move { - let diagnostic_groups = if diagnostic_groups.is_empty() { + let diagnostic_groups = if diagnostic_groups.is_empty() + || diagnostic_groups.len() >= MAX_DIAGNOSTIC_GROUPS + { None } else { Some(diagnostic_groups) @@ -1189,21 +1191,16 @@ pub fn gather_context( MAX_CONTEXT_TOKENS, ); let input_events = make_events_prompt(); - let input_outline = if can_collect_data { - prompt_for_outline(&snapshot) - } else { - String::new() - }; let editable_range = input_excerpt.editable_range.to_offset(&snapshot); let body = PredictEditsBody { input_events, input_excerpt: input_excerpt.prompt, - speculated_output: Some(input_excerpt.speculated_output), - outline: Some(input_outline), can_collect_data, diagnostic_groups, git_info, + outline: None, + speculated_output: None, }; Ok(GatherContextOutput { @@ -1214,32 +1211,6 @@ pub fn gather_context( }) } -fn prompt_for_outline(snapshot: &BufferSnapshot) -> String { - let mut input_outline = String::new(); - - writeln!( - input_outline, - "```{}", - snapshot - .file() - .map_or(Cow::Borrowed("untitled"), |file| file - .path() - .to_string_lossy()) - ) - .unwrap(); - - if let Some(outline) = snapshot.outline(None) { - for item in &outline.items { - let spacing = " ".repeat(item.depth); - writeln!(input_outline, "{}{}", spacing, item.text).unwrap(); - } - } - - writeln!(input_outline, "```").unwrap(); - - input_outline -} - fn prompt_for_events(events: &VecDeque, mut remaining_tokens: usize) -> String { let mut result = String::new(); for event in events.iter().rev() { From ebcce8730dee6f611f729c922d1a8f5793d68257 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 11 Aug 2025 15:10:46 +0200 Subject: [PATCH 053/109] Port some more tools to `agent2` (#35973) Release Notes: - N/A --- Cargo.lock | 2 + crates/agent2/Cargo.toml | 2 + crates/agent2/src/agent.rs | 8 +- crates/agent2/src/tools.rs | 12 + crates/agent2/src/tools/copy_path_tool.rs | 118 ++++ .../agent2/src/tools/create_directory_tool.rs | 89 +++ crates/agent2/src/tools/delete_path_tool.rs | 137 ++++ .../agent2/src/tools/list_directory_tool.rs | 664 ++++++++++++++++++ crates/agent2/src/tools/move_path_tool.rs | 123 ++++ crates/agent2/src/tools/open_tool.rs | 170 +++++ 10 files changed, 1324 insertions(+), 1 deletion(-) create mode 100644 crates/agent2/src/tools/copy_path_tool.rs create mode 100644 crates/agent2/src/tools/create_directory_tool.rs create mode 100644 crates/agent2/src/tools/delete_path_tool.rs create mode 100644 crates/agent2/src/tools/list_directory_tool.rs create mode 100644 crates/agent2/src/tools/move_path_tool.rs create mode 100644 crates/agent2/src/tools/open_tool.rs diff --git a/Cargo.lock b/Cargo.lock index 634bacd0f3..f0d21381fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -210,6 +210,7 @@ dependencies = [ "language_models", "log", "lsp", + "open", "paths", "portable-pty", "pretty_assertions", @@ -223,6 +224,7 @@ dependencies = [ "settings", "smol", "task", + "tempfile", "terminal", "theme", "ui", diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml index 65452f60fc..a288ff30b2 100644 --- a/crates/agent2/Cargo.toml +++ b/crates/agent2/Cargo.toml @@ -32,6 +32,7 @@ language.workspace = true language_model.workspace = true language_models.workspace = true log.workspace = true +open.workspace = true paths.workspace = true portable-pty.workspace = true project.workspace = true @@ -67,6 +68,7 @@ pretty_assertions.workspace = true project = { workspace = true, "features" = ["test-support"] } reqwest_client.workspace = true settings = { workspace = true, "features" = ["test-support"] } +tempfile.workspace = true terminal = { workspace = true, "features" = ["test-support"] } theme = { workspace = true, "features" = ["test-support"] } worktree = { workspace = true, "features" = ["test-support"] } diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index edb79003b4..398ea6ad50 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -1,6 +1,7 @@ use crate::{AgentResponseEvent, Thread, templates::Templates}; use crate::{ - EditFileTool, FindPathTool, ReadFileTool, TerminalTool, ThinkingTool, ToolCallAuthorization, + CopyPathTool, CreateDirectoryTool, EditFileTool, FindPathTool, ListDirectoryTool, MovePathTool, + OpenTool, ReadFileTool, TerminalTool, ThinkingTool, ToolCallAuthorization, }; use acp_thread::ModelSelector; use agent_client_protocol as acp; @@ -416,6 +417,11 @@ impl acp_thread::AgentConnection for NativeAgentConnection { let thread = cx.new(|cx| { let mut thread = Thread::new(project.clone(), agent.project_context.clone(), action_log.clone(), agent.templates.clone(), default_model); + thread.add_tool(CreateDirectoryTool::new(project.clone())); + thread.add_tool(CopyPathTool::new(project.clone())); + thread.add_tool(MovePathTool::new(project.clone())); + thread.add_tool(ListDirectoryTool::new(project.clone())); + thread.add_tool(OpenTool::new(project.clone())); thread.add_tool(ThinkingTool); thread.add_tool(FindPathTool::new(project.clone())); thread.add_tool(ReadFileTool::new(project.clone(), action_log)); diff --git a/crates/agent2/src/tools.rs b/crates/agent2/src/tools.rs index df4a7a9580..5c3920fcbb 100644 --- a/crates/agent2/src/tools.rs +++ b/crates/agent2/src/tools.rs @@ -1,11 +1,23 @@ +mod copy_path_tool; +mod create_directory_tool; +mod delete_path_tool; mod edit_file_tool; mod find_path_tool; +mod list_directory_tool; +mod move_path_tool; +mod open_tool; mod read_file_tool; mod terminal_tool; mod thinking_tool; +pub use copy_path_tool::*; +pub use create_directory_tool::*; +pub use delete_path_tool::*; pub use edit_file_tool::*; pub use find_path_tool::*; +pub use list_directory_tool::*; +pub use move_path_tool::*; +pub use open_tool::*; pub use read_file_tool::*; pub use terminal_tool::*; pub use thinking_tool::*; diff --git a/crates/agent2/src/tools/copy_path_tool.rs b/crates/agent2/src/tools/copy_path_tool.rs new file mode 100644 index 0000000000..f973b86990 --- /dev/null +++ b/crates/agent2/src/tools/copy_path_tool.rs @@ -0,0 +1,118 @@ +use crate::{AgentTool, ToolCallEventStream}; +use agent_client_protocol::ToolKind; +use anyhow::{Context as _, Result, anyhow}; +use gpui::{App, AppContext, Entity, SharedString, Task}; +use project::Project; +use schemars::JsonSchema; +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. +/// +/// 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. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct CopyPathToolInput { + /// The source path of the file or directory to copy. + /// If a directory is specified, its contents will be copied recursively (like `cp -r`). + /// + /// + /// If the project has the following files: + /// + /// - directory1/a/something.txt + /// - directory2/a/things.txt + /// - directory3/a/other.txt + /// + /// 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" + /// + pub destination_path: String, +} + +pub struct CopyPathTool { + project: Entity, +} + +impl CopyPathTool { + pub fn new(project: Entity) -> Self { + Self { project } + } +} + +impl AgentTool for CopyPathTool { + type Input = CopyPathToolInput; + type Output = String; + + fn name(&self) -> SharedString { + "copy_path".into() + } + + fn kind(&self) -> ToolKind { + ToolKind::Move + } + + fn initial_title(&self, input: Result) -> ui::SharedString { + if let Ok(input) = input { + let src = MarkdownInlineCode(&input.source_path); + let dest = MarkdownInlineCode(&input.destination_path); + format!("Copy {src} to {dest}").into() + } else { + "Copy path".into() + } + } + + fn run( + self: Arc, + input: Self::Input, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let copy_task = self.project.update(cx, |project, cx| { + match project + .find_project_path(&input.source_path, cx) + .and_then(|project_path| project.entry_for_path(&project_path, cx)) + { + Some(entity) => match project.find_project_path(&input.destination_path, cx) { + Some(project_path) => { + project.copy_entry(entity.id, None, project_path.path, cx) + } + None => Task::ready(Err(anyhow!( + "Destination path {} was outside the project.", + input.destination_path + ))), + }, + None => Task::ready(Err(anyhow!( + "Source path {} was not found in the project.", + input.source_path + ))), + } + }); + + cx.background_spawn(async move { + let _ = copy_task.await.with_context(|| { + format!( + "Copying {} to {}", + input.source_path, input.destination_path + ) + })?; + Ok(format!( + "Copied {} to {}", + input.source_path, input.destination_path + )) + }) + } +} diff --git a/crates/agent2/src/tools/create_directory_tool.rs b/crates/agent2/src/tools/create_directory_tool.rs new file mode 100644 index 0000000000..c173c5ae67 --- /dev/null +++ b/crates/agent2/src/tools/create_directory_tool.rs @@ -0,0 +1,89 @@ +use agent_client_protocol::ToolKind; +use anyhow::{Context as _, Result, anyhow}; +use gpui::{App, Entity, SharedString, Task}; +use project::Project; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +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. +/// +/// 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. + /// + /// + /// If the project has the following structure: + /// + /// - directory1/ + /// - directory2/ + /// + /// You can create a new directory by providing a path of "directory1/new_directory" + /// + pub path: String, +} + +pub struct CreateDirectoryTool { + project: Entity, +} + +impl CreateDirectoryTool { + pub fn new(project: Entity) -> Self { + Self { project } + } +} + +impl AgentTool for CreateDirectoryTool { + type Input = CreateDirectoryToolInput; + type Output = String; + + fn name(&self) -> SharedString { + "create_directory".into() + } + + fn kind(&self) -> ToolKind { + ToolKind::Read + } + + fn initial_title(&self, input: Result) -> SharedString { + if let Ok(input) = input { + format!("Create directory {}", MarkdownInlineCode(&input.path)).into() + } else { + "Create directory".into() + } + } + + fn run( + self: Arc, + input: Self::Input, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let project_path = match self.project.read(cx).find_project_path(&input.path, cx) { + Some(project_path) => project_path, + None => { + return Task::ready(Err(anyhow!("Path to create was outside the project"))); + } + }; + let destination_path: Arc = input.path.as_str().into(); + + let create_entry = self.project.update(cx, |project, cx| { + project.create_entry(project_path.clone(), true, cx) + }); + + cx.spawn(async move |_cx| { + create_entry + .await + .with_context(|| format!("Creating directory {destination_path}"))?; + + Ok(format!("Created directory {destination_path}")) + }) + } +} diff --git a/crates/agent2/src/tools/delete_path_tool.rs b/crates/agent2/src/tools/delete_path_tool.rs new file mode 100644 index 0000000000..e013b3a3e7 --- /dev/null +++ b/crates/agent2/src/tools/delete_path_tool.rs @@ -0,0 +1,137 @@ +use crate::{AgentTool, ToolCallEventStream}; +use action_log::ActionLog; +use agent_client_protocol::ToolKind; +use anyhow::{Context as _, Result, anyhow}; +use futures::{SinkExt, StreamExt, channel::mpsc}; +use gpui::{App, AppContext, Entity, SharedString, Task}; +use project::{Project, ProjectPath}; +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. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct DeletePathToolInput { + /// The path of the file or directory to delete. + /// + /// + /// If the project has the following files: + /// + /// - directory1/a/something.txt + /// - directory2/a/things.txt + /// - directory3/a/other.txt + /// + /// You can delete the first file by providing a path of "directory1/a/something.txt" + /// + pub path: String, +} + +pub struct DeletePathTool { + project: Entity, + action_log: Entity, +} + +impl DeletePathTool { + pub fn new(project: Entity, action_log: Entity) -> Self { + Self { + project, + action_log, + } + } +} + +impl AgentTool for DeletePathTool { + type Input = DeletePathToolInput; + type Output = String; + + fn name(&self) -> SharedString { + "delete_path".into() + } + + fn kind(&self) -> ToolKind { + ToolKind::Delete + } + + fn initial_title(&self, input: Result) -> SharedString { + if let Ok(input) = input { + format!("Delete “`{}`”", input.path).into() + } else { + "Delete path".into() + } + } + + fn run( + self: Arc, + input: Self::Input, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let path = input.path; + let Some(project_path) = self.project.read(cx).find_project_path(&path, cx) else { + return Task::ready(Err(anyhow!( + "Couldn't delete {path} because that path isn't in this project." + ))); + }; + + let Some(worktree) = self + .project + .read(cx) + .worktree_for_id(project_path.worktree_id, cx) + else { + return Task::ready(Err(anyhow!( + "Couldn't delete {path} because that path isn't in this project." + ))); + }; + + let worktree_snapshot = worktree.read(cx).snapshot(); + let (mut paths_tx, mut paths_rx) = mpsc::channel(256); + cx.background_spawn({ + let project_path = project_path.clone(); + async move { + for entry in + worktree_snapshot.traverse_from_path(true, false, false, &project_path.path) + { + if !entry.path.starts_with(&project_path.path) { + break; + } + paths_tx + .send(ProjectPath { + worktree_id: project_path.worktree_id, + path: entry.path.clone(), + }) + .await?; + } + anyhow::Ok(()) + } + }) + .detach(); + + let project = self.project.clone(); + let action_log = self.action_log.clone(); + cx.spawn(async move |cx| { + while let Some(path) = paths_rx.next().await { + if let Ok(buffer) = project + .update(cx, |project, cx| project.open_buffer(path, cx))? + .await + { + action_log.update(cx, |action_log, cx| { + action_log.will_delete_buffer(buffer.clone(), cx) + })?; + } + } + + let deletion_task = project + .update(cx, |project, cx| { + project.delete_file(project_path, false, cx) + })? + .with_context(|| { + format!("Couldn't delete {path} because that path isn't in this project.") + })?; + deletion_task + .await + .with_context(|| format!("Deleting {path}"))?; + Ok(format!("Deleted {path}")) + }) + } +} diff --git a/crates/agent2/src/tools/list_directory_tool.rs b/crates/agent2/src/tools/list_directory_tool.rs new file mode 100644 index 0000000000..61f21d8f95 --- /dev/null +++ b/crates/agent2/src/tools/list_directory_tool.rs @@ -0,0 +1,664 @@ +use crate::{AgentTool, ToolCallEventStream}; +use agent_client_protocol::ToolKind; +use anyhow::{Result, anyhow}; +use gpui::{App, Entity, SharedString, Task}; +use project::{Project, WorktreeSettings}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::Settings; +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. +#[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. + /// + /// + /// If the project has the following root directories: + /// + /// - directory1 + /// - directory2 + /// + /// You can list the contents of `directory1` by using the path `directory1`. + /// + /// + /// + /// If the project has the following root directories: + /// + /// - foo + /// - bar + /// + /// If you wanna list contents in the directory `foo/baz`, you should use the path `foo/baz`. + /// + pub path: String, +} + +pub struct ListDirectoryTool { + project: Entity, +} + +impl ListDirectoryTool { + pub fn new(project: Entity) -> Self { + Self { project } + } +} + +impl AgentTool for ListDirectoryTool { + type Input = ListDirectoryToolInput; + type Output = String; + + fn name(&self) -> SharedString { + "list_directory".into() + } + + fn kind(&self) -> ToolKind { + ToolKind::Read + } + + fn initial_title(&self, input: Result) -> SharedString { + if let Ok(input) = input { + let path = MarkdownInlineCode(&input.path); + format!("List the {path} directory's contents").into() + } else { + "List directory".into() + } + } + + fn run( + self: Arc, + input: Self::Input, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + // Sometimes models will return these even though we tell it to give a path and not a glob. + // When this happens, just list the root worktree directories. + if matches!(input.path.as_str(), "." | "" | "./" | "*") { + let output = self + .project + .read(cx) + .worktrees(cx) + .filter_map(|worktree| { + worktree.read(cx).root_entry().and_then(|entry| { + if entry.is_dir() { + entry.path.to_str() + } else { + None + } + }) + }) + .collect::>() + .join("\n"); + + return Task::ready(Ok(output)); + } + + let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx) else { + return Task::ready(Err(anyhow!("Path {} not found in project", input.path))); + }; + let Some(worktree) = self + .project + .read(cx) + .worktree_for_id(project_path.worktree_id, cx) + else { + return Task::ready(Err(anyhow!("Worktree not found"))); + }; + + // Check if the directory whose contents we're listing is itself excluded or private + let global_settings = WorktreeSettings::get_global(cx); + if global_settings.is_path_excluded(&project_path.path) { + return Task::ready(Err(anyhow!( + "Cannot list directory because its path matches the user's global `file_scan_exclusions` setting: {}", + &input.path + ))); + } + + if global_settings.is_path_private(&project_path.path) { + return Task::ready(Err(anyhow!( + "Cannot list directory because its path matches the user's global `private_files` setting: {}", + &input.path + ))); + } + + let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx); + if worktree_settings.is_path_excluded(&project_path.path) { + return Task::ready(Err(anyhow!( + "Cannot list directory because its path matches the user's worktree`file_scan_exclusions` setting: {}", + &input.path + ))); + } + + if worktree_settings.is_path_private(&project_path.path) { + return Task::ready(Err(anyhow!( + "Cannot list directory because its path matches the user's worktree `private_paths` setting: {}", + &input.path + ))); + } + + let worktree_snapshot = worktree.read(cx).snapshot(); + let worktree_root_name = worktree.read(cx).root_name().to_string(); + + let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else { + return Task::ready(Err(anyhow!("Path not found: {}", input.path))); + }; + + if !entry.is_dir() { + return Task::ready(Err(anyhow!("{} is not a directory.", input.path))); + } + let worktree_snapshot = worktree.read(cx).snapshot(); + + let mut folders = Vec::new(); + let mut files = Vec::new(); + + for entry in worktree_snapshot.child_entries(&project_path.path) { + // Skip private and excluded files and directories + if global_settings.is_path_private(&entry.path) + || global_settings.is_path_excluded(&entry.path) + { + continue; + } + + if self + .project + .read(cx) + .find_project_path(&entry.path, cx) + .map(|project_path| { + let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx); + + worktree_settings.is_path_excluded(&project_path.path) + || worktree_settings.is_path_private(&project_path.path) + }) + .unwrap_or(false) + { + continue; + } + + let full_path = Path::new(&worktree_root_name) + .join(&entry.path) + .display() + .to_string(); + if entry.is_dir() { + folders.push(full_path); + } else { + files.push(full_path); + } + } + + let mut output = String::new(); + + if !folders.is_empty() { + writeln!(output, "# Folders:\n{}", folders.join("\n")).unwrap(); + } + + if !files.is_empty() { + writeln!(output, "\n# Files:\n{}", files.join("\n")).unwrap(); + } + + if output.is_empty() { + writeln!(output, "{} is empty.", input.path).unwrap(); + } + + Task::ready(Ok(output)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{TestAppContext, UpdateGlobal}; + use indoc::indoc; + use project::{FakeFs, Project, WorktreeSettings}; + use serde_json::json; + use settings::SettingsStore; + use util::path; + + fn platform_paths(path_str: &str) -> String { + if cfg!(target_os = "windows") { + path_str.replace("/", "\\") + } else { + path_str.to_string() + } + } + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + language::init(cx); + Project::init_settings(cx); + }); + } + + #[gpui::test] + async fn test_list_directory_separates_files_and_dirs(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "src": { + "main.rs": "fn main() {}", + "lib.rs": "pub fn hello() {}", + "models": { + "user.rs": "struct User {}", + "post.rs": "struct Post {}" + }, + "utils": { + "helper.rs": "pub fn help() {}" + } + }, + "tests": { + "integration_test.rs": "#[test] fn test() {}" + }, + "README.md": "# Project", + "Cargo.toml": "[package]" + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let tool = Arc::new(ListDirectoryTool::new(project)); + + // Test listing root directory + let input = ListDirectoryToolInput { + path: "project".into(), + }; + let output = cx + .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .await + .unwrap(); + assert_eq!( + output, + platform_paths(indoc! {" + # Folders: + project/src + project/tests + + # Files: + project/Cargo.toml + project/README.md + "}) + ); + + // Test listing src directory + let input = ListDirectoryToolInput { + path: "project/src".into(), + }; + let output = cx + .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .await + .unwrap(); + assert_eq!( + output, + platform_paths(indoc! {" + # Folders: + project/src/models + project/src/utils + + # Files: + project/src/lib.rs + project/src/main.rs + "}) + ); + + // Test listing directory with only files + let input = ListDirectoryToolInput { + path: "project/tests".into(), + }; + let output = cx + .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .await + .unwrap(); + assert!(!output.contains("# Folders:")); + assert!(output.contains("# Files:")); + assert!(output.contains(&platform_paths("project/tests/integration_test.rs"))); + } + + #[gpui::test] + async fn test_list_directory_empty_directory(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "empty_dir": {} + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let tool = Arc::new(ListDirectoryTool::new(project)); + + let input = ListDirectoryToolInput { + path: "project/empty_dir".into(), + }; + let output = cx + .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .await + .unwrap(); + assert_eq!(output, "project/empty_dir is empty.\n"); + } + + #[gpui::test] + async fn test_list_directory_error_cases(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "file.txt": "content" + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let tool = Arc::new(ListDirectoryTool::new(project)); + + // Test non-existent path + let input = ListDirectoryToolInput { + path: "project/nonexistent".into(), + }; + let output = cx + .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .await; + assert!(output.unwrap_err().to_string().contains("Path not found")); + + // Test trying to list a file instead of directory + let input = ListDirectoryToolInput { + path: "project/file.txt".into(), + }; + let output = cx + .update(|cx| tool.run(input, ToolCallEventStream::test().0, cx)) + .await; + assert!( + output + .unwrap_err() + .to_string() + .contains("is not a directory") + ); + } + + #[gpui::test] + async fn test_list_directory_security(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "normal_dir": { + "file1.txt": "content", + "file2.txt": "content" + }, + ".mysecrets": "SECRET_KEY=abc123", + ".secretdir": { + "config": "special configuration", + "secret.txt": "secret content" + }, + ".mymetadata": "custom metadata", + "visible_dir": { + "normal.txt": "normal content", + "special.privatekey": "private key content", + "data.mysensitive": "sensitive data", + ".hidden_subdir": { + "hidden_file.txt": "hidden content" + } + } + }), + ) + .await; + + // Configure settings explicitly + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings::(cx, |settings| { + settings.file_scan_exclusions = Some(vec![ + "**/.secretdir".to_string(), + "**/.mymetadata".to_string(), + "**/.hidden_subdir".to_string(), + ]); + settings.private_files = Some(vec![ + "**/.mysecrets".to_string(), + "**/*.privatekey".to_string(), + "**/*.mysensitive".to_string(), + ]); + }); + }); + }); + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let tool = Arc::new(ListDirectoryTool::new(project)); + + // Listing root directory should exclude private and excluded files + let input = ListDirectoryToolInput { + path: "project".into(), + }; + let output = cx + .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .await + .unwrap(); + + // Should include normal directories + assert!(output.contains("normal_dir"), "Should list normal_dir"); + assert!(output.contains("visible_dir"), "Should list visible_dir"); + + // Should NOT include excluded or private files + assert!( + !output.contains(".secretdir"), + "Should not list .secretdir (file_scan_exclusions)" + ); + assert!( + !output.contains(".mymetadata"), + "Should not list .mymetadata (file_scan_exclusions)" + ); + assert!( + !output.contains(".mysecrets"), + "Should not list .mysecrets (private_files)" + ); + + // Trying to list an excluded directory should fail + let input = ListDirectoryToolInput { + path: "project/.secretdir".into(), + }; + let output = cx + .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .await; + assert!( + output + .unwrap_err() + .to_string() + .contains("file_scan_exclusions"), + "Error should mention file_scan_exclusions" + ); + + // Listing a directory should exclude private files within it + let input = ListDirectoryToolInput { + path: "project/visible_dir".into(), + }; + let output = cx + .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .await + .unwrap(); + + // Should include normal files + assert!(output.contains("normal.txt"), "Should list normal.txt"); + + // Should NOT include private files + assert!( + !output.contains("privatekey"), + "Should not list .privatekey files (private_files)" + ); + assert!( + !output.contains("mysensitive"), + "Should not list .mysensitive files (private_files)" + ); + + // Should NOT include subdirectories that match exclusions + assert!( + !output.contains(".hidden_subdir"), + "Should not list .hidden_subdir (file_scan_exclusions)" + ); + } + + #[gpui::test] + async fn test_list_directory_with_multiple_worktree_settings(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + + // Create first worktree with its own private files + fs.insert_tree( + path!("/worktree1"), + json!({ + ".zed": { + "settings.json": r#"{ + "file_scan_exclusions": ["**/fixture.*"], + "private_files": ["**/secret.rs", "**/config.toml"] + }"# + }, + "src": { + "main.rs": "fn main() { println!(\"Hello from worktree1\"); }", + "secret.rs": "const API_KEY: &str = \"secret_key_1\";", + "config.toml": "[database]\nurl = \"postgres://localhost/db1\"" + }, + "tests": { + "test.rs": "mod tests { fn test_it() {} }", + "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));" + } + }), + ) + .await; + + // Create second worktree with different private files + fs.insert_tree( + path!("/worktree2"), + json!({ + ".zed": { + "settings.json": r#"{ + "file_scan_exclusions": ["**/internal.*"], + "private_files": ["**/private.js", "**/data.json"] + }"# + }, + "lib": { + "public.js": "export function greet() { return 'Hello from worktree2'; }", + "private.js": "const SECRET_TOKEN = \"private_token_2\";", + "data.json": "{\"api_key\": \"json_secret_key\"}" + }, + "docs": { + "README.md": "# Public Documentation", + "internal.md": "# Internal Secrets and Configuration" + } + }), + ) + .await; + + // Set global settings + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings::(cx, |settings| { + settings.file_scan_exclusions = + Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]); + settings.private_files = Some(vec!["**/.env".to_string()]); + }); + }); + }); + + let project = Project::test( + fs.clone(), + [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()], + cx, + ) + .await; + + // Wait for worktrees to be fully scanned + cx.executor().run_until_parked(); + + let tool = Arc::new(ListDirectoryTool::new(project)); + + // Test listing worktree1/src - should exclude secret.rs and config.toml based on local settings + let input = ListDirectoryToolInput { + path: "worktree1/src".into(), + }; + let output = cx + .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .await + .unwrap(); + assert!(output.contains("main.rs"), "Should list main.rs"); + assert!( + !output.contains("secret.rs"), + "Should not list secret.rs (local private_files)" + ); + assert!( + !output.contains("config.toml"), + "Should not list config.toml (local private_files)" + ); + + // Test listing worktree1/tests - should exclude fixture.sql based on local settings + let input = ListDirectoryToolInput { + path: "worktree1/tests".into(), + }; + let output = cx + .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .await + .unwrap(); + assert!(output.contains("test.rs"), "Should list test.rs"); + assert!( + !output.contains("fixture.sql"), + "Should not list fixture.sql (local file_scan_exclusions)" + ); + + // Test listing worktree2/lib - should exclude private.js and data.json based on local settings + let input = ListDirectoryToolInput { + path: "worktree2/lib".into(), + }; + let output = cx + .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .await + .unwrap(); + assert!(output.contains("public.js"), "Should list public.js"); + assert!( + !output.contains("private.js"), + "Should not list private.js (local private_files)" + ); + assert!( + !output.contains("data.json"), + "Should not list data.json (local private_files)" + ); + + // Test listing worktree2/docs - should exclude internal.md based on local settings + let input = ListDirectoryToolInput { + path: "worktree2/docs".into(), + }; + let output = cx + .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .await + .unwrap(); + assert!(output.contains("README.md"), "Should list README.md"); + assert!( + !output.contains("internal.md"), + "Should not list internal.md (local file_scan_exclusions)" + ); + + // Test trying to list an excluded directory directly + let input = ListDirectoryToolInput { + path: "worktree1/src/secret.rs".into(), + }; + let output = cx + .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .await; + assert!( + output + .unwrap_err() + .to_string() + .contains("Cannot list directory"), + ); + } +} diff --git a/crates/agent2/src/tools/move_path_tool.rs b/crates/agent2/src/tools/move_path_tool.rs new file mode 100644 index 0000000000..f8d5d0d176 --- /dev/null +++ b/crates/agent2/src/tools/move_path_tool.rs @@ -0,0 +1,123 @@ +use crate::{AgentTool, ToolCallEventStream}; +use agent_client_protocol::ToolKind; +use anyhow::{Context as _, Result, anyhow}; +use gpui::{App, AppContext, Entity, SharedString, Task}; +use project::Project; +use schemars::JsonSchema; +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. +/// +/// 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. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct MovePathToolInput { + /// The source path of the file or directory to move/rename. + /// + /// + /// If the project has the following files: + /// + /// - directory1/a/something.txt + /// - directory2/a/things.txt + /// - directory3/a/other.txt + /// + /// You can move 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 moved/renamed to. + /// If the paths are the same except for the filename, then this will be a rename. + /// + /// + /// To move "directory1/a/something.txt" to "directory2/b/renamed.txt", + /// provide a destination_path of "directory2/b/renamed.txt" + /// + pub destination_path: String, +} + +pub struct MovePathTool { + project: Entity, +} + +impl MovePathTool { + pub fn new(project: Entity) -> Self { + Self { project } + } +} + +impl AgentTool for MovePathTool { + type Input = MovePathToolInput; + type Output = String; + + fn name(&self) -> SharedString { + "move_path".into() + } + + fn kind(&self) -> ToolKind { + ToolKind::Move + } + + fn initial_title(&self, input: Result) -> SharedString { + if let Ok(input) = input { + let src = MarkdownInlineCode(&input.source_path); + let dest = MarkdownInlineCode(&input.destination_path); + let src_path = Path::new(&input.source_path); + let dest_path = Path::new(&input.destination_path); + + match dest_path + .file_name() + .and_then(|os_str| os_str.to_os_string().into_string().ok()) + { + Some(filename) if src_path.parent() == dest_path.parent() => { + let filename = MarkdownInlineCode(&filename); + format!("Rename {src} to {filename}").into() + } + _ => format!("Move {src} to {dest}").into(), + } + } else { + "Move path".into() + } + } + + fn run( + self: Arc, + input: Self::Input, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let rename_task = self.project.update(cx, |project, cx| { + match project + .find_project_path(&input.source_path, cx) + .and_then(|project_path| project.entry_for_path(&project_path, cx)) + { + Some(entity) => match project.find_project_path(&input.destination_path, cx) { + Some(project_path) => project.rename_entry(entity.id, project_path.path, cx), + None => Task::ready(Err(anyhow!( + "Destination path {} was outside the project.", + input.destination_path + ))), + }, + None => Task::ready(Err(anyhow!( + "Source path {} was not found in the project.", + input.source_path + ))), + } + }); + + cx.background_spawn(async move { + let _ = rename_task.await.with_context(|| { + format!("Moving {} to {}", input.source_path, input.destination_path) + })?; + Ok(format!( + "Moved {} to {}", + input.source_path, input.destination_path + )) + }) + } +} diff --git a/crates/agent2/src/tools/open_tool.rs b/crates/agent2/src/tools/open_tool.rs new file mode 100644 index 0000000000..0860b62a51 --- /dev/null +++ b/crates/agent2/src/tools/open_tool.rs @@ -0,0 +1,170 @@ +use crate::AgentTool; +use agent_client_protocol::ToolKind; +use anyhow::{Context as _, Result}; +use gpui::{App, AppContext, Entity, SharedString, Task}; +use project::Project; +use schemars::JsonSchema; +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: +/// +/// - 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. +/// +/// 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. + path_or_url: String, +} + +pub struct OpenTool { + project: Entity, +} + +impl OpenTool { + pub fn new(project: Entity) -> Self { + Self { project } + } +} + +impl AgentTool for OpenTool { + type Input = OpenToolInput; + type Output = String; + + fn name(&self) -> SharedString { + "open".into() + } + + fn kind(&self) -> ToolKind { + ToolKind::Execute + } + + fn initial_title(&self, input: Result) -> SharedString { + if let Ok(input) = input { + format!("Open `{}`", MarkdownEscaped(&input.path_or_url)).into() + } else { + "Open file or URL".into() + } + } + + fn run( + self: Arc, + input: Self::Input, + event_stream: crate::ToolCallEventStream, + cx: &mut App, + ) -> Task> { + // If path_or_url turns out to be a path in the project, make it absolute. + let abs_path = to_absolute_path(&input.path_or_url, self.project.clone(), cx); + let authorize = event_stream.authorize(self.initial_title(Ok(input.clone())).to_string()); + cx.background_spawn(async move { + authorize.await?; + + match abs_path { + Some(path) => open::that(path), + None => open::that(&input.path_or_url), + } + .context("Failed to open URL or file path")?; + + Ok(format!("Successfully opened {}", input.path_or_url)) + }) + } +} + +fn to_absolute_path( + potential_path: &str, + project: Entity, + cx: &mut App, +) -> Option { + let project = project.read(cx); + project + .find_project_path(PathBuf::from(potential_path), cx) + .and_then(|project_path| project.absolute_path(&project_path, cx)) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + use project::{FakeFs, Project}; + use settings::SettingsStore; + use std::path::Path; + use tempfile::TempDir; + + #[gpui::test] + async fn test_to_absolute_path(cx: &mut TestAppContext) { + init_test(cx); + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let temp_path = temp_dir.path().to_string_lossy().to_string(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + &temp_path, + serde_json::json!({ + "src": { + "main.rs": "fn main() {}", + "lib.rs": "pub fn lib_fn() {}" + }, + "docs": { + "readme.md": "# Project Documentation" + } + }), + ) + .await; + + // Use the temp_path as the root directory, not just its filename + let project = Project::test(fs.clone(), [temp_dir.path()], cx).await; + + // Test cases where the function should return Some + cx.update(|cx| { + // Project-relative paths should return Some + // Create paths using the last segment of the temp path to simulate a project-relative path + let root_dir_name = Path::new(&temp_path) + .file_name() + .unwrap_or_else(|| std::ffi::OsStr::new("temp")) + .to_string_lossy(); + + assert!( + to_absolute_path(&format!("{root_dir_name}/src/main.rs"), project.clone(), cx) + .is_some(), + "Failed to resolve main.rs path" + ); + + assert!( + to_absolute_path( + &format!("{root_dir_name}/docs/readme.md",), + project.clone(), + cx, + ) + .is_some(), + "Failed to resolve readme.md path" + ); + + // External URL should return None + let result = to_absolute_path("https://example.com", project.clone(), cx); + assert_eq!(result, None, "External URLs should return None"); + + // Path outside project + let result = to_absolute_path("../invalid/path", project.clone(), cx); + assert_eq!(result, None, "Paths outside the project should return None"); + }); + } + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + language::init(cx); + Project::init_settings(cx); + }); + } +} From 8dbded46d8b28c80d2948088afa19db3188a6b34 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 11 Aug 2025 15:34:34 +0200 Subject: [PATCH 054/109] agent2: Add now, grep, and web search tools (#35974) Release Notes: - N/A --------- Co-authored-by: Bennet Bo Fenner Co-authored-by: Antonio Scandurra --- Cargo.lock | 4 + crates/agent2/Cargo.toml | 4 + crates/agent2/src/agent.rs | 9 +- crates/agent2/src/tools.rs | 6 + crates/agent2/src/tools/grep_tool.rs | 1196 +++++++++++++++++ crates/agent2/src/tools/now_tool.rs | 66 + crates/agent2/src/tools/web_search_tool.rs | 105 ++ .../cloud_llm_client/src/cloud_llm_client.rs | 4 +- 8 files changed, 1390 insertions(+), 4 deletions(-) create mode 100644 crates/agent2/src/tools/grep_tool.rs create mode 100644 crates/agent2/src/tools/now_tool.rs create mode 100644 crates/agent2/src/tools/web_search_tool.rs diff --git a/Cargo.lock b/Cargo.lock index f0d21381fa..7b5e82a312 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -191,6 +191,7 @@ dependencies = [ "anyhow", "assistant_tool", "assistant_tools", + "chrono", "client", "clock", "cloud_llm_client", @@ -227,10 +228,13 @@ dependencies = [ "tempfile", "terminal", "theme", + "tree-sitter-rust", "ui", + "unindent", "util", "uuid", "watch", + "web_search", "which 6.0.3", "workspace-hack", "worktree", diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml index a288ff30b2..622b08016a 100644 --- a/crates/agent2/Cargo.toml +++ b/crates/agent2/Cargo.toml @@ -20,6 +20,7 @@ agent_settings.workspace = true anyhow.workspace = true assistant_tool.workspace = true assistant_tools.workspace = true +chrono.workspace = true cloud_llm_client.workspace = true collections.workspace = true fs.workspace = true @@ -49,6 +50,7 @@ ui.workspace = true util.workspace = true uuid.workspace = true watch.workspace = true +web_search.workspace = true which.workspace = true workspace-hack.workspace = true @@ -71,5 +73,7 @@ settings = { workspace = true, "features" = ["test-support"] } tempfile.workspace = true terminal = { workspace = true, "features" = ["test-support"] } theme = { workspace = true, "features" = ["test-support"] } +tree-sitter-rust.workspace = true +unindent = { workspace = true } worktree = { workspace = true, "features" = ["test-support"] } zlog.workspace = true diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index 398ea6ad50..b1cefd2864 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -1,7 +1,8 @@ use crate::{AgentResponseEvent, Thread, templates::Templates}; use crate::{ - CopyPathTool, CreateDirectoryTool, EditFileTool, FindPathTool, ListDirectoryTool, MovePathTool, - OpenTool, ReadFileTool, TerminalTool, ThinkingTool, ToolCallAuthorization, + CopyPathTool, CreateDirectoryTool, EditFileTool, FindPathTool, GrepTool, ListDirectoryTool, + MovePathTool, NowTool, OpenTool, ReadFileTool, TerminalTool, ThinkingTool, + ToolCallAuthorization, WebSearchTool, }; use acp_thread::ModelSelector; use agent_client_protocol as acp; @@ -424,9 +425,13 @@ impl acp_thread::AgentConnection for NativeAgentConnection { thread.add_tool(OpenTool::new(project.clone())); thread.add_tool(ThinkingTool); thread.add_tool(FindPathTool::new(project.clone())); + thread.add_tool(GrepTool::new(project.clone())); thread.add_tool(ReadFileTool::new(project.clone(), action_log)); thread.add_tool(EditFileTool::new(cx.entity())); + thread.add_tool(NowTool); thread.add_tool(TerminalTool::new(project.clone(), cx)); + // TODO: Needs to be conditional based on zed model or not + thread.add_tool(WebSearchTool); thread }); diff --git a/crates/agent2/src/tools.rs b/crates/agent2/src/tools.rs index 5c3920fcbb..29ba6780b8 100644 --- a/crates/agent2/src/tools.rs +++ b/crates/agent2/src/tools.rs @@ -3,21 +3,27 @@ mod create_directory_tool; mod delete_path_tool; mod edit_file_tool; mod find_path_tool; +mod grep_tool; mod list_directory_tool; mod move_path_tool; +mod now_tool; mod open_tool; mod read_file_tool; mod terminal_tool; mod thinking_tool; +mod web_search_tool; pub use copy_path_tool::*; pub use create_directory_tool::*; pub use delete_path_tool::*; pub use edit_file_tool::*; pub use find_path_tool::*; +pub use grep_tool::*; pub use list_directory_tool::*; pub use move_path_tool::*; +pub use now_tool::*; pub use open_tool::*; pub use read_file_tool::*; pub use terminal_tool::*; pub use thinking_tool::*; +pub use web_search_tool::*; diff --git a/crates/agent2/src/tools/grep_tool.rs b/crates/agent2/src/tools/grep_tool.rs new file mode 100644 index 0000000000..3266cb5734 --- /dev/null +++ b/crates/agent2/src/tools/grep_tool.rs @@ -0,0 +1,1196 @@ +use crate::{AgentTool, ToolCallEventStream}; +use agent_client_protocol as acp; +use anyhow::{Result, anyhow}; +use futures::StreamExt; +use gpui::{App, Entity, SharedString, Task}; +use language::{OffsetRangeExt, ParseStatus, Point}; +use project::{ + Project, WorktreeSettings, + search::{SearchQuery, SearchResult}, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::Settings; +use std::{cmp, fmt::Write, sync::Arc}; +use util::RangeExt; +use util::markdown::MarkdownInlineCode; +use util::paths::PathMatcher; + +/// Searches the contents of files in the project with a regular expression +/// +/// - Prefer this tool to path search when searching for symbols in the project, because you won't need to guess what path it's in. +/// - Supports full regex syntax (eg. "log.*Error", "function\\s+\\w+", etc.) +/// - Pass an `include_pattern` if you know how to narrow your search on the files system +/// - Never use this tool to search for paths. Only search file contents with this tool. +/// - Use this tool when you need to find files containing specific patterns +/// - Results are paginated with 20 matches per page. Use the optional 'offset' parameter to request subsequent pages. +/// - 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. + /// + /// Do NOT specify a path here! This will only be matched against the code **content**. + pub regex: String, + /// A glob pattern for the paths of files to include in the search. + /// Supports standard glob patterns like "**/*.rs" or "src/**/*.ts". + /// If omitted, all files in the project will be searched. + pub include_pattern: Option, + /// Optional starting position for paginated results (0-based). + /// When not provided, starts from the beginning. + #[serde(default)] + pub offset: u32, + /// Whether the regex is case-sensitive. Defaults to false (case-insensitive). + #[serde(default)] + pub case_sensitive: bool, +} + +impl GrepToolInput { + /// Which page of search results this is. + pub fn page(&self) -> u32 { + 1 + (self.offset / RESULTS_PER_PAGE) + } +} + +const RESULTS_PER_PAGE: u32 = 20; + +pub struct GrepTool { + project: Entity, +} + +impl GrepTool { + pub fn new(project: Entity) -> Self { + Self { project } + } +} + +impl AgentTool for GrepTool { + type Input = GrepToolInput; + type Output = String; + + fn name(&self) -> SharedString { + "grep".into() + } + + fn kind(&self) -> acp::ToolKind { + acp::ToolKind::Search + } + + fn initial_title(&self, input: Result) -> SharedString { + match input { + Ok(input) => { + let page = input.page(); + let regex_str = MarkdownInlineCode(&input.regex); + let case_info = if input.case_sensitive { + " (case-sensitive)" + } else { + "" + }; + + if page > 1 { + format!("Get page {page} of search results for regex {regex_str}{case_info}") + } else { + format!("Search files for regex {regex_str}{case_info}") + } + } + Err(_) => "Search with regex".into(), + } + .into() + } + + fn run( + self: Arc, + input: Self::Input, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + const CONTEXT_LINES: u32 = 2; + const MAX_ANCESTOR_LINES: u32 = 10; + + let include_matcher = match PathMatcher::new( + input + .include_pattern + .as_ref() + .into_iter() + .collect::>(), + ) { + Ok(matcher) => matcher, + Err(error) => { + return Task::ready(Err(anyhow!("invalid include glob pattern: {error}"))); + } + }; + + // Exclude global file_scan_exclusions and private_files settings + let exclude_matcher = { + let global_settings = WorktreeSettings::get_global(cx); + let exclude_patterns = global_settings + .file_scan_exclusions + .sources() + .iter() + .chain(global_settings.private_files.sources().iter()); + + match PathMatcher::new(exclude_patterns) { + Ok(matcher) => matcher, + Err(error) => { + return Task::ready(Err(anyhow!("invalid exclude pattern: {error}"))); + } + } + }; + + let query = match SearchQuery::regex( + &input.regex, + false, + input.case_sensitive, + false, + false, + include_matcher, + exclude_matcher, + true, // Always match file include pattern against *full project paths* that start with a project root. + None, + ) { + Ok(query) => query, + Err(error) => return Task::ready(Err(error)), + }; + + let results = self + .project + .update(cx, |project, cx| project.search(query, cx)); + + let project = self.project.downgrade(); + cx.spawn(async move |cx| { + futures::pin_mut!(results); + + let mut output = String::new(); + let mut skips_remaining = input.offset; + let mut matches_found = 0; + let mut has_more_matches = false; + + 'outer: while let Some(SearchResult::Buffer { buffer, ranges }) = results.next().await { + if ranges.is_empty() { + continue; + } + + let Ok((Some(path), mut parse_status)) = buffer.read_with(cx, |buffer, cx| { + (buffer.file().map(|file| file.full_path(cx)), buffer.parse_status()) + }) else { + continue; + }; + + // Check if this file should be excluded based on its worktree settings + if let Ok(Some(project_path)) = project.read_with(cx, |project, cx| { + project.find_project_path(&path, cx) + }) { + if cx.update(|cx| { + let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx); + worktree_settings.is_path_excluded(&project_path.path) + || worktree_settings.is_path_private(&project_path.path) + }).unwrap_or(false) { + continue; + } + } + + while *parse_status.borrow() != ParseStatus::Idle { + parse_status.changed().await?; + } + + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; + + let mut ranges = ranges + .into_iter() + .map(|range| { + let matched = range.to_point(&snapshot); + let matched_end_line_len = snapshot.line_len(matched.end.row); + let full_lines = Point::new(matched.start.row, 0)..Point::new(matched.end.row, matched_end_line_len); + let symbols = snapshot.symbols_containing(matched.start, None); + + if let Some(ancestor_node) = snapshot.syntax_ancestor(full_lines.clone()) { + let full_ancestor_range = ancestor_node.byte_range().to_point(&snapshot); + let end_row = full_ancestor_range.end.row.min(full_ancestor_range.start.row + MAX_ANCESTOR_LINES); + let end_col = snapshot.line_len(end_row); + let capped_ancestor_range = Point::new(full_ancestor_range.start.row, 0)..Point::new(end_row, end_col); + + if capped_ancestor_range.contains_inclusive(&full_lines) { + return (capped_ancestor_range, Some(full_ancestor_range), symbols) + } + } + + let mut matched = matched; + matched.start.column = 0; + matched.start.row = + matched.start.row.saturating_sub(CONTEXT_LINES); + matched.end.row = cmp::min( + snapshot.max_point().row, + matched.end.row + CONTEXT_LINES, + ); + matched.end.column = snapshot.line_len(matched.end.row); + + (matched, None, symbols) + }) + .peekable(); + + let mut file_header_written = false; + + while let Some((mut range, ancestor_range, parent_symbols)) = ranges.next(){ + if skips_remaining > 0 { + skips_remaining -= 1; + continue; + } + + // We'd already found a full page of matches, and we just found one more. + if matches_found >= RESULTS_PER_PAGE { + has_more_matches = true; + break 'outer; + } + + while let Some((next_range, _, _)) = ranges.peek() { + if range.end.row >= next_range.start.row { + range.end = next_range.end; + ranges.next(); + } else { + break; + } + } + + if !file_header_written { + writeln!(output, "\n## Matches in {}", path.display())?; + file_header_written = true; + } + + let end_row = range.end.row; + output.push_str("\n### "); + + if let Some(parent_symbols) = &parent_symbols { + for symbol in parent_symbols { + write!(output, "{} › ", symbol.text)?; + } + } + + if range.start.row == end_row { + writeln!(output, "L{}", range.start.row + 1)?; + } else { + writeln!(output, "L{}-{}", range.start.row + 1, end_row + 1)?; + } + + output.push_str("```\n"); + output.extend(snapshot.text_for_range(range)); + output.push_str("\n```\n"); + + if let Some(ancestor_range) = ancestor_range { + if end_row < ancestor_range.end.row { + let remaining_lines = ancestor_range.end.row - end_row; + writeln!(output, "\n{} lines remaining in ancestor node. Read the file to see all.", remaining_lines)?; + } + } + + event_stream.update_fields(acp::ToolCallUpdateFields { + content: Some(vec![output.clone().into()]), + ..Default::default() + }); + matches_found += 1; + } + } + + let output = if matches_found == 0 { + "No matches found".to_string() + } else if has_more_matches { + format!( + "Showing matches {}-{} (there were more matches found; use offset: {} to see next page):\n{output}", + input.offset + 1, + input.offset + matches_found, + input.offset + RESULTS_PER_PAGE, + ) + } else { + format!("Found {matches_found} matches:\n{output}") + }; + + event_stream.update_fields(acp::ToolCallUpdateFields { + content: Some(vec![output.clone().into()]), + ..Default::default() + }); + + Ok(output) + }) + } +} + +#[cfg(test)] +mod tests { + use crate::ToolCallEventStream; + + use super::*; + use gpui::{TestAppContext, UpdateGlobal}; + use language::{Language, LanguageConfig, LanguageMatcher}; + use project::{FakeFs, Project, WorktreeSettings}; + use serde_json::json; + use settings::SettingsStore; + use unindent::Unindent; + use util::path; + + #[gpui::test] + async fn test_grep_tool_with_include_pattern(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + path!("/root"), + serde_json::json!({ + "src": { + "main.rs": "fn main() {\n println!(\"Hello, world!\");\n}", + "utils": { + "helper.rs": "fn helper() {\n println!(\"I'm a helper!\");\n}", + }, + }, + "tests": { + "test_main.rs": "fn test_main() {\n assert!(true);\n}", + } + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + + // Test with include pattern for Rust files inside the root of the project + let input = GrepToolInput { + regex: "println".to_string(), + include_pattern: Some("root/**/*.rs".to_string()), + offset: 0, + case_sensitive: false, + }; + + let result = run_grep_tool(input, project.clone(), cx).await; + assert!(result.contains("main.rs"), "Should find matches in main.rs"); + assert!( + result.contains("helper.rs"), + "Should find matches in helper.rs" + ); + assert!( + !result.contains("test_main.rs"), + "Should not include test_main.rs even though it's a .rs file (because it doesn't have the pattern)" + ); + + // Test with include pattern for src directory only + let input = GrepToolInput { + regex: "fn".to_string(), + include_pattern: Some("root/**/src/**".to_string()), + offset: 0, + case_sensitive: false, + }; + + let result = run_grep_tool(input, project.clone(), cx).await; + assert!( + result.contains("main.rs"), + "Should find matches in src/main.rs" + ); + assert!( + result.contains("helper.rs"), + "Should find matches in src/utils/helper.rs" + ); + assert!( + !result.contains("test_main.rs"), + "Should not include test_main.rs as it's not in src directory" + ); + + // Test with empty include pattern (should default to all files) + let input = GrepToolInput { + regex: "fn".to_string(), + include_pattern: None, + offset: 0, + case_sensitive: false, + }; + + let result = run_grep_tool(input, project.clone(), cx).await; + assert!(result.contains("main.rs"), "Should find matches in main.rs"); + assert!( + result.contains("helper.rs"), + "Should find matches in helper.rs" + ); + assert!( + result.contains("test_main.rs"), + "Should include test_main.rs" + ); + } + + #[gpui::test] + async fn test_grep_tool_with_case_sensitivity(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + path!("/root"), + serde_json::json!({ + "case_test.txt": "This file has UPPERCASE and lowercase text.\nUPPERCASE patterns should match only with case_sensitive: true", + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + + // Test case-insensitive search (default) + let input = GrepToolInput { + regex: "uppercase".to_string(), + include_pattern: Some("**/*.txt".to_string()), + offset: 0, + case_sensitive: false, + }; + + let result = run_grep_tool(input, project.clone(), cx).await; + assert!( + result.contains("UPPERCASE"), + "Case-insensitive search should match uppercase" + ); + + // Test case-sensitive search + let input = GrepToolInput { + regex: "uppercase".to_string(), + include_pattern: Some("**/*.txt".to_string()), + offset: 0, + case_sensitive: true, + }; + + let result = run_grep_tool(input, project.clone(), cx).await; + assert!( + !result.contains("UPPERCASE"), + "Case-sensitive search should not match uppercase" + ); + + // Test case-sensitive search + let input = GrepToolInput { + regex: "LOWERCASE".to_string(), + include_pattern: Some("**/*.txt".to_string()), + offset: 0, + case_sensitive: true, + }; + + let result = run_grep_tool(input, project.clone(), cx).await; + + assert!( + !result.contains("lowercase"), + "Case-sensitive search should match lowercase" + ); + + // Test case-sensitive search for lowercase pattern + let input = GrepToolInput { + regex: "lowercase".to_string(), + include_pattern: Some("**/*.txt".to_string()), + offset: 0, + case_sensitive: true, + }; + + let result = run_grep_tool(input, project.clone(), cx).await; + assert!( + result.contains("lowercase"), + "Case-sensitive search should match lowercase text" + ); + } + + /// Helper function to set up a syntax test environment + async fn setup_syntax_test(cx: &mut TestAppContext) -> Entity { + use unindent::Unindent; + init_test(cx); + cx.executor().allow_parking(); + + let fs = FakeFs::new(cx.executor().clone()); + + // Create test file with syntax structures + fs.insert_tree( + path!("/root"), + serde_json::json!({ + "test_syntax.rs": r#" + fn top_level_function() { + println!("This is at the top level"); + } + + mod feature_module { + pub mod nested_module { + pub fn nested_function( + first_arg: String, + second_arg: i32, + ) { + println!("Function in nested module"); + println!("{first_arg}"); + println!("{second_arg}"); + } + } + } + + struct MyStruct { + field1: String, + field2: i32, + } + + impl MyStruct { + fn method_with_block() { + let condition = true; + if condition { + println!("Inside if block"); + } + } + + fn long_function() { + println!("Line 1"); + println!("Line 2"); + println!("Line 3"); + println!("Line 4"); + println!("Line 5"); + println!("Line 6"); + println!("Line 7"); + println!("Line 8"); + println!("Line 9"); + println!("Line 10"); + println!("Line 11"); + println!("Line 12"); + } + } + + trait Processor { + fn process(&self, input: &str) -> String; + } + + impl Processor for MyStruct { + fn process(&self, input: &str) -> String { + format!("Processed: {}", input) + } + } + "#.unindent().trim(), + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + + project.update(cx, |project, _cx| { + project.languages().add(rust_lang().into()) + }); + + project + } + + #[gpui::test] + async fn test_grep_top_level_function(cx: &mut TestAppContext) { + let project = setup_syntax_test(cx).await; + + // Test: Line at the top level of the file + let input = GrepToolInput { + regex: "This is at the top level".to_string(), + include_pattern: Some("**/*.rs".to_string()), + offset: 0, + case_sensitive: false, + }; + + let result = run_grep_tool(input, project.clone(), cx).await; + let expected = r#" + Found 1 matches: + + ## Matches in root/test_syntax.rs + + ### fn top_level_function › L1-3 + ``` + fn top_level_function() { + println!("This is at the top level"); + } + ``` + "# + .unindent(); + assert_eq!(result, expected); + } + + #[gpui::test] + async fn test_grep_function_body(cx: &mut TestAppContext) { + let project = setup_syntax_test(cx).await; + + // Test: Line inside a function body + let input = GrepToolInput { + regex: "Function in nested module".to_string(), + include_pattern: Some("**/*.rs".to_string()), + offset: 0, + case_sensitive: false, + }; + + let result = run_grep_tool(input, project.clone(), cx).await; + let expected = r#" + Found 1 matches: + + ## Matches in root/test_syntax.rs + + ### mod feature_module › pub mod nested_module › pub fn nested_function › L10-14 + ``` + ) { + println!("Function in nested module"); + println!("{first_arg}"); + println!("{second_arg}"); + } + ``` + "# + .unindent(); + assert_eq!(result, expected); + } + + #[gpui::test] + async fn test_grep_function_args_and_body(cx: &mut TestAppContext) { + let project = setup_syntax_test(cx).await; + + // Test: Line with a function argument + let input = GrepToolInput { + regex: "second_arg".to_string(), + include_pattern: Some("**/*.rs".to_string()), + offset: 0, + case_sensitive: false, + }; + + let result = run_grep_tool(input, project.clone(), cx).await; + let expected = r#" + Found 1 matches: + + ## Matches in root/test_syntax.rs + + ### mod feature_module › pub mod nested_module › pub fn nested_function › L7-14 + ``` + pub fn nested_function( + first_arg: String, + second_arg: i32, + ) { + println!("Function in nested module"); + println!("{first_arg}"); + println!("{second_arg}"); + } + ``` + "# + .unindent(); + assert_eq!(result, expected); + } + + #[gpui::test] + async fn test_grep_if_block(cx: &mut TestAppContext) { + use unindent::Unindent; + let project = setup_syntax_test(cx).await; + + // Test: Line inside an if block + let input = GrepToolInput { + regex: "Inside if block".to_string(), + include_pattern: Some("**/*.rs".to_string()), + offset: 0, + case_sensitive: false, + }; + + let result = run_grep_tool(input, project.clone(), cx).await; + let expected = r#" + Found 1 matches: + + ## Matches in root/test_syntax.rs + + ### impl MyStruct › fn method_with_block › L26-28 + ``` + if condition { + println!("Inside if block"); + } + ``` + "# + .unindent(); + assert_eq!(result, expected); + } + + #[gpui::test] + async fn test_grep_long_function_top(cx: &mut TestAppContext) { + use unindent::Unindent; + let project = setup_syntax_test(cx).await; + + // Test: Line in the middle of a long function - should show message about remaining lines + let input = GrepToolInput { + regex: "Line 5".to_string(), + include_pattern: Some("**/*.rs".to_string()), + offset: 0, + case_sensitive: false, + }; + + let result = run_grep_tool(input, project.clone(), cx).await; + let expected = r#" + Found 1 matches: + + ## Matches in root/test_syntax.rs + + ### impl MyStruct › fn long_function › L31-41 + ``` + fn long_function() { + println!("Line 1"); + println!("Line 2"); + println!("Line 3"); + println!("Line 4"); + println!("Line 5"); + println!("Line 6"); + println!("Line 7"); + println!("Line 8"); + println!("Line 9"); + println!("Line 10"); + ``` + + 3 lines remaining in ancestor node. Read the file to see all. + "# + .unindent(); + assert_eq!(result, expected); + } + + #[gpui::test] + async fn test_grep_long_function_bottom(cx: &mut TestAppContext) { + use unindent::Unindent; + let project = setup_syntax_test(cx).await; + + // Test: Line in the long function + let input = GrepToolInput { + regex: "Line 12".to_string(), + include_pattern: Some("**/*.rs".to_string()), + offset: 0, + case_sensitive: false, + }; + + let result = run_grep_tool(input, project.clone(), cx).await; + let expected = r#" + Found 1 matches: + + ## Matches in root/test_syntax.rs + + ### impl MyStruct › fn long_function › L41-45 + ``` + println!("Line 10"); + println!("Line 11"); + println!("Line 12"); + } + } + ``` + "# + .unindent(); + assert_eq!(result, expected); + } + + async fn run_grep_tool( + input: GrepToolInput, + project: Entity, + cx: &mut TestAppContext, + ) -> String { + let tool = Arc::new(GrepTool { project }); + let task = cx.update(|cx| tool.run(input, ToolCallEventStream::test().0, cx)); + + match task.await { + Ok(result) => { + if cfg!(windows) { + result.replace("root\\", "root/") + } else { + result.to_string() + } + } + Err(e) => panic!("Failed to run grep tool: {}", e), + } + } + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + language::init(cx); + Project::init_settings(cx); + }); + } + + fn rust_lang() -> Language { + Language::new( + LanguageConfig { + name: "Rust".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_outline_query(include_str!("../../../languages/src/rust/outline.scm")) + .unwrap() + } + + #[gpui::test] + async fn test_grep_security_boundaries(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + path!("/"), + json!({ + "project_root": { + "allowed_file.rs": "fn main() { println!(\"This file is in the project\"); }", + ".mysecrets": "SECRET_KEY=abc123\nfn secret() { /* private */ }", + ".secretdir": { + "config": "fn special_configuration() { /* excluded */ }" + }, + ".mymetadata": "fn custom_metadata() { /* excluded */ }", + "subdir": { + "normal_file.rs": "fn normal_file_content() { /* Normal */ }", + "special.privatekey": "fn private_key_content() { /* private */ }", + "data.mysensitive": "fn sensitive_data() { /* private */ }" + } + }, + "outside_project": { + "sensitive_file.rs": "fn outside_function() { /* This file is outside the project */ }" + } + }), + ) + .await; + + cx.update(|cx| { + use gpui::UpdateGlobal; + use project::WorktreeSettings; + use settings::SettingsStore; + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings::(cx, |settings| { + settings.file_scan_exclusions = Some(vec![ + "**/.secretdir".to_string(), + "**/.mymetadata".to_string(), + ]); + settings.private_files = Some(vec![ + "**/.mysecrets".to_string(), + "**/*.privatekey".to_string(), + "**/*.mysensitive".to_string(), + ]); + }); + }); + }); + + let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await; + + // Searching for files outside the project worktree should return no results + let result = run_grep_tool( + GrepToolInput { + regex: "outside_function".to_string(), + include_pattern: None, + offset: 0, + case_sensitive: false, + }, + project.clone(), + cx, + ) + .await; + let paths = extract_paths_from_results(&result); + assert!( + paths.is_empty(), + "grep_tool should not find files outside the project worktree" + ); + + // Searching within the project should succeed + let result = run_grep_tool( + GrepToolInput { + regex: "main".to_string(), + include_pattern: None, + offset: 0, + case_sensitive: false, + }, + project.clone(), + cx, + ) + .await; + let paths = extract_paths_from_results(&result); + assert!( + paths.iter().any(|p| p.contains("allowed_file.rs")), + "grep_tool should be able to search files inside worktrees" + ); + + // Searching files that match file_scan_exclusions should return no results + let result = run_grep_tool( + GrepToolInput { + regex: "special_configuration".to_string(), + include_pattern: None, + offset: 0, + case_sensitive: false, + }, + project.clone(), + cx, + ) + .await; + let paths = extract_paths_from_results(&result); + assert!( + paths.is_empty(), + "grep_tool should not search files in .secretdir (file_scan_exclusions)" + ); + + let result = run_grep_tool( + GrepToolInput { + regex: "custom_metadata".to_string(), + include_pattern: None, + offset: 0, + case_sensitive: false, + }, + project.clone(), + cx, + ) + .await; + let paths = extract_paths_from_results(&result); + assert!( + paths.is_empty(), + "grep_tool should not search .mymetadata files (file_scan_exclusions)" + ); + + // Searching private files should return no results + let result = run_grep_tool( + GrepToolInput { + regex: "SECRET_KEY".to_string(), + include_pattern: None, + offset: 0, + case_sensitive: false, + }, + project.clone(), + cx, + ) + .await; + let paths = extract_paths_from_results(&result); + assert!( + paths.is_empty(), + "grep_tool should not search .mysecrets (private_files)" + ); + + let result = run_grep_tool( + GrepToolInput { + regex: "private_key_content".to_string(), + include_pattern: None, + offset: 0, + case_sensitive: false, + }, + project.clone(), + cx, + ) + .await; + let paths = extract_paths_from_results(&result); + + assert!( + paths.is_empty(), + "grep_tool should not search .privatekey files (private_files)" + ); + + let result = run_grep_tool( + GrepToolInput { + regex: "sensitive_data".to_string(), + include_pattern: None, + offset: 0, + case_sensitive: false, + }, + project.clone(), + cx, + ) + .await; + let paths = extract_paths_from_results(&result); + assert!( + paths.is_empty(), + "grep_tool should not search .mysensitive files (private_files)" + ); + + // Searching a normal file should still work, even with private_files configured + let result = run_grep_tool( + GrepToolInput { + regex: "normal_file_content".to_string(), + include_pattern: None, + offset: 0, + case_sensitive: false, + }, + project.clone(), + cx, + ) + .await; + let paths = extract_paths_from_results(&result); + assert!( + paths.iter().any(|p| p.contains("normal_file.rs")), + "Should be able to search normal files" + ); + + // Path traversal attempts with .. in include_pattern should not escape project + let result = run_grep_tool( + GrepToolInput { + regex: "outside_function".to_string(), + include_pattern: Some("../outside_project/**/*.rs".to_string()), + offset: 0, + case_sensitive: false, + }, + project.clone(), + cx, + ) + .await; + let paths = extract_paths_from_results(&result); + assert!( + paths.is_empty(), + "grep_tool should not allow escaping project boundaries with relative paths" + ); + } + + #[gpui::test] + async fn test_grep_with_multiple_worktree_settings(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + + // Create first worktree with its own private files + fs.insert_tree( + path!("/worktree1"), + json!({ + ".zed": { + "settings.json": r#"{ + "file_scan_exclusions": ["**/fixture.*"], + "private_files": ["**/secret.rs"] + }"# + }, + "src": { + "main.rs": "fn main() { let secret_key = \"hidden\"; }", + "secret.rs": "const API_KEY: &str = \"secret_value\";", + "utils.rs": "pub fn get_config() -> String { \"config\".to_string() }" + }, + "tests": { + "test.rs": "fn test_secret() { assert!(true); }", + "fixture.sql": "SELECT * FROM secret_table;" + } + }), + ) + .await; + + // Create second worktree with different private files + fs.insert_tree( + path!("/worktree2"), + json!({ + ".zed": { + "settings.json": r#"{ + "file_scan_exclusions": ["**/internal.*"], + "private_files": ["**/private.js", "**/data.json"] + }"# + }, + "lib": { + "public.js": "export function getSecret() { return 'public'; }", + "private.js": "const SECRET_KEY = \"private_value\";", + "data.json": "{\"secret_data\": \"hidden\"}" + }, + "docs": { + "README.md": "# Documentation with secret info", + "internal.md": "Internal secret documentation" + } + }), + ) + .await; + + // Set global settings + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings::(cx, |settings| { + settings.file_scan_exclusions = + Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]); + settings.private_files = Some(vec!["**/.env".to_string()]); + }); + }); + }); + + let project = Project::test( + fs.clone(), + [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()], + cx, + ) + .await; + + // Wait for worktrees to be fully scanned + cx.executor().run_until_parked(); + + // Search for "secret" - should exclude files based on worktree-specific settings + let result = run_grep_tool( + GrepToolInput { + regex: "secret".to_string(), + include_pattern: None, + offset: 0, + case_sensitive: false, + }, + project.clone(), + cx, + ) + .await; + let paths = extract_paths_from_results(&result); + + // Should find matches in non-private files + assert!( + paths.iter().any(|p| p.contains("main.rs")), + "Should find 'secret' in worktree1/src/main.rs" + ); + assert!( + paths.iter().any(|p| p.contains("test.rs")), + "Should find 'secret' in worktree1/tests/test.rs" + ); + assert!( + paths.iter().any(|p| p.contains("public.js")), + "Should find 'secret' in worktree2/lib/public.js" + ); + assert!( + paths.iter().any(|p| p.contains("README.md")), + "Should find 'secret' in worktree2/docs/README.md" + ); + + // Should NOT find matches in private/excluded files based on worktree settings + assert!( + !paths.iter().any(|p| p.contains("secret.rs")), + "Should not search in worktree1/src/secret.rs (local private_files)" + ); + assert!( + !paths.iter().any(|p| p.contains("fixture.sql")), + "Should not search in worktree1/tests/fixture.sql (local file_scan_exclusions)" + ); + assert!( + !paths.iter().any(|p| p.contains("private.js")), + "Should not search in worktree2/lib/private.js (local private_files)" + ); + assert!( + !paths.iter().any(|p| p.contains("data.json")), + "Should not search in worktree2/lib/data.json (local private_files)" + ); + assert!( + !paths.iter().any(|p| p.contains("internal.md")), + "Should not search in worktree2/docs/internal.md (local file_scan_exclusions)" + ); + + // Test with `include_pattern` specific to one worktree + let result = run_grep_tool( + GrepToolInput { + regex: "secret".to_string(), + include_pattern: Some("worktree1/**/*.rs".to_string()), + offset: 0, + case_sensitive: false, + }, + project.clone(), + cx, + ) + .await; + + let paths = extract_paths_from_results(&result); + + // Should only find matches in worktree1 *.rs files (excluding private ones) + assert!( + paths.iter().any(|p| p.contains("main.rs")), + "Should find match in worktree1/src/main.rs" + ); + assert!( + paths.iter().any(|p| p.contains("test.rs")), + "Should find match in worktree1/tests/test.rs" + ); + assert!( + !paths.iter().any(|p| p.contains("secret.rs")), + "Should not find match in excluded worktree1/src/secret.rs" + ); + assert!( + paths.iter().all(|p| !p.contains("worktree2")), + "Should not find any matches in worktree2" + ); + } + + // Helper function to extract file paths from grep results + fn extract_paths_from_results(results: &str) -> Vec { + results + .lines() + .filter(|line| line.starts_with("## Matches in ")) + .map(|line| { + line.strip_prefix("## Matches in ") + .unwrap() + .trim() + .to_string() + }) + .collect() + } +} diff --git a/crates/agent2/src/tools/now_tool.rs b/crates/agent2/src/tools/now_tool.rs new file mode 100644 index 0000000000..71698b8275 --- /dev/null +++ b/crates/agent2/src/tools/now_tool.rs @@ -0,0 +1,66 @@ +use std::sync::Arc; + +use agent_client_protocol as acp; +use anyhow::Result; +use chrono::{Local, Utc}; +use gpui::{App, SharedString, Task}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::{AgentTool, ToolCallEventStream}; + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum Timezone { + /// Use UTC for the datetime. + Utc, + /// Use local time for the datetime. + Local, +} + +/// Returns the current datetime in RFC 3339 format. +/// Only use this tool when the user specifically asks for it or the current task would benefit from knowing the current datetime. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct NowToolInput { + /// The timezone to use for the datetime. + timezone: Timezone, +} + +pub struct NowTool; + +impl AgentTool for NowTool { + type Input = NowToolInput; + type Output = String; + + fn name(&self) -> SharedString { + "now".into() + } + + fn kind(&self) -> acp::ToolKind { + acp::ToolKind::Other + } + + fn initial_title(&self, _input: Result) -> SharedString { + "Get current time".into() + } + + fn run( + self: Arc, + input: Self::Input, + event_stream: ToolCallEventStream, + _cx: &mut App, + ) -> Task> { + let now = match input.timezone { + Timezone::Utc => Utc::now().to_rfc3339(), + Timezone::Local => Local::now().to_rfc3339(), + }; + let content = format!("The current datetime is {now}."); + + event_stream.update_fields(acp::ToolCallUpdateFields { + content: Some(vec![content.clone().into()]), + ..Default::default() + }); + + Task::ready(Ok(content)) + } +} diff --git a/crates/agent2/src/tools/web_search_tool.rs b/crates/agent2/src/tools/web_search_tool.rs new file mode 100644 index 0000000000..12587c2f67 --- /dev/null +++ b/crates/agent2/src/tools/web_search_tool.rs @@ -0,0 +1,105 @@ +use std::sync::Arc; + +use crate::{AgentTool, ToolCallEventStream}; +use agent_client_protocol as acp; +use anyhow::{Result, anyhow}; +use cloud_llm_client::WebSearchResponse; +use gpui::{App, AppContext, Task}; +use language_model::LanguageModelToolResultContent; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +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. \ +/// Results will include snippets and links from relevant web pages. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct WebSearchToolInput { + /// The search term or question to query on the web. + query: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(transparent)] +pub struct WebSearchToolOutput(WebSearchResponse); + +impl From for LanguageModelToolResultContent { + fn from(value: WebSearchToolOutput) -> Self { + serde_json::to_string(&value.0) + .expect("Failed to serialize WebSearchResponse") + .into() + } +} + +pub struct WebSearchTool; + +impl AgentTool for WebSearchTool { + type Input = WebSearchToolInput; + type Output = WebSearchToolOutput; + + fn name(&self) -> SharedString { + "web_search".into() + } + + fn kind(&self) -> acp::ToolKind { + acp::ToolKind::Fetch + } + + fn initial_title(&self, _input: Result) -> SharedString { + "Searching the Web".into() + } + + fn run( + self: Arc, + input: Self::Input, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let Some(provider) = WebSearchRegistry::read_global(cx).active_provider() else { + return Task::ready(Err(anyhow!("Web search is not available."))); + }; + + let search_task = provider.search(input.query, cx); + cx.background_spawn(async move { + let response = match search_task.await { + Ok(response) => response, + Err(err) => { + event_stream.update_fields(acp::ToolCallUpdateFields { + title: Some("Web Search Failed".to_string()), + ..Default::default() + }); + return Err(err); + } + }; + + let result_text = if response.results.len() == 1 { + "1 result".to_string() + } else { + format!("{} results", response.results.len()) + }; + event_stream.update_fields(acp::ToolCallUpdateFields { + title: Some(format!("Searched the web: {result_text}")), + content: Some( + response + .results + .iter() + .map(|result| acp::ToolCallContent::Content { + content: acp::ContentBlock::ResourceLink(acp::ResourceLink { + name: result.title.clone(), + uri: result.url.clone(), + title: Some(result.title.clone()), + description: Some(result.text.clone()), + mime_type: None, + annotations: None, + size: None, + }), + }) + .collect(), + ), + ..Default::default() + }); + Ok(WebSearchToolOutput(response)) + }) + } +} diff --git a/crates/cloud_llm_client/src/cloud_llm_client.rs b/crates/cloud_llm_client/src/cloud_llm_client.rs index e78957ec49..741945af10 100644 --- a/crates/cloud_llm_client/src/cloud_llm_client.rs +++ b/crates/cloud_llm_client/src/cloud_llm_client.rs @@ -263,12 +263,12 @@ pub struct WebSearchBody { pub query: String, } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct WebSearchResponse { pub results: Vec, } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct WebSearchResult { pub title: String, pub url: String, From abb64d2320e77bd3c3e6e0f46c0dbad9f2e25c17 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 11 Aug 2025 10:09:25 -0400 Subject: [PATCH 055/109] Ignore project-local settings for always_allow_tool_actions (#35976) Now `always_allow_tool_actions` is only respected as the user's global setting, not as an overridable project-local setting. This way, you don't have to worry about switching into a project (or switching branches within a project) and discovering that suddenly your tool calls no longer require confirmation. Release Notes: - Removed always_allow_tool_actions from project-local settings (it is now global-only) Co-authored-by: David Kleingeld --- crates/agent_settings/src/agent_settings.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index e6a79963d6..d9557c5d00 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -442,10 +442,6 @@ impl Settings for AgentSettings { &mut settings.inline_alternatives, value.inline_alternatives.clone(), ); - merge( - &mut settings.always_allow_tool_actions, - value.always_allow_tool_actions, - ); merge( &mut settings.notify_when_agent_waiting, value.notify_when_agent_waiting, @@ -507,6 +503,20 @@ impl Settings for AgentSettings { } } + debug_assert_eq!( + sources.default.always_allow_tool_actions.unwrap_or(false), + false, + "For security, agent.always_allow_tool_actions should always be false in default.json. If it's true, that is a bug that should be fixed!" + ); + + // For security reasons, only trust the user's global settings for whether to always allow tool actions. + // If this could be overridden locally, an attacker could (e.g. by committing to source control and + // convincing you to switch branches) modify your project-local settings to disable the agent's safety checks. + settings.always_allow_tool_actions = sources + .user + .and_then(|setting| setting.always_allow_tool_actions) + .unwrap_or(false); + Ok(settings) } From 6478e66e7a6e0c2c580190c674e1f9f1db92f764 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 11 Aug 2025 10:56:45 -0400 Subject: [PATCH 056/109] Stricter `disable_ai` overrides (#35977) Settings overrides (e.g. local project settings, server settings) can no longer change `disable_ai` to `false` if it was `true`; they can only change it to `true`. In other words, settings can only cause AI to be *more* disabled, they can't undo the user's preference for no AI (or the project's requirement not to use AI). Release Notes: - Settings overrides (such as local project settings) can now only override `disable_ai` to become `true`; they can no longer cause otherwise-disabled AI to become re-enabled. --------- Co-authored-by: Assistant Co-authored-by: David Kleingeld --- crates/project/src/project.rs | 171 ++++++++++++++++++++++++++++++++-- 1 file changed, 163 insertions(+), 8 deletions(-) diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index d543e6bf25..27ab55d53e 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -962,14 +962,19 @@ impl settings::Settings for DisableAiSettings { type FileContent = Option; fn load(sources: SettingsSources, _: &mut App) -> Result { - Ok(Self { - disable_ai: sources - .user - .or(sources.server) - .copied() - .flatten() - .unwrap_or(sources.default.ok_or_else(Self::missing_default)?), - }) + // For security reasons, settings can only make AI restrictions MORE strict, not less. + // (For example, if someone is working on a project that contractually + // requires no AI use, that should override the user's setting which + // permits AI use.) + // This also prevents an attacker from using project or server settings to enable AI when it should be disabled. + let disable_ai = sources + .project + .iter() + .chain(sources.user.iter()) + .chain(sources.server.iter()) + .any(|disabled| **disabled == Some(true)); + + Ok(Self { disable_ai }) } fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {} @@ -5508,3 +5513,153 @@ fn provide_inline_values( variables } + +#[cfg(test)] +mod disable_ai_settings_tests { + use super::*; + use gpui::TestAppContext; + use settings::{Settings, SettingsSources}; + + #[gpui::test] + async fn test_disable_ai_settings_security(cx: &mut TestAppContext) { + cx.update(|cx| { + // Test 1: Default is false (AI enabled) + let sources = SettingsSources { + default: &Some(false), + global: None, + extensions: None, + user: None, + release_channel: None, + operating_system: None, + profile: None, + server: None, + project: &[], + }; + let settings = DisableAiSettings::load(sources, cx).unwrap(); + assert_eq!(settings.disable_ai, false, "Default should allow AI"); + + // Test 2: Global true, local false -> still disabled (local cannot re-enable) + let global_true = Some(true); + let local_false = Some(false); + let sources = SettingsSources { + default: &Some(false), + global: None, + extensions: None, + user: Some(&global_true), + release_channel: None, + operating_system: None, + profile: None, + server: None, + project: &[&local_false], + }; + let settings = DisableAiSettings::load(sources, cx).unwrap(); + assert_eq!( + settings.disable_ai, true, + "Local false cannot override global true" + ); + + // Test 3: Global false, local true -> disabled (local can make more restrictive) + let global_false = Some(false); + let local_true = Some(true); + let sources = SettingsSources { + default: &Some(false), + global: None, + extensions: None, + user: Some(&global_false), + release_channel: None, + operating_system: None, + profile: None, + server: None, + project: &[&local_true], + }; + let settings = DisableAiSettings::load(sources, cx).unwrap(); + assert_eq!( + settings.disable_ai, true, + "Local true can override global false" + ); + + // Test 4: Server can only make more restrictive (set to true) + let user_false = Some(false); + let server_true = Some(true); + let sources = SettingsSources { + default: &Some(false), + global: None, + extensions: None, + user: Some(&user_false), + release_channel: None, + operating_system: None, + profile: None, + server: Some(&server_true), + project: &[], + }; + let settings = DisableAiSettings::load(sources, cx).unwrap(); + assert_eq!( + settings.disable_ai, true, + "Server can set to true even if user is false" + ); + + // Test 5: Server false cannot override user true + let user_true = Some(true); + let server_false = Some(false); + let sources = SettingsSources { + default: &Some(false), + global: None, + extensions: None, + user: Some(&user_true), + release_channel: None, + operating_system: None, + profile: None, + server: Some(&server_false), + project: &[], + }; + let settings = DisableAiSettings::load(sources, cx).unwrap(); + assert_eq!( + settings.disable_ai, true, + "Server false cannot override user true" + ); + + // Test 6: Multiple local settings, any true disables AI + let global_false = Some(false); + let local_false3 = Some(false); + let local_true2 = Some(true); + let local_false4 = Some(false); + let sources = SettingsSources { + default: &Some(false), + global: None, + extensions: None, + user: Some(&global_false), + release_channel: None, + operating_system: None, + profile: None, + server: None, + project: &[&local_false3, &local_true2, &local_false4], + }; + let settings = DisableAiSettings::load(sources, cx).unwrap(); + assert_eq!( + settings.disable_ai, true, + "Any local true should disable AI" + ); + + // Test 7: All three sources can independently disable AI + let user_false2 = Some(false); + let server_false2 = Some(false); + let local_true3 = Some(true); + let sources = SettingsSources { + default: &Some(false), + global: None, + extensions: None, + user: Some(&user_false2), + release_channel: None, + operating_system: None, + profile: None, + server: Some(&server_false2), + project: &[&local_true3], + }; + let settings = DisableAiSettings::load(sources, cx).unwrap(); + assert_eq!( + settings.disable_ai, true, + "Local can disable even if user and server are false" + ); + }); + } +} From 12084b667784e3a1fae4b1bc4b9abf5c7640f55e Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 11 Aug 2025 16:07:32 +0100 Subject: [PATCH 057/109] Fix keys not being sent to terminal (#35979) Fixes #35057 Release Notes: - Fix input being sent to editor/terminal when pending keystrokes are resolved --- crates/gpui/src/key_dispatch.rs | 173 +++++++++++++++++++++++++++++++- crates/gpui/src/window.rs | 3 +- 2 files changed, 173 insertions(+), 3 deletions(-) diff --git a/crates/gpui/src/key_dispatch.rs b/crates/gpui/src/key_dispatch.rs index cc6ebb9b08..c3f5d18603 100644 --- a/crates/gpui/src/key_dispatch.rs +++ b/crates/gpui/src/key_dispatch.rs @@ -611,9 +611,17 @@ impl DispatchTree { #[cfg(test)] mod tests { - use std::{cell::RefCell, rc::Rc}; + use crate::{ + self as gpui, Element, ElementId, GlobalElementId, InspectorElementId, LayoutId, Style, + }; + use core::panic; + use std::{cell::RefCell, ops::Range, rc::Rc}; - use crate::{Action, ActionRegistry, DispatchTree, KeyBinding, KeyContext, Keymap}; + use crate::{ + Action, ActionRegistry, App, Bounds, Context, DispatchTree, FocusHandle, InputHandler, + IntoElement, KeyBinding, KeyContext, Keymap, Pixels, Point, Render, TestAppContext, + UTF16Selection, Window, + }; #[derive(PartialEq, Eq)] struct TestAction; @@ -674,4 +682,165 @@ mod tests { assert!(keybinding[0].action.partial_eq(&TestAction)) } + + #[crate::test] + fn test_input_handler_pending(cx: &mut TestAppContext) { + #[derive(Clone)] + struct CustomElement { + focus_handle: FocusHandle, + text: Rc>, + } + impl CustomElement { + fn new(cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + text: Rc::default(), + } + } + } + impl Element for CustomElement { + type RequestLayoutState = (); + + type PrepaintState = (); + + fn id(&self) -> Option { + Some("custom".into()) + } + fn source_location(&self) -> Option<&'static panic::Location<'static>> { + None + } + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + (window.request_layout(Style::default(), [], cx), ()) + } + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + window.set_focus_handle(&self.focus_handle, cx); + } + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let mut key_context = KeyContext::default(); + key_context.add("Terminal"); + window.set_key_context(key_context); + window.handle_input(&self.focus_handle, self.clone(), cx); + window.on_action(std::any::TypeId::of::(), |_, _, _, _| {}); + } + } + impl IntoElement for CustomElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } + } + + impl InputHandler for CustomElement { + fn selected_text_range( + &mut self, + _: bool, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + + fn marked_text_range(&mut self, _: &mut Window, _: &mut App) -> Option> { + None + } + + fn text_for_range( + &mut self, + _: Range, + _: &mut Option>, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + + fn replace_text_in_range( + &mut self, + replacement_range: Option>, + text: &str, + _: &mut Window, + _: &mut App, + ) { + if replacement_range.is_some() { + unimplemented!() + } + self.text.borrow_mut().push_str(text) + } + + fn replace_and_mark_text_in_range( + &mut self, + replacement_range: Option>, + new_text: &str, + _: Option>, + _: &mut Window, + _: &mut App, + ) { + if replacement_range.is_some() { + unimplemented!() + } + self.text.borrow_mut().push_str(new_text) + } + + fn unmark_text(&mut self, _: &mut Window, _: &mut App) {} + + fn bounds_for_range( + &mut self, + _: Range, + _: &mut Window, + _: &mut App, + ) -> Option> { + None + } + + fn character_index_for_point( + &mut self, + _: Point, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + } + impl Render for CustomElement { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + self.clone() + } + } + + cx.update(|cx| { + cx.bind_keys([KeyBinding::new("ctrl-b", TestAction, Some("Terminal"))]); + cx.bind_keys([KeyBinding::new("ctrl-b h", TestAction, Some("Terminal"))]); + }); + let (test, cx) = cx.add_window_view(|_, cx| CustomElement::new(cx)); + cx.update(|window, cx| { + window.focus(&test.read(cx).focus_handle); + window.activate_window(); + }); + cx.simulate_keystrokes("ctrl-b ["); + test.update(cx, |test, _| assert_eq!(test.text.borrow().as_str(), "[")) + } } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 40d3845ff9..3a430b806d 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -3688,7 +3688,8 @@ impl Window { ); if !match_result.to_replay.is_empty() { - self.replay_pending_input(match_result.to_replay, cx) + self.replay_pending_input(match_result.to_replay, cx); + cx.propagate_event = true; } if !match_result.pending.is_empty() { From 62270b33c24e64763c5330d69f8ebc3931d49ae8 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Mon, 11 Aug 2025 11:09:38 -0400 Subject: [PATCH 058/109] git: Add ability to clone remote repositories from Zed (#35606) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds preliminary git clone support through using the new `GitClone` action. This works with SSH connections too. - [x] Get backend working - [x] Add a UI to interact with this Future follow-ups: - Polish the UI - Have the path select prompt say "Select Repository clone target" instead of “Open” - Use Zed path prompt if the user has that as a setting - Add support for cloning from a user's GitHub repositories directly Release Notes: - Add the ability to clone remote git repositories through the `git: Clone` action --------- Co-authored-by: hpmcdona --- crates/fs/src/fs.rs | 24 ++++- crates/git/src/git.rs | 2 + crates/git_ui/src/git_panel.rs | 93 ++++++++++++++++++ crates/git_ui/src/git_ui.rs | 120 ++++++++++++++++++++++- crates/language/src/language_settings.rs | 2 +- crates/project/src/git_store.rs | 56 +++++++++++ crates/proto/proto/git.proto | 10 ++ crates/proto/proto/zed.proto | 5 +- crates/proto/src/proto.rs | 6 +- 9 files changed, 310 insertions(+), 8 deletions(-) diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index a76ccee2bf..af8fe129ab 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -12,7 +12,7 @@ use gpui::BackgroundExecutor; use gpui::Global; use gpui::ReadGlobal as _; use std::borrow::Cow; -use util::command::new_std_command; +use util::command::{new_smol_command, new_std_command}; #[cfg(unix)] use std::os::fd::{AsFd, AsRawFd}; @@ -134,6 +134,7 @@ pub trait Fs: Send + Sync { fn home_dir(&self) -> Option; fn open_repo(&self, abs_dot_git: &Path) -> Option>; fn git_init(&self, abs_work_directory: &Path, fallback_branch_name: String) -> Result<()>; + async fn git_clone(&self, repo_url: &str, abs_work_directory: &Path) -> Result<()>; fn is_fake(&self) -> bool; async fn is_case_sensitive(&self) -> Result; @@ -839,6 +840,23 @@ impl Fs for RealFs { Ok(()) } + async fn git_clone(&self, repo_url: &str, abs_work_directory: &Path) -> Result<()> { + let output = new_smol_command("git") + .current_dir(abs_work_directory) + .args(&["clone", repo_url]) + .output() + .await?; + + if !output.status.success() { + anyhow::bail!( + "git clone failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + Ok(()) + } + fn is_fake(&self) -> bool { false } @@ -2352,6 +2370,10 @@ impl Fs for FakeFs { smol::block_on(self.create_dir(&abs_work_directory_path.join(".git"))) } + async fn git_clone(&self, _repo_url: &str, _abs_work_directory: &Path) -> Result<()> { + anyhow::bail!("Git clone is not supported in fake Fs") + } + fn is_fake(&self) -> bool { true } diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index 553361e673..e6336eb656 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -93,6 +93,8 @@ actions!( Init, /// Opens all modified files in the editor. OpenModifiedFiles, + /// Clones a repository. + Clone, ] ); diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index e4f445858d..75fac114d2 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -2081,6 +2081,99 @@ impl GitPanel { .detach_and_log_err(cx); } + pub(crate) fn git_clone(&mut self, repo: String, window: &mut Window, cx: &mut Context) { + let path = cx.prompt_for_paths(gpui::PathPromptOptions { + files: false, + directories: true, + multiple: false, + }); + + let workspace = self.workspace.clone(); + + cx.spawn_in(window, async move |this, cx| { + let mut paths = path.await.ok()?.ok()??; + let mut path = paths.pop()?; + let repo_name = repo + .split(std::path::MAIN_SEPARATOR_STR) + .last()? + .strip_suffix(".git")? + .to_owned(); + + let fs = this.read_with(cx, |this, _| this.fs.clone()).ok()?; + + let prompt_answer = match fs.git_clone(&repo, path.as_path()).await { + Ok(_) => cx.update(|window, cx| { + window.prompt( + PromptLevel::Info, + "Git Clone", + None, + &["Add repo to project", "Open repo in new project"], + cx, + ) + }), + Err(e) => { + this.update(cx, |this: &mut GitPanel, cx| { + let toast = StatusToast::new(e.to_string(), cx, |this, _| { + this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error)) + .dismiss_button(true) + }); + + this.workspace + .update(cx, |workspace, cx| { + workspace.toggle_status_toast(toast, cx); + }) + .ok(); + }) + .ok()?; + + return None; + } + } + .ok()?; + + path.push(repo_name); + match prompt_answer.await.ok()? { + 0 => { + workspace + .update(cx, |workspace, cx| { + workspace + .project() + .update(cx, |project, cx| { + project.create_worktree(path.as_path(), true, cx) + }) + .detach(); + }) + .ok(); + } + 1 => { + workspace + .update(cx, move |workspace, cx| { + workspace::open_new( + Default::default(), + workspace.app_state().clone(), + cx, + move |workspace, _, cx| { + cx.activate(true); + workspace + .project() + .update(cx, |project, cx| { + project.create_worktree(&path, true, cx) + }) + .detach(); + }, + ) + .detach(); + }) + .ok(); + } + _ => {} + } + + Some(()) + }) + .detach(); + } + pub(crate) fn git_init(&mut self, window: &mut Window, cx: &mut Context) { let worktrees = self .project diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index bde867bcd2..7d5207dfb6 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -3,21 +3,25 @@ use std::any::Any; use ::settings::Settings; use command_palette_hooks::CommandPaletteFilter; use commit_modal::CommitModal; -use editor::{Editor, actions::DiffClipboardWithSelectionData}; +use editor::{Editor, EditorElement, EditorStyle, actions::DiffClipboardWithSelectionData}; mod blame_ui; use git::{ repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus}, status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode}, }; use git_panel_settings::GitPanelSettings; -use gpui::{Action, App, Context, FocusHandle, Window, actions}; +use gpui::{ + Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, TextStyle, + Window, actions, +}; use onboarding::GitOnboardingModal; use project_diff::ProjectDiff; +use theme::ThemeSettings; use ui::prelude::*; -use workspace::Workspace; +use workspace::{ModalView, Workspace}; use zed_actions; -use crate::text_diff_view::TextDiffView; +use crate::{git_panel::GitPanel, text_diff_view::TextDiffView}; mod askpass_modal; pub mod branch_picker; @@ -169,6 +173,19 @@ pub fn init(cx: &mut App) { panel.git_init(window, cx); }); }); + workspace.register_action(|workspace, _action: &git::Clone, window, cx| { + let Some(panel) = workspace.panel::(cx) else { + return; + }; + + workspace.toggle_modal(window, cx, |window, cx| { + GitCloneModal::show(panel, window, cx) + }); + + // panel.update(cx, |panel, cx| { + // panel.git_clone(window, cx); + // }); + }); workspace.register_action(|workspace, _: &git::OpenModifiedFiles, window, cx| { open_modified_files(workspace, window, cx); }); @@ -613,3 +630,98 @@ impl Component for GitStatusIcon { ) } } + +struct GitCloneModal { + panel: Entity, + repo_input: Entity, + focus_handle: FocusHandle, +} + +impl GitCloneModal { + pub fn show(panel: Entity, window: &mut Window, cx: &mut Context) -> Self { + let repo_input = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Enter repository", cx); + editor + }); + let focus_handle = repo_input.focus_handle(cx); + + window.focus(&focus_handle); + + Self { + panel, + repo_input, + focus_handle, + } + } + + fn render_editor(&self, window: &Window, cx: &App) -> impl IntoElement { + let settings = ThemeSettings::get_global(cx); + let theme = cx.theme(); + + let text_style = TextStyle { + color: cx.theme().colors().text, + font_family: settings.buffer_font.family.clone(), + font_features: settings.buffer_font.features.clone(), + font_size: settings.buffer_font_size(cx).into(), + font_weight: settings.buffer_font.weight, + line_height: relative(settings.buffer_line_height.value()), + background_color: Some(theme.colors().editor_background), + ..Default::default() + }; + + let element = EditorElement::new( + &self.repo_input, + EditorStyle { + background: theme.colors().editor_background, + local_player: theme.players().local(), + text: text_style, + ..Default::default() + }, + ); + + div() + .rounded_md() + .p_1() + .border_1() + .border_color(theme.colors().border_variant) + .when( + self.repo_input + .focus_handle(cx) + .contains_focused(window, cx), + |this| this.border_color(theme.colors().border_focused), + ) + .child(element) + .bg(theme.colors().editor_background) + } +} + +impl Focusable for GitCloneModal { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for GitCloneModal { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .size_full() + .w(rems(34.)) + .elevation_3(cx) + .child(self.render_editor(window, cx)) + .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| { + cx.emit(DismissEvent); + })) + .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| { + let repo = this.repo_input.read(cx).text(cx); + this.panel.update(cx, |panel, cx| { + panel.git_clone(repo, window, cx); + }); + cx.emit(DismissEvent); + })) + } +} + +impl EventEmitter for GitCloneModal {} + +impl ModalView for GitCloneModal {} diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index 9b0abb1537..1aae0b2f7e 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -987,7 +987,7 @@ pub struct InlayHintSettings { /// Default: false #[serde(default)] pub enabled: bool, - /// Global switch to toggle inline values on and off. + /// Global switch to toggle inline values on and off when debugging. /// /// Default: true #[serde(default = "default_true")] diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 01fc987816..5d48c833ab 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -441,6 +441,7 @@ impl GitStore { client.add_entity_request_handler(Self::handle_blame_buffer); client.add_entity_message_handler(Self::handle_update_repository); client.add_entity_message_handler(Self::handle_remove_repository); + client.add_entity_request_handler(Self::handle_git_clone); } pub fn is_local(&self) -> bool { @@ -1464,6 +1465,45 @@ impl GitStore { } } + pub fn git_clone( + &self, + repo: String, + path: impl Into>, + cx: &App, + ) -> Task> { + let path = path.into(); + match &self.state { + GitStoreState::Local { fs, .. } => { + let fs = fs.clone(); + cx.background_executor() + .spawn(async move { fs.git_clone(&repo, &path).await }) + } + GitStoreState::Ssh { + upstream_client, + upstream_project_id, + .. + } => { + let request = upstream_client.request(proto::GitClone { + project_id: upstream_project_id.0, + abs_path: path.to_string_lossy().to_string(), + remote_repo: repo, + }); + + cx.background_spawn(async move { + let result = request.await?; + + match result.success { + true => Ok(()), + false => Err(anyhow!("Git Clone failed")), + } + }) + } + GitStoreState::Remote { .. } => { + Task::ready(Err(anyhow!("Git Clone isn't supported for remote users"))) + } + } + } + async fn handle_update_repository( this: Entity, envelope: TypedEnvelope, @@ -1550,6 +1590,22 @@ impl GitStore { Ok(proto::Ack {}) } + async fn handle_git_clone( + this: Entity, + envelope: TypedEnvelope, + cx: AsyncApp, + ) -> Result { + let path: Arc = PathBuf::from(envelope.payload.abs_path).into(); + let repo_name = envelope.payload.remote_repo; + let result = cx + .update(|cx| this.read(cx).git_clone(repo_name, path, cx))? + .await; + + Ok(proto::GitCloneResponse { + success: result.is_ok(), + }) + } + async fn handle_fetch( this: Entity, envelope: TypedEnvelope, diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index c32da9b110..f2c388a3a3 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -202,6 +202,16 @@ message GitInit { string fallback_branch_name = 3; } +message GitClone { + uint64 project_id = 1; + string abs_path = 2; + string remote_repo = 3; +} + +message GitCloneResponse { + bool success = 1; +} + message CheckForPushedCommits { uint64 project_id = 1; reserved 2; diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index bb97bd500a..856a793c2f 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -399,7 +399,10 @@ message Envelope { GetDefaultBranchResponse get_default_branch_response = 360; GetCrashFiles get_crash_files = 361; - GetCrashFilesResponse get_crash_files_response = 362; // current max + GetCrashFilesResponse get_crash_files_response = 362; + + GitClone git_clone = 363; + GitCloneResponse git_clone_response = 364; // current max } reserved 87 to 88; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 9edb041b4b..a5dd97661f 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -316,6 +316,8 @@ messages!( (PullWorkspaceDiagnostics, Background), (GetDefaultBranch, Background), (GetDefaultBranchResponse, Background), + (GitClone, Background), + (GitCloneResponse, Background) ); request_messages!( @@ -484,6 +486,7 @@ request_messages!( (GetDocumentDiagnostics, GetDocumentDiagnosticsResponse), (PullWorkspaceDiagnostics, Ack), (GetDefaultBranch, GetDefaultBranchResponse), + (GitClone, GitCloneResponse) ); entity_messages!( @@ -615,7 +618,8 @@ entity_messages!( LogToDebugConsole, GetDocumentDiagnostics, PullWorkspaceDiagnostics, - GetDefaultBranch + GetDefaultBranch, + GitClone ); entity_messages!( From 7965052757f2ce235eea72cf40d9d992f00b5527 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Mon, 11 Aug 2025 12:33:21 -0400 Subject: [PATCH 059/109] Make SwitchField component clickable from the keyboard when focused (#35830) Release Notes: - N/A --- crates/ui/src/components/toggle.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/ui/src/components/toggle.rs b/crates/ui/src/components/toggle.rs index 59c056859d..e5f28e3b25 100644 --- a/crates/ui/src/components/toggle.rs +++ b/crates/ui/src/components/toggle.rs @@ -420,7 +420,7 @@ pub struct Switch { id: ElementId, toggle_state: ToggleState, disabled: bool, - on_click: Option>, + on_click: Option>, label: Option, key_binding: Option, color: SwitchColor, @@ -459,7 +459,7 @@ impl Switch { mut self, handler: impl Fn(&ToggleState, &mut Window, &mut App) + 'static, ) -> Self { - self.on_click = Some(Box::new(handler)); + self.on_click = Some(Rc::new(handler)); self } @@ -513,10 +513,16 @@ impl RenderOnce for Switch { .when_some( self.tab_index.filter(|_| !self.disabled), |this, tab_index| { - this.tab_index(tab_index).focus(|mut style| { - style.border_color = Some(cx.theme().colors().border_focused); - style - }) + this.tab_index(tab_index) + .focus(|mut style| { + style.border_color = Some(cx.theme().colors().border_focused); + style + }) + .when_some(self.on_click.clone(), |this, on_click| { + this.on_click(move |_, window, cx| { + on_click(&self.toggle_state.inverse(), window, cx) + }) + }) }, ) .child( From 42bf5a17b969bd33335b18ad12e0773b07a198f6 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Mon, 11 Aug 2025 12:49:46 -0400 Subject: [PATCH 060/109] Delay rendering tool call diff editor until it has a revealed range (#35901) Release Notes: - N/A --- crates/acp_thread/src/diff.rs | 4 +++ crates/agent_ui/src/acp/thread_view.rs | 35 ++++++++++++++++---------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/crates/acp_thread/src/diff.rs b/crates/acp_thread/src/diff.rs index 9cc6271360..a2c2d6c322 100644 --- a/crates/acp_thread/src/diff.rs +++ b/crates/acp_thread/src/diff.rs @@ -174,6 +174,10 @@ impl Diff { buffer_text ) } + + pub fn has_revealed_range(&self, cx: &App) -> bool { + self.multibuffer().read(cx).excerpt_paths().next().is_some() + } } pub struct PendingDiff { diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 2536612ece..32f9948d97 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -1153,16 +1153,25 @@ impl AcpThreadView { ), }; - let needs_confirmation = match &tool_call.status { - ToolCallStatus::WaitingForConfirmation { .. } => true, - _ => tool_call - .content - .iter() - .any(|content| matches!(content, ToolCallContent::Diff(_))), - }; - - let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation; - let is_open = !is_collapsible || self.expanded_tool_calls.contains(&tool_call.id); + let needs_confirmation = matches!( + tool_call.status, + ToolCallStatus::WaitingForConfirmation { .. } + ); + let is_edit = matches!(tool_call.kind, acp::ToolKind::Edit); + let has_diff = tool_call + .content + .iter() + .any(|content| matches!(content, ToolCallContent::Diff { .. })); + let has_nonempty_diff = tool_call.content.iter().any(|content| match content { + ToolCallContent::Diff(diff) => diff.read(cx).has_revealed_range(cx), + _ => false, + }); + let is_collapsible = + !tool_call.content.is_empty() && !needs_confirmation && !is_edit && !has_diff; + let is_open = tool_call.content.is_empty() + || needs_confirmation + || has_nonempty_diff + || self.expanded_tool_calls.contains(&tool_call.id); let gradient_color = cx.theme().colors().panel_background; let gradient_overlay = { @@ -1180,7 +1189,7 @@ impl AcpThreadView { }; v_flex() - .when(needs_confirmation, |this| { + .when(needs_confirmation || is_edit || has_diff, |this| { this.rounded_lg() .border_1() .border_color(self.tool_card_border_color(cx)) @@ -1194,7 +1203,7 @@ impl AcpThreadView { .gap_1() .justify_between() .map(|this| { - if needs_confirmation { + if needs_confirmation || is_edit || has_diff { this.pl_2() .pr_1() .py_1() @@ -1271,7 +1280,7 @@ impl AcpThreadView { .child(self.render_markdown( tool_call.label.clone(), default_markdown_style( - needs_confirmation, + needs_confirmation || is_edit || has_diff, window, cx, ), From 39dfd52d041cf33f6270b1deebc854c749fb4a58 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Mon, 11 Aug 2025 18:50:24 +0200 Subject: [PATCH 061/109] python: Create DAP download directory sooner (#35986) Closes #35980 Release Notes: - Fixed Python Debug sessions not starting up when a session is started up for the first time. --- crates/dap_adapters/src/python.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/dap_adapters/src/python.rs b/crates/dap_adapters/src/python.rs index 461ce6fbb3..a2bd934311 100644 --- a/crates/dap_adapters/src/python.rs +++ b/crates/dap_adapters/src/python.rs @@ -152,6 +152,9 @@ impl PythonDebugAdapter { maybe!(async move { let response = latest_release.filter(|response| response.status().is_success())?; + let download_dir = debug_adapters_dir().join(Self::ADAPTER_NAME); + std::fs::create_dir_all(&download_dir).ok()?; + let mut output = String::new(); response .into_body() From 76b95d4f671ac04b2af25385004dc58cff95ff72 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Mon, 11 Aug 2025 13:06:31 -0400 Subject: [PATCH 062/109] Try to diagnose memory access violation in Windows tests (#35926) Release Notes: - N/A --- .github/actions/run_tests_windows/action.yml | 163 ++++++++++++++++++- 1 file changed, 162 insertions(+), 1 deletion(-) diff --git a/.github/actions/run_tests_windows/action.yml b/.github/actions/run_tests_windows/action.yml index cbe95e82c1..e3e3b7142e 100644 --- a/.github/actions/run_tests_windows/action.yml +++ b/.github/actions/run_tests_windows/action.yml @@ -20,7 +20,168 @@ runs: with: node-version: "18" + - name: Configure crash dumps + shell: powershell + run: | + # Record the start time for this CI run + $runStartTime = Get-Date + $runStartTimeStr = $runStartTime.ToString("yyyy-MM-dd HH:mm:ss") + Write-Host "CI run started at: $runStartTimeStr" + + # Save the timestamp for later use + echo "CI_RUN_START_TIME=$($runStartTime.Ticks)" >> $env:GITHUB_ENV + + # Create crash dump directory in workspace (non-persistent) + $dumpPath = "$env:GITHUB_WORKSPACE\crash_dumps" + New-Item -ItemType Directory -Force -Path $dumpPath | Out-Null + + Write-Host "Setting up crash dump detection..." + Write-Host "Workspace dump path: $dumpPath" + + # Note: We're NOT modifying registry on stateful runners + # Instead, we'll check default Windows crash locations after tests + - name: Run tests shell: powershell working-directory: ${{ inputs.working-directory }} - run: cargo nextest run --workspace --no-fail-fast + run: | + $env:RUST_BACKTRACE = "full" + + # Enable Windows debugging features + $env:_NT_SYMBOL_PATH = "srv*https://msdl.microsoft.com/download/symbols" + + # .NET crash dump environment variables (ephemeral) + $env:COMPlus_DbgEnableMiniDump = "1" + $env:COMPlus_DbgMiniDumpType = "4" + $env:COMPlus_CreateDumpDiagnostics = "1" + + cargo nextest run --workspace --no-fail-fast + continue-on-error: true + + - name: Analyze crash dumps + if: always() + shell: powershell + run: | + Write-Host "Checking for crash dumps..." + + # Get the CI run start time from the environment + $runStartTime = [DateTime]::new([long]$env:CI_RUN_START_TIME) + Write-Host "Only analyzing dumps created after: $($runStartTime.ToString('yyyy-MM-dd HH:mm:ss'))" + + # Check all possible crash dump locations + $searchPaths = @( + "$env:GITHUB_WORKSPACE\crash_dumps", + "$env:LOCALAPPDATA\CrashDumps", + "$env:TEMP", + "$env:GITHUB_WORKSPACE", + "$env:USERPROFILE\AppData\Local\CrashDumps", + "C:\Windows\System32\config\systemprofile\AppData\Local\CrashDumps" + ) + + $dumps = @() + foreach ($path in $searchPaths) { + if (Test-Path $path) { + Write-Host "Searching in: $path" + $found = Get-ChildItem "$path\*.dmp" -ErrorAction SilentlyContinue | Where-Object { + $_.CreationTime -gt $runStartTime + } + if ($found) { + $dumps += $found + Write-Host " Found $($found.Count) dump(s) from this CI run" + } + } + } + + if ($dumps) { + Write-Host "Found $($dumps.Count) crash dump(s)" + + # Install debugging tools if not present + $cdbPath = "C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\cdb.exe" + if (-not (Test-Path $cdbPath)) { + Write-Host "Installing Windows Debugging Tools..." + $url = "https://go.microsoft.com/fwlink/?linkid=2237387" + Invoke-WebRequest -Uri $url -OutFile winsdksetup.exe + Start-Process -Wait winsdksetup.exe -ArgumentList "/features OptionId.WindowsDesktopDebuggers /quiet" + } + + foreach ($dump in $dumps) { + Write-Host "`n==================================" + Write-Host "Analyzing crash dump: $($dump.Name)" + Write-Host "Size: $([math]::Round($dump.Length / 1MB, 2)) MB" + Write-Host "Time: $($dump.CreationTime)" + Write-Host "==================================" + + # Set symbol path + $env:_NT_SYMBOL_PATH = "srv*C:\symbols*https://msdl.microsoft.com/download/symbols" + + # Run analysis + $analysisOutput = & $cdbPath -z $dump.FullName -c "!analyze -v; ~*k; lm; q" 2>&1 | Out-String + + # Extract key information + if ($analysisOutput -match "ExceptionCode:\s*([\w]+)") { + Write-Host "Exception Code: $($Matches[1])" + if ($Matches[1] -eq "c0000005") { + Write-Host "Exception Type: ACCESS VIOLATION" + } + } + + if ($analysisOutput -match "EXCEPTION_RECORD:\s*(.+)") { + Write-Host "Exception Record: $($Matches[1])" + } + + if ($analysisOutput -match "FAULTING_IP:\s*\n(.+)") { + Write-Host "Faulting Instruction: $($Matches[1])" + } + + # Save full analysis + $analysisFile = "$($dump.FullName).analysis.txt" + $analysisOutput | Out-File -FilePath $analysisFile + Write-Host "`nFull analysis saved to: $analysisFile" + + # Print stack trace section + Write-Host "`n--- Stack Trace Preview ---" + $stackSection = $analysisOutput -split "STACK_TEXT:" | Select-Object -Last 1 + $stackLines = $stackSection -split "`n" | Select-Object -First 20 + $stackLines | ForEach-Object { Write-Host $_ } + Write-Host "--- End Stack Trace Preview ---" + } + + Write-Host "`n⚠️ Crash dumps detected! Download the 'crash-dumps' artifact for detailed analysis." + + # Copy dumps to workspace for artifact upload + $artifactPath = "$env:GITHUB_WORKSPACE\crash_dumps_collected" + New-Item -ItemType Directory -Force -Path $artifactPath | Out-Null + + foreach ($dump in $dumps) { + $destName = "$($dump.Directory.Name)_$($dump.Name)" + Copy-Item $dump.FullName -Destination "$artifactPath\$destName" + if (Test-Path "$($dump.FullName).analysis.txt") { + Copy-Item "$($dump.FullName).analysis.txt" -Destination "$artifactPath\$destName.analysis.txt" + } + } + + Write-Host "Copied $($dumps.Count) dump(s) to artifact directory" + } else { + Write-Host "No crash dumps from this CI run found" + } + + - name: Upload crash dumps + if: always() + uses: actions/upload-artifact@v4 + with: + name: crash-dumps-${{ github.run_id }}-${{ github.run_attempt }} + path: | + crash_dumps_collected/*.dmp + crash_dumps_collected/*.txt + if-no-files-found: ignore + retention-days: 7 + + - name: Check test results + shell: powershell + working-directory: ${{ inputs.working-directory }} + run: | + # Re-check test results to fail the job if tests failed + if ($LASTEXITCODE -ne 0) { + Write-Host "Tests failed with exit code: $LASTEXITCODE" + exit $LASTEXITCODE + } From 56c4992b9ac5c63534fb8cf63fb6536d9abe9a0f Mon Sep 17 00:00:00 2001 From: localcc Date: Mon, 11 Aug 2025 19:17:48 +0200 Subject: [PATCH 063/109] Fix underline flickering (#35989) Closes #35559 Release Notes: - Fixed underline flickering --- crates/gpui/src/scene.rs | 2 +- crates/gpui/src/window.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs index c527dfe750..758d06e597 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -476,7 +476,7 @@ pub(crate) struct Underline { pub content_mask: ContentMask, pub color: Hsla, pub thickness: ScaledPixels, - pub wavy: bool, + pub wavy: u32, } impl From for Primitive { diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 3a430b806d..c0ffd34a0d 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -2814,7 +2814,7 @@ impl Window { content_mask: content_mask.scale(scale_factor), color: style.color.unwrap_or_default().opacity(element_opacity), thickness: style.thickness.scale(scale_factor), - wavy: style.wavy, + wavy: if style.wavy { 1 } else { 0 }, }); } @@ -2845,7 +2845,7 @@ impl Window { content_mask: content_mask.scale(scale_factor), thickness: style.thickness.scale(scale_factor), color: style.color.unwrap_or_default().opacity(opacity), - wavy: false, + wavy: 0, }); } From 365b5aa31d606f8ecac440de98a81f405f751d67 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 11 Aug 2025 19:22:19 +0200 Subject: [PATCH 064/109] Centralize `always_allow` logic when authorizing agent2 tools (#35988) Release Notes: - N/A --------- Co-authored-by: Cole Miller Co-authored-by: Bennet Bo Fenner Co-authored-by: Agus Zubiaga Co-authored-by: Ben Brandt --- crates/agent2/src/tests/mod.rs | 93 ++++++++++++++++++++++- crates/agent2/src/tests/test_tools.rs | 4 +- crates/agent2/src/thread.rs | 40 +++++++--- crates/agent2/src/tools/edit_file_tool.rs | 16 ++-- crates/agent2/src/tools/open_tool.rs | 2 +- crates/agent2/src/tools/terminal_tool.rs | 18 +---- crates/fs/src/fs.rs | 3 + 7 files changed, 136 insertions(+), 40 deletions(-) diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index b47816f35c..d6aaddf2c2 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -4,9 +4,11 @@ use action_log::ActionLog; use agent_client_protocol::{self as acp}; use anyhow::Result; use client::{Client, UserStore}; -use fs::FakeFs; +use fs::{FakeFs, Fs}; use futures::channel::mpsc::UnboundedReceiver; -use gpui::{AppContext, Entity, Task, TestAppContext, http_client::FakeHttpClient}; +use gpui::{ + App, AppContext, Entity, Task, TestAppContext, UpdateGlobal, http_client::FakeHttpClient, +}; use indoc::indoc; use language_model::{ LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, @@ -19,6 +21,7 @@ use reqwest_client::ReqwestClient; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::json; +use settings::SettingsStore; use smol::stream::StreamExt; use std::{cell::RefCell, path::Path, rc::Rc, sync::Arc, time::Duration}; use util::path; @@ -282,6 +285,63 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { }) ] ); + + // Simulate yet another tool call. + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "tool_id_3".into(), + name: ToolRequiringPermission.name().into(), + raw_input: "{}".into(), + input: json!({}), + is_input_complete: true, + }, + )); + fake_model.end_last_completion_stream(); + + // Respond by always allowing tools. + let tool_call_auth_3 = next_tool_call_authorization(&mut events).await; + tool_call_auth_3 + .response + .send(tool_call_auth_3.options[0].id.clone()) + .unwrap(); + cx.run_until_parked(); + let completion = fake_model.pending_completions().pop().unwrap(); + let message = completion.messages.last().unwrap(); + assert_eq!( + message.content, + vec![MessageContent::ToolResult(LanguageModelToolResult { + tool_use_id: tool_call_auth_3.tool_call.id.0.to_string().into(), + tool_name: ToolRequiringPermission.name().into(), + is_error: false, + content: "Allowed".into(), + output: Some("Allowed".into()) + })] + ); + + // Simulate a final tool call, ensuring we don't trigger authorization. + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "tool_id_4".into(), + name: ToolRequiringPermission.name().into(), + raw_input: "{}".into(), + input: json!({}), + is_input_complete: true, + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + let completion = fake_model.pending_completions().pop().unwrap(); + let message = completion.messages.last().unwrap(); + assert_eq!( + message.content, + vec![MessageContent::ToolResult(LanguageModelToolResult { + tool_use_id: "tool_id_4".into(), + tool_name: ToolRequiringPermission.name().into(), + is_error: false, + content: "Allowed".into(), + output: Some("Allowed".into()) + })] + ); } #[gpui::test] @@ -773,13 +833,17 @@ impl TestModel { async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest { cx.executor().allow_parking(); + + let fs = FakeFs::new(cx.background_executor.clone()); + cx.update(|cx| { settings::init(cx); + watch_settings(fs.clone(), cx); Project::init_settings(cx); + agent_settings::init(cx); }); let templates = Templates::new(); - let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree(path!("/test"), json!({})).await; let project = Project::test(fs, [path!("/test").as_ref()], cx).await; @@ -841,3 +905,26 @@ fn init_logger() { env_logger::init(); } } + +fn watch_settings(fs: Arc, cx: &mut App) { + let fs = fs.clone(); + cx.spawn({ + async move |cx| { + let mut new_settings_content_rx = settings::watch_config_file( + cx.background_executor(), + fs, + paths::settings_file().clone(), + ); + + while let Some(new_settings_content) = new_settings_content_rx.next().await { + cx.update(|cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.set_user_settings(&new_settings_content, cx) + }) + }) + .ok(); + } + } + }) + .detach(); +} diff --git a/crates/agent2/src/tests/test_tools.rs b/crates/agent2/src/tests/test_tools.rs index d06614f3fe..7c7b81f52f 100644 --- a/crates/agent2/src/tests/test_tools.rs +++ b/crates/agent2/src/tests/test_tools.rs @@ -110,9 +110,9 @@ impl AgentTool for ToolRequiringPermission { event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { - let auth_check = event_stream.authorize("Authorize?".into()); + let authorize = event_stream.authorize("Authorize?", cx); cx.foreground_executor().spawn(async move { - auth_check.await?; + authorize.await?; Ok("Allowed".to_string()) }) } diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index dd8e5476ab..23a0f7972d 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -1,10 +1,12 @@ use crate::{SystemPromptTemplate, Template, Templates}; use action_log::ActionLog; use agent_client_protocol as acp; +use agent_settings::AgentSettings; use anyhow::{Context as _, Result, anyhow}; use assistant_tool::adapt_schema_to_format; use cloud_llm_client::{CompletionIntent, CompletionMode}; use collections::HashMap; +use fs::Fs; use futures::{ channel::{mpsc, oneshot}, stream::FuturesUnordered, @@ -21,8 +23,9 @@ use project::Project; use prompt_store::ProjectContext; use schemars::{JsonSchema, Schema}; use serde::{Deserialize, Serialize}; +use settings::{Settings, update_settings_file}; use smol::stream::StreamExt; -use std::{cell::RefCell, collections::BTreeMap, fmt::Write, future::Future, rc::Rc, sync::Arc}; +use std::{cell::RefCell, collections::BTreeMap, fmt::Write, rc::Rc, sync::Arc}; use util::{ResultExt, markdown::MarkdownCodeBlock}; #[derive(Debug, Clone)] @@ -506,8 +509,9 @@ impl Thread { })); }; + let fs = self.project.read(cx).fs().clone(); let tool_event_stream = - ToolCallEventStream::new(&tool_use, tool.kind(), event_stream.clone()); + ToolCallEventStream::new(&tool_use, tool.kind(), event_stream.clone(), Some(fs)); tool_event_stream.update_fields(acp::ToolCallUpdateFields { status: Some(acp::ToolCallStatus::InProgress), ..Default::default() @@ -884,6 +888,7 @@ pub struct ToolCallEventStream { kind: acp::ToolKind, input: serde_json::Value, stream: AgentResponseEventStream, + fs: Option>, } impl ToolCallEventStream { @@ -902,6 +907,7 @@ impl ToolCallEventStream { }, acp::ToolKind::Other, AgentResponseEventStream(events_tx), + None, ); (stream, ToolCallEventStreamReceiver(events_rx)) @@ -911,12 +917,14 @@ impl ToolCallEventStream { tool_use: &LanguageModelToolUse, kind: acp::ToolKind, stream: AgentResponseEventStream, + fs: Option>, ) -> Self { Self { tool_use_id: tool_use.id.clone(), kind, input: tool_use.input.clone(), stream, + fs, } } @@ -951,7 +959,11 @@ impl ToolCallEventStream { .ok(); } - pub fn authorize(&self, title: String) -> impl use<> + Future> { + pub fn authorize(&self, title: impl Into, cx: &mut App) -> Task> { + if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions { + return Task::ready(Ok(())); + } + let (response_tx, response_rx) = oneshot::channel(); self.stream .0 @@ -959,7 +971,7 @@ impl ToolCallEventStream { ToolCallAuthorization { tool_call: AgentResponseEventStream::initial_tool_call( &self.tool_use_id, - title, + title.into(), self.kind.clone(), self.input.clone(), ), @@ -984,12 +996,22 @@ impl ToolCallEventStream { }, ))) .ok(); - async move { - match response_rx.await?.0.as_ref() { - "allow" | "always_allow" => Ok(()), - _ => Err(anyhow!("Permission to run tool denied by user")), + let fs = self.fs.clone(); + cx.spawn(async move |cx| match response_rx.await?.0.as_ref() { + "always_allow" => { + if let Some(fs) = fs.clone() { + cx.update(|cx| { + update_settings_file::(fs, cx, |settings, _| { + settings.set_always_allow_tool_actions(true); + }); + })?; + } + + Ok(()) } - } + "allow" => Ok(()), + _ => Err(anyhow!("Permission to run tool denied by user")), + }) } } diff --git a/crates/agent2/src/tools/edit_file_tool.rs b/crates/agent2/src/tools/edit_file_tool.rs index d9a4cdf8ba..88764d1953 100644 --- a/crates/agent2/src/tools/edit_file_tool.rs +++ b/crates/agent2/src/tools/edit_file_tool.rs @@ -133,7 +133,7 @@ impl EditFileTool { &self, input: &EditFileToolInput, event_stream: &ToolCallEventStream, - cx: &App, + cx: &mut App, ) -> Task> { if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions { return Task::ready(Ok(())); @@ -147,8 +147,9 @@ impl EditFileTool { .components() .any(|component| component.as_os_str() == local_settings_folder.as_os_str()) { - return cx.foreground_executor().spawn( - event_stream.authorize(format!("{} (local settings)", input.display_description)), + return event_stream.authorize( + format!("{} (local settings)", input.display_description), + cx, ); } @@ -156,9 +157,9 @@ impl EditFileTool { // so check for that edge case too. if let Ok(canonical_path) = std::fs::canonicalize(&input.path) { if canonical_path.starts_with(paths::config_dir()) { - return cx.foreground_executor().spawn( - event_stream - .authorize(format!("{} (global settings)", input.display_description)), + return event_stream.authorize( + format!("{} (global settings)", input.display_description), + cx, ); } } @@ -173,8 +174,7 @@ impl EditFileTool { if project_path.is_some() { Task::ready(Ok(())) } else { - cx.foreground_executor() - .spawn(event_stream.authorize(input.display_description.clone())) + event_stream.authorize(&input.display_description, cx) } } } diff --git a/crates/agent2/src/tools/open_tool.rs b/crates/agent2/src/tools/open_tool.rs index 0860b62a51..36420560c1 100644 --- a/crates/agent2/src/tools/open_tool.rs +++ b/crates/agent2/src/tools/open_tool.rs @@ -65,7 +65,7 @@ impl AgentTool for OpenTool { ) -> Task> { // If path_or_url turns out to be a path in the project, make it absolute. let abs_path = to_absolute_path(&input.path_or_url, self.project.clone(), cx); - let authorize = event_stream.authorize(self.initial_title(Ok(input.clone())).to_string()); + let authorize = event_stream.authorize(self.initial_title(Ok(input.clone())), cx); cx.background_spawn(async move { authorize.await?; diff --git a/crates/agent2/src/tools/terminal_tool.rs b/crates/agent2/src/tools/terminal_tool.rs index c0b34444dd..ecb855ac34 100644 --- a/crates/agent2/src/tools/terminal_tool.rs +++ b/crates/agent2/src/tools/terminal_tool.rs @@ -5,7 +5,6 @@ use gpui::{App, AppContext, Entity, SharedString, Task}; use project::{Project, terminals::TerminalKind}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use settings::Settings; use std::{ path::{Path, PathBuf}, sync::Arc, @@ -61,21 +60,6 @@ impl TerminalTool { determine_shell: determine_shell.shared(), } } - - fn authorize( - &self, - input: &TerminalToolInput, - event_stream: &ToolCallEventStream, - cx: &App, - ) -> Task> { - if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions { - return Task::ready(Ok(())); - } - - // TODO: do we want to have a special title here? - cx.foreground_executor() - .spawn(event_stream.authorize(self.initial_title(Ok(input.clone())).to_string())) - } } impl AgentTool for TerminalTool { @@ -152,7 +136,7 @@ impl AgentTool for TerminalTool { env }); - let authorize = self.authorize(&input, &event_stream, cx); + let authorize = event_stream.authorize(self.initial_title(Ok(input.clone())), cx); cx.spawn({ async move |cx| { diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index af8fe129ab..a2b75ac6a7 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -2172,6 +2172,9 @@ impl Fs for FakeFs { async fn atomic_write(&self, path: PathBuf, data: String) -> Result<()> { self.simulate_random_delay().await; let path = normalize_path(path.as_path()); + if let Some(path) = path.parent() { + self.create_dir(path).await?; + } self.write_file_internal(path, data.into_bytes(), true)?; Ok(()) } From bb6ea2294430b96aacebb58c696d57f3e9ef8ba8 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 11 Aug 2025 19:24:48 +0200 Subject: [PATCH 065/109] agent2: Port more tools (#35987) Release Notes: - N/A --------- Co-authored-by: Ben Brandt Co-authored-by: Antonio Scandurra --- Cargo.lock | 2 + crates/action_log/src/action_log.rs | 16 -- crates/agent2/Cargo.toml | 2 + crates/agent2/src/agent.rs | 8 +- crates/agent2/src/tools.rs | 4 + crates/agent2/src/tools/diagnostics_tool.rs | 177 ++++++++++++++++++ crates/agent2/src/tools/fetch_tool.rs | 161 ++++++++++++++++ .../assistant_tools/src/diagnostics_tool.rs | 6 +- 8 files changed, 352 insertions(+), 24 deletions(-) create mode 100644 crates/agent2/src/tools/diagnostics_tool.rs create mode 100644 crates/agent2/src/tools/fetch_tool.rs diff --git a/Cargo.lock b/Cargo.lock index 7b5e82a312..8a3e319a57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -204,6 +204,8 @@ dependencies = [ "gpui", "gpui_tokio", "handlebars 4.5.0", + "html_to_markdown", + "http_client", "indoc", "itertools 0.14.0", "language", diff --git a/crates/action_log/src/action_log.rs b/crates/action_log/src/action_log.rs index 025aba060d..c4eaffc228 100644 --- a/crates/action_log/src/action_log.rs +++ b/crates/action_log/src/action_log.rs @@ -17,8 +17,6 @@ use util::{ pub struct ActionLog { /// Buffers that we want to notify the model about when they change. tracked_buffers: BTreeMap, TrackedBuffer>, - /// Has the model edited a file since it last checked diagnostics? - edited_since_project_diagnostics_check: bool, /// The project this action log is associated with project: Entity, } @@ -28,7 +26,6 @@ impl ActionLog { pub fn new(project: Entity) -> Self { Self { tracked_buffers: BTreeMap::default(), - edited_since_project_diagnostics_check: false, project, } } @@ -37,16 +34,6 @@ impl ActionLog { &self.project } - /// Notifies a diagnostics check - pub fn checked_project_diagnostics(&mut self) { - self.edited_since_project_diagnostics_check = false; - } - - /// Returns true if any files have been edited since the last project diagnostics check - pub fn has_edited_files_since_project_diagnostics_check(&self) -> bool { - self.edited_since_project_diagnostics_check - } - pub fn latest_snapshot(&self, buffer: &Entity) -> Option { Some(self.tracked_buffers.get(buffer)?.snapshot.clone()) } @@ -543,14 +530,11 @@ impl ActionLog { /// Mark a buffer as created by agent, so we can refresh it in the context pub fn buffer_created(&mut self, buffer: Entity, cx: &mut Context) { - self.edited_since_project_diagnostics_check = true; self.track_buffer_internal(buffer.clone(), true, cx); } /// Mark a buffer as edited by agent, so we can refresh it in the context pub fn buffer_edited(&mut self, buffer: Entity, cx: &mut Context) { - self.edited_since_project_diagnostics_check = true; - let tracked_buffer = self.track_buffer_internal(buffer.clone(), false, cx); if let TrackedBufferStatus::Deleted = tracked_buffer.status { tracked_buffer.status = TrackedBufferStatus::Modified; diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml index 622b08016a..7ee48aca04 100644 --- a/crates/agent2/Cargo.toml +++ b/crates/agent2/Cargo.toml @@ -27,6 +27,8 @@ fs.workspace = true futures.workspace = true gpui.workspace = true handlebars = { workspace = true, features = ["rust-embed"] } +html_to_markdown.workspace = true +http_client.workspace = true indoc.workspace = true itertools.workspace = true language.workspace = true diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index b1cefd2864..66893f49f9 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -1,8 +1,8 @@ use crate::{AgentResponseEvent, Thread, templates::Templates}; use crate::{ - CopyPathTool, CreateDirectoryTool, EditFileTool, FindPathTool, GrepTool, ListDirectoryTool, - MovePathTool, NowTool, OpenTool, ReadFileTool, TerminalTool, ThinkingTool, - ToolCallAuthorization, WebSearchTool, + CopyPathTool, CreateDirectoryTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, + GrepTool, ListDirectoryTool, MovePathTool, NowTool, OpenTool, ReadFileTool, TerminalTool, + ThinkingTool, ToolCallAuthorization, WebSearchTool, }; use acp_thread::ModelSelector; use agent_client_protocol as acp; @@ -420,11 +420,13 @@ impl acp_thread::AgentConnection for NativeAgentConnection { let mut thread = Thread::new(project.clone(), agent.project_context.clone(), action_log.clone(), agent.templates.clone(), default_model); thread.add_tool(CreateDirectoryTool::new(project.clone())); thread.add_tool(CopyPathTool::new(project.clone())); + thread.add_tool(DiagnosticsTool::new(project.clone())); thread.add_tool(MovePathTool::new(project.clone())); thread.add_tool(ListDirectoryTool::new(project.clone())); thread.add_tool(OpenTool::new(project.clone())); thread.add_tool(ThinkingTool); thread.add_tool(FindPathTool::new(project.clone())); + thread.add_tool(FetchTool::new(project.read(cx).client().http_client())); thread.add_tool(GrepTool::new(project.clone())); thread.add_tool(ReadFileTool::new(project.clone(), action_log)); thread.add_tool(EditFileTool::new(cx.entity())); diff --git a/crates/agent2/src/tools.rs b/crates/agent2/src/tools.rs index 29ba6780b8..8896b14538 100644 --- a/crates/agent2/src/tools.rs +++ b/crates/agent2/src/tools.rs @@ -1,7 +1,9 @@ mod copy_path_tool; mod create_directory_tool; mod delete_path_tool; +mod diagnostics_tool; mod edit_file_tool; +mod fetch_tool; mod find_path_tool; mod grep_tool; mod list_directory_tool; @@ -16,7 +18,9 @@ mod web_search_tool; pub use copy_path_tool::*; pub use create_directory_tool::*; pub use delete_path_tool::*; +pub use diagnostics_tool::*; pub use edit_file_tool::*; +pub use fetch_tool::*; pub use find_path_tool::*; pub use grep_tool::*; pub use list_directory_tool::*; diff --git a/crates/agent2/src/tools/diagnostics_tool.rs b/crates/agent2/src/tools/diagnostics_tool.rs new file mode 100644 index 0000000000..bd0b20df5a --- /dev/null +++ b/crates/agent2/src/tools/diagnostics_tool.rs @@ -0,0 +1,177 @@ +use crate::{AgentTool, ToolCallEventStream}; +use agent_client_protocol as acp; +use anyhow::{Result, anyhow}; +use gpui::{App, Entity, Task}; +use language::{DiagnosticSeverity, OffsetRangeExt}; +use project::Project; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::{fmt::Write, path::Path, sync::Arc}; +use ui::SharedString; +use util::markdown::MarkdownInlineCode; + +/// Get errors and warnings for the project or a specific file. +/// +/// This tool can be invoked after a series of edits to determine if further edits are necessary, or if the user asks to fix errors or warnings in their codebase. +/// +/// When a path is provided, shows all diagnostics for that specific file. +/// When no path is provided, shows a summary of error and warning counts for all files in the project. +/// +/// +/// To get diagnostics for a specific file: +/// { +/// "path": "src/main.rs" +/// } +/// +/// To get a project-wide diagnostic summary: +/// {} +/// +/// +/// +/// - If you think you can fix a diagnostic, make 1-2 attempts and then give up. +/// - Don't remove code you've generated just because you can't fix an error. The user can help you fix it. +/// +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct DiagnosticsToolInput { + /// The path to get diagnostics for. If not provided, returns a project-wide summary. + /// + /// 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: + /// + /// - lorem + /// - ipsum + /// + /// If you wanna access diagnostics for `dolor.txt` in `ipsum`, you should use the path `ipsum/dolor.txt`. + /// + pub path: Option, +} + +pub struct DiagnosticsTool { + project: Entity, +} + +impl DiagnosticsTool { + pub fn new(project: Entity) -> Self { + Self { project } + } +} + +impl AgentTool for DiagnosticsTool { + type Input = DiagnosticsToolInput; + type Output = String; + + fn name(&self) -> SharedString { + "diagnostics".into() + } + + fn kind(&self) -> acp::ToolKind { + acp::ToolKind::Read + } + + fn initial_title(&self, input: Result) -> SharedString { + if let Some(path) = input.ok().and_then(|input| match input.path { + Some(path) if !path.is_empty() => Some(path), + _ => None, + }) { + format!("Check diagnostics for {}", MarkdownInlineCode(&path)).into() + } else { + "Check project diagnostics".into() + } + } + + fn run( + self: Arc, + input: Self::Input, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + match input.path { + Some(path) if !path.is_empty() => { + let Some(project_path) = self.project.read(cx).find_project_path(&path, cx) else { + return Task::ready(Err(anyhow!("Could not find path {path} in project",))); + }; + + let buffer = self + .project + .update(cx, |project, cx| project.open_buffer(project_path, cx)); + + cx.spawn(async move |cx| { + let mut output = String::new(); + let buffer = buffer.await?; + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; + + for (_, group) in snapshot.diagnostic_groups(None) { + let entry = &group.entries[group.primary_ix]; + let range = entry.range.to_point(&snapshot); + let severity = match entry.diagnostic.severity { + DiagnosticSeverity::ERROR => "error", + DiagnosticSeverity::WARNING => "warning", + _ => continue, + }; + + writeln!( + output, + "{} at line {}: {}", + severity, + range.start.row + 1, + entry.diagnostic.message + )?; + + event_stream.update_fields(acp::ToolCallUpdateFields { + content: Some(vec![output.clone().into()]), + ..Default::default() + }); + } + + if output.is_empty() { + Ok("File doesn't have errors or warnings!".to_string()) + } else { + Ok(output) + } + }) + } + _ => { + let project = self.project.read(cx); + let mut output = String::new(); + let mut has_diagnostics = false; + + for (project_path, _, summary) in project.diagnostic_summaries(true, cx) { + if summary.error_count > 0 || summary.warning_count > 0 { + let Some(worktree) = project.worktree_for_id(project_path.worktree_id, cx) + else { + continue; + }; + + has_diagnostics = true; + output.push_str(&format!( + "{}: {} error(s), {} warning(s)\n", + Path::new(worktree.read(cx).root_name()) + .join(project_path.path) + .display(), + summary.error_count, + summary.warning_count + )); + } + } + + if has_diagnostics { + event_stream.update_fields(acp::ToolCallUpdateFields { + content: Some(vec![output.clone().into()]), + ..Default::default() + }); + Task::ready(Ok(output)) + } else { + let text = "No errors or warnings found in the project."; + event_stream.update_fields(acp::ToolCallUpdateFields { + content: Some(vec![text.into()]), + ..Default::default() + }); + Task::ready(Ok(text.into())) + } + } + } + } +} diff --git a/crates/agent2/src/tools/fetch_tool.rs b/crates/agent2/src/tools/fetch_tool.rs new file mode 100644 index 0000000000..7f3752843c --- /dev/null +++ b/crates/agent2/src/tools/fetch_tool.rs @@ -0,0 +1,161 @@ +use std::rc::Rc; +use std::sync::Arc; +use std::{borrow::Cow, cell::RefCell}; + +use agent_client_protocol as acp; +use anyhow::{Context as _, Result, bail}; +use futures::AsyncReadExt as _; +use gpui::{App, AppContext as _, Task}; +use html_to_markdown::{TagHandler, convert_html_to_markdown, markdown}; +use http_client::{AsyncBody, HttpClientWithUrl}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ui::SharedString; +use util::markdown::MarkdownEscaped; + +use crate::{AgentTool, ToolCallEventStream}; + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] +enum ContentType { + Html, + Plaintext, + Json, +} + +/// Fetches a URL and returns the content as Markdown. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct FetchToolInput { + /// The URL to fetch. + url: String, +} + +pub struct FetchTool { + http_client: Arc, +} + +impl FetchTool { + pub fn new(http_client: Arc) -> Self { + Self { http_client } + } + + async fn build_message(http_client: Arc, url: &str) -> Result { + let url = if !url.starts_with("https://") && !url.starts_with("http://") { + Cow::Owned(format!("https://{url}")) + } else { + Cow::Borrowed(url) + }; + + let mut response = http_client.get(&url, AsyncBody::default(), true).await?; + + let mut body = Vec::new(); + response + .body_mut() + .read_to_end(&mut body) + .await + .context("error reading response body")?; + + if response.status().is_client_error() { + let text = String::from_utf8_lossy(body.as_slice()); + bail!( + "status error {}, response: {text:?}", + response.status().as_u16() + ); + } + + let Some(content_type) = response.headers().get("content-type") else { + bail!("missing Content-Type header"); + }; + let content_type = content_type + .to_str() + .context("invalid Content-Type header")?; + + let content_type = if content_type.starts_with("text/plain") { + ContentType::Plaintext + } else if content_type.starts_with("application/json") { + ContentType::Json + } else { + ContentType::Html + }; + + match content_type { + ContentType::Html => { + let mut handlers: Vec = vec![ + Rc::new(RefCell::new(markdown::WebpageChromeRemover)), + Rc::new(RefCell::new(markdown::ParagraphHandler)), + Rc::new(RefCell::new(markdown::HeadingHandler)), + Rc::new(RefCell::new(markdown::ListHandler)), + Rc::new(RefCell::new(markdown::TableHandler::new())), + Rc::new(RefCell::new(markdown::StyledTextHandler)), + ]; + if url.contains("wikipedia.org") { + use html_to_markdown::structure::wikipedia; + + handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover))); + handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaInfoboxHandler))); + handlers.push(Rc::new( + RefCell::new(wikipedia::WikipediaCodeHandler::new()), + )); + } else { + handlers.push(Rc::new(RefCell::new(markdown::CodeHandler))); + } + + convert_html_to_markdown(&body[..], &mut handlers) + } + ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()), + ContentType::Json => { + let json: serde_json::Value = serde_json::from_slice(&body)?; + + Ok(format!( + "```json\n{}\n```", + serde_json::to_string_pretty(&json)? + )) + } + } + } +} + +impl AgentTool for FetchTool { + type Input = FetchToolInput; + type Output = String; + + fn name(&self) -> SharedString { + "fetch".into() + } + + fn kind(&self) -> acp::ToolKind { + acp::ToolKind::Fetch + } + + fn initial_title(&self, input: Result) -> SharedString { + match input { + Ok(input) => format!("Fetch {}", MarkdownEscaped(&input.url)).into(), + Err(_) => "Fetch URL".into(), + } + } + + fn run( + self: Arc, + input: Self::Input, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let text = cx.background_spawn({ + let http_client = self.http_client.clone(); + async move { Self::build_message(http_client, &input.url).await } + }); + + cx.foreground_executor().spawn(async move { + let text = text.await?; + if text.trim().is_empty() { + bail!("no textual content found"); + } + + event_stream.update_fields(acp::ToolCallUpdateFields { + content: Some(vec![text.clone().into()]), + ..Default::default() + }); + + Ok(text) + }) + } +} diff --git a/crates/assistant_tools/src/diagnostics_tool.rs b/crates/assistant_tools/src/diagnostics_tool.rs index bc479eb596..4ec794e127 100644 --- a/crates/assistant_tools/src/diagnostics_tool.rs +++ b/crates/assistant_tools/src/diagnostics_tool.rs @@ -86,7 +86,7 @@ impl Tool for DiagnosticsTool { input: serde_json::Value, _request: Arc, project: Entity, - action_log: Entity, + _action_log: Entity, _model: Arc, _window: Option, cx: &mut App, @@ -159,10 +159,6 @@ impl Tool for DiagnosticsTool { } } - action_log.update(cx, |action_log, _cx| { - action_log.checked_project_diagnostics(); - }); - if has_diagnostics { Task::ready(Ok(output.into())).into() } else { From 2c84e33b7b7a6e5338221f8bf1d5b60365060566 Mon Sep 17 00:00:00 2001 From: localcc Date: Mon, 11 Aug 2025 19:57:39 +0200 Subject: [PATCH 066/109] Fix icon padding (#35990) Release Notes: - N/A --- .../zed/resources/windows/app-icon-nightly.ico | Bin 149224 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/crates/zed/resources/windows/app-icon-nightly.ico b/crates/zed/resources/windows/app-icon-nightly.ico index 165e4ce1f7ccbff084fbbf8131fb3c8e27853f4b..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 GIT binary patch literal 0 HcmV?d00001 literal 149224 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

x8_y)-@rqrF+7QAFlcfxBWFdqUzM6QMXRK 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ΠKz3mwEJf{tphjPapdaT&>C8mv6bhe|Oc#pZPbR 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�^VKc%wt|I~_- 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

Fe+Ermhr^929fhzR|+5E4MapgJBDS9Zbz|7}5Q zi9z$$pfwGkwF`WF|LwSW|2XjQZgA)0S9Ac4U2-GR9>{JGwiX24y~oL5#m@yA59hbx z=ZUrA)@-c`8VRLTIv!+}eXN|Zx&Kh%b zoHgU(IAhJtal(q5bFVEo&jJ@-{#X|tK7Kboeg+pleg-=p?CX_4MWe69TZ^O+cV8X>IV9L!YV9vuS zV8+eKZ^OgM>&VN^=_km`=*A}i9zO@&#|*lw5#(Qd*cN=BAt(4AL(n~iAPkx-0AcVL zFAo>!&TQ~Kg`ja^(A|KbHFzf6oD62*y9GHIY`8hW{BKfu7i48jcz42-nEAhSUjWEaRzklmnh3IPTN@JW{NaS9g9aSBks7}O_M zVi#bLW9Mg(WaDNKW8-8HVP$6!W(D=9*cgOaSs6rFSsBFHSQw;XeQxM^S(5Caa}szM zRM-T$<=FY$CE0nh#M!tCMAEZt0N?93vZUpGu z81NY(?CcCe94w$dPJtvJXpb-Ve?wW>{|O!*|HJGZ{s-GT{EzhT_#f-%_dn3l;eV)u zJ-E-J2HFSA!Cn9wPXX;8mf&P9l;a1Tlfd=g)kyEZv$ppCG;i*|CwP1R z54N}a?`>@S-+&Kvb_36UWe$!)MGkfb=>E$BRRPHUvOrtQ{~@+k|NV`P!R?ux;NbtB zI@;j<1uh!u;C-*4_L>GKM}ZnA2ZJ&fJA*1Wdx4H1Xm2d{|0pNB|H-~y|Dzlo{+GqX z{;!CN{O_u)^xsNa;=i4Y^nWXU9`IUK9WKrSElv&wO>TAuZ61z7Q2h>SS0#D4{Lc;w z_@5UN@;}4V>wkf-&;QEMp#QE4vi~hXx97YBnb4+n!jFGqo`sNjEF zLH_^Qem?&*ygmOH1PA@k_xJsu>*4;t$jjq@MUdZr4_PVjdTr2Jc4Ka?0s}5iQ2UO- zn2$3Hw1){)p9X1b{m=9F`=90M@jucemu>V6Yb8 zVzB1t;I`oBo^C55@WWn2@V~9F;D39NIw3*u8c26=y(RSDRY>5!lc3-aJ6_)DHr(vo zHawtufs?_KkCVZQmy6qim)qT(mpjXhm%G4}hr7^(hpWJhhpWJphpW((hqJ(%hbzmD zo5$Uio1fbjJO{?XV8P1;nrjDR(EOMw4>yA`4;O Date: Mon, 11 Aug 2025 15:25:18 -0400 Subject: [PATCH 067/109] Add windows issue template (#35998) Release Notes: - N/A --- .../ISSUE_TEMPLATE/07_bug_windows_alpha.yml | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/07_bug_windows_alpha.yml diff --git a/.github/ISSUE_TEMPLATE/07_bug_windows_alpha.yml b/.github/ISSUE_TEMPLATE/07_bug_windows_alpha.yml new file mode 100644 index 0000000000..bf39560a3c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/07_bug_windows_alpha.yml @@ -0,0 +1,35 @@ +name: Bug Report (Windows) +description: Zed Windows-Related Bugs +type: "Bug" +labels: ["windows"] +title: "Windows: " +body: + - type: textarea + attributes: + label: Summary + description: Describe the bug with a one line summary, and provide detailed reproduction steps + value: | + + SUMMARY_SENTENCE_HERE + + ### Description + + Steps to trigger the problem: + 1. + 2. + 3. + + **Expected Behavior**: + **Actual Behavior**: + + validations: + required: true + - type: textarea + id: environment + attributes: + label: Zed Version and System Specs + description: 'Open Zed, and in the command palette select "zed: copy system specs into clipboard"' + placeholder: | + Output of "zed: copy system specs into clipboard" + validations: + required: true From 094e878ccf639968c525294cc02f207061af88c5 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 11 Aug 2025 17:50:47 -0300 Subject: [PATCH 068/109] agent2: Refine terminal tool call display (#35984) Release Notes: - N/A --- crates/acp_thread/src/terminal.rs | 10 +- crates/agent_ui/src/acp/thread_view.rs | 339 ++++++++++++++++++++++--- 2 files changed, 309 insertions(+), 40 deletions(-) diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index b800873737..41d7fb89bb 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/crates/acp_thread/src/terminal.rs @@ -29,8 +29,14 @@ impl Terminal { cx: &mut Context, ) -> Self { Self { - command: cx - .new(|cx| Markdown::new(command.into(), Some(language_registry.clone()), None, cx)), + command: cx.new(|cx| { + Markdown::new( + format!("```\n{}\n```", command).into(), + Some(language_registry.clone()), + None, + cx, + ) + }), working_dir, terminal, started_at: Instant::now(), diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 32f9948d97..f37deac26e 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -38,7 +38,7 @@ use theme::ThemeSettings; use ui::{ Disclosure, Divider, DividerColor, KeyBinding, Scrollbar, ScrollbarState, Tooltip, prelude::*, }; -use util::ResultExt; +use util::{ResultExt, size::format_file_size, time::duration_alt_display}; use workspace::{CollaboratorId, Workspace}; use zed_actions::agent::{Chat, NextHistoryMessage, PreviousHistoryMessage}; @@ -75,6 +75,7 @@ pub struct AcpThreadView { edits_expanded: bool, plan_expanded: bool, editor_expanded: bool, + terminal_expanded: bool, message_history: Rc>>>, _cancel_task: Option>, _subscriptions: [Subscription; 1], @@ -200,6 +201,7 @@ impl AcpThreadView { edits_expanded: false, plan_expanded: false, editor_expanded: false, + terminal_expanded: true, message_history, _subscriptions: [subscription], _cancel_task: None, @@ -768,7 +770,7 @@ impl AcpThreadView { window, cx, ); - view.set_embedded_mode(None, cx); + view.set_embedded_mode(Some(1000), cx); view }); @@ -914,17 +916,26 @@ impl AcpThreadView { .child(message_body) .into_any() } - AgentThreadEntry::ToolCall(tool_call) => div() - .w_full() - .py_1p5() - .px_5() - .child(self.render_tool_call(index, tool_call, window, cx)) - .into_any(), + AgentThreadEntry::ToolCall(tool_call) => { + let has_terminals = tool_call.terminals().next().is_some(); + + div().w_full().py_1p5().px_5().map(|this| { + if has_terminals { + this.children(tool_call.terminals().map(|terminal| { + self.render_terminal_tool_call(terminal, tool_call, window, cx) + })) + } else { + this.child(self.render_tool_call(index, tool_call, window, cx)) + } + }) + } + .into_any(), }; let Some(thread) = self.thread() else { return primary; }; + let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); if index == total_entries - 1 && !is_generating { v_flex() @@ -1173,8 +1184,7 @@ impl AcpThreadView { || has_nonempty_diff || self.expanded_tool_calls.contains(&tool_call.id); - let gradient_color = cx.theme().colors().panel_background; - let gradient_overlay = { + let gradient_overlay = |color: Hsla| { div() .absolute() .top_0() @@ -1183,8 +1193,8 @@ impl AcpThreadView { .h_full() .bg(linear_gradient( 90., - linear_color_stop(gradient_color, 1.), - linear_color_stop(gradient_color.opacity(0.2), 0.), + linear_color_stop(color, 1.), + linear_color_stop(color.opacity(0.2), 0.), )) }; @@ -1286,7 +1296,17 @@ impl AcpThreadView { ), )), ) - .child(gradient_overlay) + .map(|this| { + if needs_confirmation { + this.child(gradient_overlay( + self.tool_card_header_bg(cx), + )) + } else { + this.child(gradient_overlay( + cx.theme().colors().panel_background, + )) + } + }) .on_click(cx.listener({ let id = tool_call.id.clone(); move |this: &mut Self, _, _, cx: &mut Context| { @@ -1321,11 +1341,9 @@ impl AcpThreadView { .children(tool_call.content.iter().map(|content| { div() .py_1p5() - .child( - self.render_tool_call_content( - content, window, cx, - ), - ) + .child(self.render_tool_call_content( + content, tool_call, window, cx, + )) .into_any_element() })) .child(self.render_permission_buttons( @@ -1339,11 +1357,9 @@ impl AcpThreadView { this.children(tool_call.content.iter().map(|content| { div() .py_1p5() - .child( - self.render_tool_call_content( - content, window, cx, - ), - ) + .child(self.render_tool_call_content( + content, tool_call, window, cx, + )) .into_any_element() })) } @@ -1360,6 +1376,7 @@ impl AcpThreadView { fn render_tool_call_content( &self, content: &ToolCallContent, + tool_call: &ToolCall, window: &Window, cx: &Context, ) -> AnyElement { @@ -1380,7 +1397,9 @@ impl AcpThreadView { } } ToolCallContent::Diff(diff) => self.render_diff_editor(&diff.read(cx).multibuffer()), - ToolCallContent::Terminal(terminal) => self.render_terminal(terminal), + ToolCallContent::Terminal(terminal) => { + self.render_terminal_tool_call(terminal, tool_call, window, cx) + } } } @@ -1393,14 +1412,22 @@ impl AcpThreadView { cx: &Context, ) -> Div { h_flex() - .p_1p5() + .py_1() + .pl_2() + .pr_1() .gap_1() - .justify_end() + .justify_between() + .flex_wrap() .when(!empty_content, |this| { this.border_t_1() .border_color(self.tool_card_border_color(cx)) }) - .children(options.iter().map(|option| { + .child( + div() + .min_w(rems_from_px(145.)) + .child(LoadingLabel::new("Waiting for Confirmation").size(LabelSize::Small)), + ) + .child(h_flex().gap_0p5().children(options.iter().map(|option| { let option_id = SharedString::from(option.id.0.clone()); Button::new((option_id, entry_ix), option.name.clone()) .map(|this| match option.kind { @@ -1433,7 +1460,7 @@ impl AcpThreadView { ); } })) - })) + }))) } fn render_diff_editor(&self, multibuffer: &Entity) -> AnyElement { @@ -1449,18 +1476,242 @@ impl AcpThreadView { .into_any() } - fn render_terminal(&self, terminal: &Entity) -> AnyElement { - v_flex() - .h_72() + fn render_terminal_tool_call( + &self, + terminal: &Entity, + tool_call: &ToolCall, + window: &Window, + cx: &Context, + ) -> AnyElement { + let terminal_data = terminal.read(cx); + let working_dir = terminal_data.working_dir(); + let command = terminal_data.command(); + let started_at = terminal_data.started_at(); + + let tool_failed = matches!( + &tool_call.status, + ToolCallStatus::Rejected + | ToolCallStatus::Canceled + | ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Failed, + .. + } + ); + + let output = terminal_data.output(); + let command_finished = output.is_some(); + let truncated_output = output.is_some_and(|output| output.was_content_truncated); + let output_line_count = output.map(|output| output.content_line_count).unwrap_or(0); + + let command_failed = command_finished + && output.is_some_and(|o| o.exit_status.is_none_or(|status| !status.success())); + + let time_elapsed = if let Some(output) = output { + output.ended_at.duration_since(started_at) + } else { + started_at.elapsed() + }; + + let header_bg = cx + .theme() + .colors() + .element_background + .blend(cx.theme().colors().editor_foreground.opacity(0.025)); + let border_color = cx.theme().colors().border.opacity(0.6); + + let working_dir = working_dir + .as_ref() + .map(|path| format!("{}", path.display())) + .unwrap_or_else(|| "current directory".to_string()); + + let header = h_flex() + .id(SharedString::from(format!( + "terminal-tool-header-{}", + terminal.entity_id() + ))) + .flex_none() + .gap_1() + .justify_between() + .rounded_t_md() .child( - if let Some(terminal_view) = self.terminal_views.get(&terminal.entity_id()) { - // TODO: terminal has all the state we need to reproduce - // what we had in the terminal card. - terminal_view.clone().into_any_element() - } else { - Empty.into_any() - }, + div() + .id(("command-target-path", terminal.entity_id())) + .w_full() + .max_w_full() + .overflow_x_scroll() + .child( + Label::new(working_dir) + .buffer_font(cx) + .size(LabelSize::XSmall) + .color(Color::Muted), + ), ) + .when(!command_finished, |header| { + header + .gap_1p5() + .child( + Button::new( + SharedString::from(format!("stop-terminal-{}", terminal.entity_id())), + "Stop", + ) + .icon(IconName::Stop) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Error) + .label_size(LabelSize::Small) + .tooltip(move |window, cx| { + Tooltip::with_meta( + "Stop This Command", + None, + "Also possible by placing your cursor inside the terminal and using regular terminal bindings.", + window, + cx, + ) + }) + .on_click({ + let terminal = terminal.clone(); + cx.listener(move |_this, _event, _window, cx| { + let inner_terminal = terminal.read(cx).inner().clone(); + inner_terminal.update(cx, |inner_terminal, _cx| { + inner_terminal.kill_active_task(); + }); + }) + }), + ) + .child(Divider::vertical()) + .child( + Icon::new(IconName::ArrowCircle) + .size(IconSize::XSmall) + .color(Color::Info) + .with_animation( + "arrow-circle", + Animation::new(Duration::from_secs(2)).repeat(), + |icon, delta| { + icon.transform(Transformation::rotate(percentage(delta))) + }, + ), + ) + }) + .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 { + "Output exceeded terminal max lines and was \ + truncated, the model received the first 16 KB." + .to_string() + } else { + format!( + "Output is {} long—to avoid unexpected token usage, \ + only 16 KB was sent back to the model.", + format_file_size(output.original_content_len as u64, true), + ) + } + } else { + "Output was truncated".to_string() + }; + + header.child( + h_flex() + .id(("terminal-tool-truncated-label", terminal.entity_id())) + .gap_1() + .child( + Icon::new(IconName::Info) + .size(IconSize::XSmall) + .color(Color::Ignored), + ) + .child( + Label::new("Truncated") + .color(Color::Muted) + .size(LabelSize::XSmall), + ) + .tooltip(Tooltip::text(tooltip)), + ) + }) + .when(time_elapsed > Duration::from_secs(10), |header| { + header.child( + Label::new(format!("({})", duration_alt_display(time_elapsed))) + .buffer_font(cx) + .color(Color::Muted) + .size(LabelSize::XSmall), + ) + }) + .child( + Disclosure::new( + SharedString::from(format!( + "terminal-tool-disclosure-{}", + terminal.entity_id() + )), + self.terminal_expanded, + ) + .opened_icon(IconName::ChevronUp) + .closed_icon(IconName::ChevronDown) + .on_click(cx.listener(move |this, _event, _window, _cx| { + this.terminal_expanded = !this.terminal_expanded; + })), + ); + + let show_output = + self.terminal_expanded && self.terminal_views.contains_key(&terminal.entity_id()); + + v_flex() + .mb_2() + .border_1() + .when(tool_failed || command_failed, |card| card.border_dashed()) + .border_color(border_color) + .rounded_lg() + .overflow_hidden() + .child( + v_flex() + .p_2() + .gap_0p5() + .bg(header_bg) + .text_xs() + .child(header) + .child( + MarkdownElement::new( + command.clone(), + terminal_command_markdown_style(window, cx), + ) + .code_block_renderer( + markdown::CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: true, + border: false, + }, + ), + ), + ) + .when(show_output, |this| { + let terminal_view = self.terminal_views.get(&terminal.entity_id()).unwrap(); + + this.child( + div() + .pt_2() + .border_t_1() + .when(tool_failed || command_failed, |card| card.border_dashed()) + .border_color(border_color) + .bg(cx.theme().colors().editor_background) + .rounded_b_md() + .text_ui_sm(cx) + .child(terminal_view.clone()), + ) + }) .into_any() } @@ -3030,6 +3281,18 @@ fn diff_editor_text_style_refinement(cx: &mut App) -> TextStyleRefinement { } } +fn terminal_command_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { + let default_md_style = default_markdown_style(true, window, cx); + + MarkdownStyle { + base_text_style: TextStyle { + ..default_md_style.base_text_style + }, + selection_background_color: cx.theme().colors().element_selection_background, + ..Default::default() + } +} + #[cfg(test)] mod tests { use agent_client_protocol::SessionId; From fa3d0aaed444027387c3021c9cd4022910cb0638 Mon Sep 17 00:00:00 2001 From: Victor Tran Date: Tue, 12 Aug 2025 07:10:14 +1000 Subject: [PATCH 069/109] gpui: Allow selection of "Services" menu independent of menu title (#34115) Release Notes: - N/A --- In the same vein as #29538, the "Services" menu on macOS depended on the text being exactly "Services", not allowing for i18n of the menu name. This PR introduces a new menu type called `OsMenu` that defines a special menu that can be populated by the system. Currently, it takes one enum value, `ServicesMenu` that tells the system to populate its contents with the items it would usually populate the "Services" menu with. An example of this being used has been implemented in the `set_menus` example: `cargo run -p gpui --example set_menus` --- Point to consider: In `mac/platform.rs:414` the existing code for setting the "Services" menu remains for backwards compatibility. Should this remain now that this new method exists to set the menu, or should it be removed? --------- Co-authored-by: Mikayla Maki --- crates/gpui/examples/set_menus.rs | 9 +++- crates/gpui/src/platform/app_menu.rs | 56 ++++++++++++++++++++++++ crates/gpui/src/platform/mac/platform.rs | 23 +++++++--- crates/title_bar/src/application_menu.rs | 8 ++++ crates/zed/src/zed/app_menus.rs | 5 +-- 5 files changed, 89 insertions(+), 12 deletions(-) diff --git a/crates/gpui/examples/set_menus.rs b/crates/gpui/examples/set_menus.rs index f53fff7c7f..8a97a8d8a2 100644 --- a/crates/gpui/examples/set_menus.rs +++ b/crates/gpui/examples/set_menus.rs @@ -1,5 +1,6 @@ use gpui::{ - App, Application, Context, Menu, MenuItem, Window, WindowOptions, actions, div, prelude::*, rgb, + App, Application, Context, Menu, MenuItem, SystemMenuType, Window, WindowOptions, actions, div, + prelude::*, rgb, }; struct SetMenus; @@ -27,7 +28,11 @@ fn main() { // Add menu items cx.set_menus(vec![Menu { name: "set_menus".into(), - items: vec![MenuItem::action("Quit", Quit)], + items: vec![ + MenuItem::os_submenu("Services", SystemMenuType::Services), + MenuItem::separator(), + MenuItem::action("Quit", Quit), + ], }]); cx.open_window(WindowOptions::default(), |_, cx| cx.new(|_| SetMenus {})) .unwrap(); diff --git a/crates/gpui/src/platform/app_menu.rs b/crates/gpui/src/platform/app_menu.rs index 2815cbdd7f..4069fee726 100644 --- a/crates/gpui/src/platform/app_menu.rs +++ b/crates/gpui/src/platform/app_menu.rs @@ -20,6 +20,34 @@ impl Menu { } } +/// OS menus are menus that are recognized by the operating system +/// This allows the operating system to provide specialized items for +/// these menus +pub struct OsMenu { + /// The name of the menu + pub name: SharedString, + + /// The type of menu + pub menu_type: SystemMenuType, +} + +impl OsMenu { + /// Create an OwnedOsMenu from this OsMenu + pub fn owned(self) -> OwnedOsMenu { + OwnedOsMenu { + name: self.name.to_string().into(), + menu_type: self.menu_type, + } + } +} + +/// The type of system menu +#[derive(Copy, Clone, Eq, PartialEq)] +pub enum SystemMenuType { + /// The 'Services' menu in the Application menu on macOS + Services, +} + /// The different kinds of items that can be in a menu pub enum MenuItem { /// A separator between items @@ -28,6 +56,9 @@ pub enum MenuItem { /// A submenu Submenu(Menu), + /// A menu, managed by the system (for example, the Services menu on macOS) + SystemMenu(OsMenu), + /// An action that can be performed Action { /// The name of this menu item @@ -53,6 +84,14 @@ impl MenuItem { Self::Submenu(menu) } + /// Creates a new submenu that is populated by the OS + pub fn os_submenu(name: impl Into, menu_type: SystemMenuType) -> Self { + Self::SystemMenu(OsMenu { + name: name.into(), + menu_type, + }) + } + /// Creates a new menu item that invokes an action pub fn action(name: impl Into, action: impl Action) -> Self { Self::Action { @@ -89,10 +128,23 @@ impl MenuItem { action, os_action, }, + MenuItem::SystemMenu(os_menu) => OwnedMenuItem::SystemMenu(os_menu.owned()), } } } +/// OS menus are menus that are recognized by the operating system +/// This allows the operating system to provide specialized items for +/// these menus +#[derive(Clone)] +pub struct OwnedOsMenu { + /// The name of the menu + pub name: SharedString, + + /// The type of menu + pub menu_type: SystemMenuType, +} + /// A menu of the application, either a main menu or a submenu #[derive(Clone)] pub struct OwnedMenu { @@ -111,6 +163,9 @@ pub enum OwnedMenuItem { /// A submenu Submenu(OwnedMenu), + /// A menu, managed by the system (for example, the Services menu on macOS) + SystemMenu(OwnedOsMenu), + /// An action that can be performed Action { /// The name of this menu item @@ -139,6 +194,7 @@ impl Clone for OwnedMenuItem { action: action.boxed_clone(), os_action: *os_action, }, + OwnedMenuItem::SystemMenu(os_menu) => OwnedMenuItem::SystemMenu(os_menu.clone()), } } } diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index c71eb448c4..c573131799 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -7,9 +7,9 @@ use super::{ use crate::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardString, CursorStyle, ForegroundExecutor, Image, ImageFormat, KeyContext, Keymap, MacDispatcher, - MacDisplay, MacWindow, Menu, MenuItem, OwnedMenu, PathPromptOptions, Platform, PlatformDisplay, - PlatformKeyboardLayout, PlatformTextSystem, PlatformWindow, Result, SemanticVersion, Task, - WindowAppearance, WindowParams, hash, + MacDisplay, MacWindow, Menu, MenuItem, OsMenu, OwnedMenu, PathPromptOptions, Platform, + PlatformDisplay, PlatformKeyboardLayout, PlatformTextSystem, PlatformWindow, Result, + SemanticVersion, SystemMenuType, Task, WindowAppearance, WindowParams, hash, }; use anyhow::{Context as _, anyhow}; use block::ConcreteBlock; @@ -413,9 +413,20 @@ impl MacPlatform { } item.setSubmenu_(submenu); item.setTitle_(ns_string(&name)); - if name == "Services" { - let app: id = msg_send![APP_CLASS, sharedApplication]; - app.setServicesMenu_(item); + item + } + MenuItem::SystemMenu(OsMenu { name, menu_type }) => { + let item = NSMenuItem::new(nil).autorelease(); + let submenu = NSMenu::new(nil).autorelease(); + submenu.setDelegate_(delegate); + item.setSubmenu_(submenu); + item.setTitle_(ns_string(&name)); + + match menu_type { + SystemMenuType::Services => { + let app: id = msg_send![APP_CLASS, sharedApplication]; + app.setServicesMenu_(item); + } } item diff --git a/crates/title_bar/src/application_menu.rs b/crates/title_bar/src/application_menu.rs index a5d5f154c9..98f0eeb6cc 100644 --- a/crates/title_bar/src/application_menu.rs +++ b/crates/title_bar/src/application_menu.rs @@ -121,8 +121,16 @@ impl ApplicationMenu { menu.action(name, action) } OwnedMenuItem::Submenu(_) => menu, + OwnedMenuItem::SystemMenu(_) => { + // A system menu doesn't make sense in this context, so ignore it + menu + } }) } + OwnedMenuItem::SystemMenu(_) => { + // A system menu doesn't make sense in this context, so ignore it + menu + } }) }) } diff --git a/crates/zed/src/zed/app_menus.rs b/crates/zed/src/zed/app_menus.rs index 15d5659f03..53eec42ba0 100644 --- a/crates/zed/src/zed/app_menus.rs +++ b/crates/zed/src/zed/app_menus.rs @@ -35,10 +35,7 @@ pub fn app_menus() -> Vec

{ ], }), MenuItem::separator(), - MenuItem::submenu(Menu { - name: "Services".into(), - items: vec![], - }), + MenuItem::os_submenu("Services", gpui::SystemMenuType::Services), MenuItem::separator(), MenuItem::action("Extensions", zed_actions::Extensions::default()), MenuItem::action("Install CLI", install_cli::Install), From add67bde43aec927dfb74d3db6fdaa362deaff45 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 11 Aug 2025 16:10:06 -0600 Subject: [PATCH 070/109] Remove unnecessary argument from Vim#update_editor (#36001) Release Notes: - N/A --- crates/vim/src/change_list.rs | 4 +- crates/vim/src/command.rs | 49 +++++++++++----------- crates/vim/src/digraph.rs | 8 +--- crates/vim/src/helix.rs | 16 +++---- crates/vim/src/indent.rs | 10 ++--- crates/vim/src/insert.rs | 2 +- crates/vim/src/motion.rs | 2 +- crates/vim/src/normal.rs | 40 +++++++++--------- crates/vim/src/normal/change.rs | 4 +- crates/vim/src/normal/convert.rs | 6 +-- crates/vim/src/normal/delete.rs | 4 +- crates/vim/src/normal/increment.rs | 2 +- crates/vim/src/normal/mark.rs | 10 ++--- crates/vim/src/normal/paste.rs | 6 +-- crates/vim/src/normal/scroll.rs | 2 +- crates/vim/src/normal/search.rs | 4 +- crates/vim/src/normal/substitute.rs | 2 +- crates/vim/src/normal/toggle_comments.rs | 4 +- crates/vim/src/normal/yank.rs | 4 +- crates/vim/src/replace.rs | 12 +++--- crates/vim/src/rewrap.rs | 6 +-- crates/vim/src/surrounds.rs | 8 ++-- crates/vim/src/vim.rs | 53 +++++++++++------------- crates/vim/src/visual.rs | 36 ++++++++-------- 24 files changed, 142 insertions(+), 152 deletions(-) diff --git a/crates/vim/src/change_list.rs b/crates/vim/src/change_list.rs index a59083f7ab..c92ce4720e 100644 --- a/crates/vim/src/change_list.rs +++ b/crates/vim/src/change_list.rs @@ -31,7 +31,7 @@ impl Vim { ) { let count = Vim::take_count(cx).unwrap_or(1); Vim::take_forced_motion(cx); - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { if let Some(selections) = editor .change_list .next_change(count, direction) @@ -49,7 +49,7 @@ impl Vim { } pub(crate) fn push_to_change_list(&mut self, window: &mut Window, cx: &mut Context) { - let Some((new_positions, buffer)) = self.update_editor(window, cx, |vim, editor, _, cx| { + let Some((new_positions, buffer)) = self.update_editor(cx, |vim, editor, cx| { let (map, selections) = editor.selections.all_adjusted_display(cx); let buffer = editor.buffer().clone(); diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 7963db3571..f7889d8cd8 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -241,9 +241,9 @@ impl Deref for WrappedAction { pub fn register(editor: &mut Editor, cx: &mut Context) { // Vim::action(editor, cx, |vim, action: &StartOfLine, window, cx| { - Vim::action(editor, cx, |vim, action: &VimSet, window, cx| { + Vim::action(editor, cx, |vim, action: &VimSet, _, cx| { for option in action.options.iter() { - vim.update_editor(window, cx, |_, editor, _, cx| match option { + vim.update_editor(cx, |_, editor, cx| match option { VimOption::Wrap(true) => { editor .set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx); @@ -298,7 +298,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { }); Vim::action(editor, cx, |vim, action: &VimSave, window, cx| { - vim.update_editor(window, cx, |_, editor, window, cx| { + vim.update_editor(cx, |_, editor, cx| { let Some(project) = editor.project.clone() else { return; }; @@ -375,7 +375,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { cx, ); } - vim.update_editor(window, cx, |vim, editor, window, cx| match action { + vim.update_editor(cx, |vim, editor, cx| match action { DeleteMarks::Marks(s) => { if s.starts_with('-') || s.ends_with('-') || s.contains(['\'', '`']) { err(s.clone(), window, cx); @@ -432,7 +432,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { }); Vim::action(editor, cx, |vim, action: &VimEdit, window, cx| { - vim.update_editor(window, cx, |vim, editor, window, cx| { + vim.update_editor(cx, |vim, editor, cx| { let Some(workspace) = vim.workspace(window) else { return; }; @@ -462,11 +462,10 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { .map(|c| Keystroke::parse(&c.to_string()).unwrap()) .collect(); vim.switch_mode(Mode::Normal, true, window, cx); - let initial_selections = vim.update_editor(window, cx, |_, editor, _, _| { - editor.selections.disjoint_anchors() - }); + let initial_selections = + vim.update_editor(cx, |_, editor, _| editor.selections.disjoint_anchors()); if let Some(range) = &action.range { - let result = vim.update_editor(window, cx, |vim, editor, window, cx| { + let result = vim.update_editor(cx, |vim, editor, cx| { let range = range.buffer_range(vim, editor, window, cx)?; editor.change_selections( SelectionEffects::no_scroll().nav_history(false), @@ -498,7 +497,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { cx.spawn_in(window, async move |vim, cx| { task.await; vim.update_in(cx, |vim, window, cx| { - vim.update_editor(window, cx, |_, editor, window, cx| { + vim.update_editor(cx, |_, editor, cx| { if had_range { editor.change_selections(SelectionEffects::default(), window, cx, |s| { s.select_anchor_ranges([s.newest_anchor().range()]); @@ -510,7 +509,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { } else { vim.switch_mode(Mode::Normal, true, window, cx); } - vim.update_editor(window, cx, |_, editor, _, cx| { + vim.update_editor(cx, |_, editor, cx| { if let Some(first_sel) = initial_selections { if let Some(tx_id) = editor .buffer() @@ -548,7 +547,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, |vim, action: &GoToLine, window, cx| { vim.switch_mode(Mode::Normal, false, window, cx); - let result = vim.update_editor(window, cx, |vim, editor, window, cx| { + let result = vim.update_editor(cx, |vim, editor, cx| { let snapshot = editor.snapshot(window, cx); let buffer_row = action.range.head().buffer_row(vim, editor, window, cx)?; let current = editor.selections.newest::(cx); @@ -573,7 +572,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { }); Vim::action(editor, cx, |vim, action: &YankCommand, window, cx| { - vim.update_editor(window, cx, |vim, editor, window, cx| { + vim.update_editor(cx, |vim, editor, cx| { let snapshot = editor.snapshot(window, cx); if let Ok(range) = action.range.buffer_range(vim, editor, window, cx) { let end = if range.end < snapshot.buffer_snapshot.max_row() { @@ -600,7 +599,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { }); Vim::action(editor, cx, |vim, action: &WithRange, window, cx| { - let result = vim.update_editor(window, cx, |vim, editor, window, cx| { + let result = vim.update_editor(cx, |vim, editor, cx| { action.range.buffer_range(vim, editor, window, cx) }); @@ -619,7 +618,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { }; let previous_selections = vim - .update_editor(window, cx, |_, editor, window, cx| { + .update_editor(cx, |_, editor, cx| { let selections = action.restore_selection.then(|| { editor .selections @@ -635,7 +634,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { .flatten(); window.dispatch_action(action.action.boxed_clone(), cx); cx.defer_in(window, move |vim, window, cx| { - vim.update_editor(window, cx, |_, editor, window, cx| { + vim.update_editor(cx, |_, editor, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { if let Some(previous_selections) = previous_selections { s.select_ranges(previous_selections); @@ -1536,7 +1535,7 @@ impl OnMatchingLines { } pub fn run(&self, vim: &mut Vim, window: &mut Window, cx: &mut Context) { - let result = vim.update_editor(window, cx, |vim, editor, window, cx| { + let result = vim.update_editor(cx, |vim, editor, cx| { self.range.buffer_range(vim, editor, window, cx) }); @@ -1600,7 +1599,7 @@ impl OnMatchingLines { }); }; - vim.update_editor(window, cx, |_, editor, window, cx| { + vim.update_editor(cx, |_, editor, cx| { let snapshot = editor.snapshot(window, cx); let mut row = range.start.0; @@ -1680,7 +1679,7 @@ pub struct ShellExec { impl Vim { pub fn cancel_running_command(&mut self, window: &mut Window, cx: &mut Context) { if self.running_command.take().is_some() { - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, _window, _cx| { editor.clear_row_highlights::(); }) @@ -1691,7 +1690,7 @@ impl Vim { fn prepare_shell_command( &mut self, command: &str, - window: &mut Window, + _: &mut Window, cx: &mut Context, ) -> String { let mut ret = String::new(); @@ -1711,7 +1710,7 @@ impl Vim { } match c { '%' => { - self.update_editor(window, cx, |_, editor, _window, cx| { + self.update_editor(cx, |_, editor, cx| { if let Some((_, buffer, _)) = editor.active_excerpt(cx) { if let Some(file) = buffer.read(cx).file() { if let Some(local) = file.as_local() { @@ -1747,7 +1746,7 @@ impl Vim { let Some(workspace) = self.workspace(window) else { return; }; - let command = self.update_editor(window, cx, |_, editor, window, cx| { + let command = self.update_editor(cx, |_, editor, cx| { let snapshot = editor.snapshot(window, cx); let start = editor.selections.newest_display(cx); let text_layout_details = editor.text_layout_details(window); @@ -1794,7 +1793,7 @@ impl Vim { let Some(workspace) = self.workspace(window) else { return; }; - let command = self.update_editor(window, cx, |_, editor, window, cx| { + let command = self.update_editor(cx, |_, editor, cx| { let snapshot = editor.snapshot(window, cx); let start = editor.selections.newest_display(cx); let range = object @@ -1896,7 +1895,7 @@ impl ShellExec { let mut input_snapshot = None; let mut input_range = None; let mut needs_newline_prefix = false; - vim.update_editor(window, cx, |vim, editor, window, cx| { + vim.update_editor(cx, |vim, editor, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); let range = if let Some(range) = self.range.clone() { let Some(range) = range.buffer_range(vim, editor, window, cx).log_err() else { @@ -1990,7 +1989,7 @@ impl ShellExec { } vim.update_in(cx, |vim, window, cx| { - vim.update_editor(window, cx, |_, editor, window, cx| { + vim.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, window, cx| { editor.edit([(range.clone(), text)], cx); let snapshot = editor.buffer().read(cx).snapshot(cx); diff --git a/crates/vim/src/digraph.rs b/crates/vim/src/digraph.rs index 881454392a..c555b781b1 100644 --- a/crates/vim/src/digraph.rs +++ b/crates/vim/src/digraph.rs @@ -56,9 +56,7 @@ impl Vim { self.pop_operator(window, cx); if self.editor_input_enabled() { - self.update_editor(window, cx, |_, editor, window, cx| { - editor.insert(&text, window, cx) - }); + self.update_editor(cx, |_, editor, cx| editor.insert(&text, window, cx)); } else { self.input_ignored(text, window, cx); } @@ -214,9 +212,7 @@ impl Vim { text.push_str(suffix); if self.editor_input_enabled() { - self.update_editor(window, cx, |_, editor, window, cx| { - editor.insert(&text, window, cx) - }); + self.update_editor(cx, |_, editor, cx| editor.insert(&text, window, cx)); } else { self.input_ignored(text.into(), window, cx); } diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index ca93c9c1de..686c74f65e 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -62,7 +62,7 @@ impl Vim { cx: &mut Context, mut is_boundary: impl FnMut(char, char, &CharClassifier) -> bool, ) { - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.change_selections(Default::default(), window, cx, |s| { s.move_with(|map, selection| { let times = times.unwrap_or(1); @@ -115,7 +115,7 @@ impl Vim { cx: &mut Context, mut is_boundary: impl FnMut(char, char, &CharClassifier) -> bool, ) { - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.change_selections(Default::default(), window, cx, |s| { s.move_with(|map, selection| { let times = times.unwrap_or(1); @@ -175,7 +175,7 @@ impl Vim { window: &mut Window, cx: &mut Context, ) { - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { let text_layout_details = editor.text_layout_details(window); editor.change_selections(Default::default(), window, cx, |s| { s.move_with(|map, selection| { @@ -253,7 +253,7 @@ impl Vim { }) } Motion::FindForward { .. } => { - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { let text_layout_details = editor.text_layout_details(window); editor.change_selections(Default::default(), window, cx, |s| { s.move_with(|map, selection| { @@ -280,7 +280,7 @@ impl Vim { }); } Motion::FindBackward { .. } => { - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { let text_layout_details = editor.text_layout_details(window); editor.change_selections(Default::default(), window, cx, |s| { s.move_with(|map, selection| { @@ -312,7 +312,7 @@ impl Vim { fn helix_insert(&mut self, _: &HelixInsert, window: &mut Window, cx: &mut Context) { self.start_recording(cx); - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.change_selections(Default::default(), window, cx, |s| { s.move_with(|_map, selection| { // In helix normal mode, move cursor to start of selection and collapse @@ -328,7 +328,7 @@ impl Vim { fn helix_append(&mut self, _: &HelixAppend, window: &mut Window, cx: &mut Context) { self.start_recording(cx); self.switch_mode(Mode::Insert, false, window, cx); - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.change_selections(Default::default(), window, cx, |s| { s.move_with(|map, selection| { let point = if selection.is_empty() { @@ -343,7 +343,7 @@ impl Vim { } pub fn helix_replace(&mut self, text: &str, window: &mut Window, cx: &mut Context) { - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, window, cx| { let (map, selections) = editor.selections.all_display(cx); diff --git a/crates/vim/src/indent.rs b/crates/vim/src/indent.rs index 75b1857a5b..7ef204de0f 100644 --- a/crates/vim/src/indent.rs +++ b/crates/vim/src/indent.rs @@ -31,7 +31,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { let count = Vim::take_count(cx).unwrap_or(1); Vim::take_forced_motion(cx); vim.store_visual_marks(window, cx); - vim.update_editor(window, cx, |vim, editor, window, cx| { + vim.update_editor(cx, |vim, editor, cx| { editor.transact(window, cx, |editor, window, cx| { let original_positions = vim.save_selection_starts(editor, cx); for _ in 0..count { @@ -50,7 +50,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { let count = Vim::take_count(cx).unwrap_or(1); Vim::take_forced_motion(cx); vim.store_visual_marks(window, cx); - vim.update_editor(window, cx, |vim, editor, window, cx| { + vim.update_editor(cx, |vim, editor, cx| { editor.transact(window, cx, |editor, window, cx| { let original_positions = vim.save_selection_starts(editor, cx); for _ in 0..count { @@ -69,7 +69,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { let count = Vim::take_count(cx).unwrap_or(1); Vim::take_forced_motion(cx); vim.store_visual_marks(window, cx); - vim.update_editor(window, cx, |vim, editor, window, cx| { + vim.update_editor(cx, |vim, editor, cx| { editor.transact(window, cx, |editor, window, cx| { let original_positions = vim.save_selection_starts(editor, cx); for _ in 0..count { @@ -95,7 +95,7 @@ impl Vim { cx: &mut Context, ) { self.stop_recording(cx); - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { let text_layout_details = editor.text_layout_details(window); editor.transact(window, cx, |editor, window, cx| { let mut selection_starts: HashMap<_, _> = Default::default(); @@ -137,7 +137,7 @@ impl Vim { cx: &mut Context, ) { self.stop_recording(cx); - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, window, cx| { let mut original_positions: HashMap<_, _> = Default::default(); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { diff --git a/crates/vim/src/insert.rs b/crates/vim/src/insert.rs index 0a370e16ba..584057a8c0 100644 --- a/crates/vim/src/insert.rs +++ b/crates/vim/src/insert.rs @@ -38,7 +38,7 @@ impl Vim { if count <= 1 || Vim::globals(cx).dot_replaying { self.create_mark("^".into(), window, cx); - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.dismiss_menus_and_popups(false, window, cx); if !HelixModeSetting::get_global(cx).0 { diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 0e487f4410..7ef883f406 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -679,7 +679,7 @@ impl Vim { match self.mode { Mode::Visual | Mode::VisualLine | Mode::VisualBlock => { if !prior_selections.is_empty() { - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.change_selections(Default::default(), window, cx, |s| { s.select_ranges(prior_selections.iter().cloned()) }) diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 13128e7b40..b74d85b7c5 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -132,7 +132,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, |vim, _: &HelixDelete, window, cx| { vim.record_current_action(cx); - vim.update_editor(window, cx, |_, editor, window, cx| { + vim.update_editor(cx, |_, editor, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.move_with(|map, selection| { if selection.is_empty() { @@ -146,7 +146,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { }); Vim::action(editor, cx, |vim, _: &HelixCollapseSelection, window, cx| { - vim.update_editor(window, cx, |_, editor, window, cx| { + vim.update_editor(cx, |_, editor, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.move_with(|map, selection| { let mut point = selection.head(); @@ -198,7 +198,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, |vim, _: &Undo, window, cx| { let times = Vim::take_count(cx); Vim::take_forced_motion(cx); - vim.update_editor(window, cx, |_, editor, window, cx| { + vim.update_editor(cx, |_, editor, cx| { for _ in 0..times.unwrap_or(1) { editor.undo(&editor::actions::Undo, window, cx); } @@ -207,7 +207,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, |vim, _: &Redo, window, cx| { let times = Vim::take_count(cx); Vim::take_forced_motion(cx); - vim.update_editor(window, cx, |_, editor, window, cx| { + vim.update_editor(cx, |_, editor, cx| { for _ in 0..times.unwrap_or(1) { editor.redo(&editor::actions::Redo, window, cx); } @@ -215,7 +215,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { }); Vim::action(editor, cx, |vim, _: &UndoLastLine, window, cx| { Vim::take_forced_motion(cx); - vim.update_editor(window, cx, |vim, editor, window, cx| { + vim.update_editor(cx, |vim, editor, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); let Some(last_change) = editor.change_list.last_before_grouping() else { return; @@ -526,7 +526,7 @@ impl Vim { window: &mut Window, cx: &mut Context, ) { - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { let text_layout_details = editor.text_layout_details(window); editor.change_selections( SelectionEffects::default().nav_history(motion.push_to_jump_list()), @@ -546,7 +546,7 @@ impl Vim { fn insert_after(&mut self, _: &InsertAfter, window: &mut Window, cx: &mut Context) { self.start_recording(cx); self.switch_mode(Mode::Insert, false, window, cx); - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.change_selections(Default::default(), window, cx, |s| { s.move_cursors_with(|map, cursor, _| (right(map, cursor, 1), SelectionGoal::None)); }); @@ -557,7 +557,7 @@ impl Vim { self.start_recording(cx); if self.mode.is_visual() { let current_mode = self.mode; - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.change_selections(Default::default(), window, cx, |s| { s.move_with(|map, selection| { if current_mode == Mode::VisualLine { @@ -581,7 +581,7 @@ impl Vim { ) { self.start_recording(cx); self.switch_mode(Mode::Insert, false, window, cx); - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.change_selections(Default::default(), window, cx, |s| { s.move_cursors_with(|map, cursor, _| { ( @@ -601,7 +601,7 @@ impl Vim { ) { self.start_recording(cx); self.switch_mode(Mode::Insert, false, window, cx); - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.change_selections(Default::default(), window, cx, |s| { s.move_cursors_with(|map, cursor, _| { (next_line_end(map, cursor, 1), SelectionGoal::None) @@ -618,7 +618,7 @@ impl Vim { ) { self.start_recording(cx); self.switch_mode(Mode::Insert, false, window, cx); - self.update_editor(window, cx, |vim, editor, window, cx| { + self.update_editor(cx, |vim, editor, cx| { let Some(Mark::Local(marks)) = vim.get_mark("^", editor, window, cx) else { return; }; @@ -637,7 +637,7 @@ impl Vim { ) { self.start_recording(cx); self.switch_mode(Mode::Insert, false, window, cx); - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, window, cx| { let selections = editor.selections.all::(cx); let snapshot = editor.buffer().read(cx).snapshot(cx); @@ -678,7 +678,7 @@ impl Vim { ) { self.start_recording(cx); self.switch_mode(Mode::Insert, false, window, cx); - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { let text_layout_details = editor.text_layout_details(window); editor.transact(window, cx, |editor, window, cx| { let selections = editor.selections.all::(cx); @@ -725,7 +725,7 @@ impl Vim { self.record_current_action(cx); let count = Vim::take_count(cx).unwrap_or(1); Vim::take_forced_motion(cx); - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, _, cx| { let selections = editor.selections.all::(cx); @@ -754,7 +754,7 @@ impl Vim { self.record_current_action(cx); let count = Vim::take_count(cx).unwrap_or(1); Vim::take_forced_motion(cx); - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, window, cx| { let selections = editor.selections.all::(cx); let snapshot = editor.buffer().read(cx).snapshot(cx); @@ -804,7 +804,7 @@ impl Vim { times -= 1; } - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, window, cx| { for _ in 0..times { editor.join_lines_impl(insert_whitespace, window, cx) @@ -828,10 +828,10 @@ impl Vim { ) } - fn show_location(&mut self, _: &ShowLocation, window: &mut Window, cx: &mut Context) { + fn show_location(&mut self, _: &ShowLocation, _: &mut Window, cx: &mut Context) { let count = Vim::take_count(cx); Vim::take_forced_motion(cx); - self.update_editor(window, cx, |vim, editor, _window, cx| { + self.update_editor(cx, |vim, editor, cx| { let selection = editor.selections.newest_anchor(); let Some((buffer, point, _)) = editor .buffer() @@ -875,7 +875,7 @@ impl Vim { fn toggle_comments(&mut self, _: &ToggleComments, window: &mut Window, cx: &mut Context) { self.record_current_action(cx); self.store_visual_marks(window, cx); - self.update_editor(window, cx, |vim, editor, window, cx| { + self.update_editor(cx, |vim, editor, cx| { editor.transact(window, cx, |editor, window, cx| { let original_positions = vim.save_selection_starts(editor, cx); editor.toggle_comments(&Default::default(), window, cx); @@ -897,7 +897,7 @@ impl Vim { let count = Vim::take_count(cx).unwrap_or(1); Vim::take_forced_motion(cx); self.stop_recording(cx); - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, window, cx| { editor.set_clip_at_line_ends(false, cx); let (map, display_selections) = editor.selections.all_display(cx); diff --git a/crates/vim/src/normal/change.rs b/crates/vim/src/normal/change.rs index c1bc7a70ae..fcd36dd7ee 100644 --- a/crates/vim/src/normal/change.rs +++ b/crates/vim/src/normal/change.rs @@ -34,7 +34,7 @@ impl Vim { } else { None }; - self.update_editor(window, cx, |vim, editor, window, cx| { + self.update_editor(cx, |vim, editor, cx| { let text_layout_details = editor.text_layout_details(window); editor.transact(window, cx, |editor, window, cx| { // We are swapping to insert mode anyway. Just set the line end clipping behavior now @@ -111,7 +111,7 @@ impl Vim { cx: &mut Context, ) { let mut objects_found = false; - self.update_editor(window, cx, |vim, editor, window, cx| { + self.update_editor(cx, |vim, editor, cx| { // We are swapping to insert mode anyway. Just set the line end clipping behavior now editor.set_clip_at_line_ends(false, cx); editor.transact(window, cx, |editor, window, cx| { diff --git a/crates/vim/src/normal/convert.rs b/crates/vim/src/normal/convert.rs index cf9498bec9..4b9c3fc8f7 100644 --- a/crates/vim/src/normal/convert.rs +++ b/crates/vim/src/normal/convert.rs @@ -31,7 +31,7 @@ impl Vim { cx: &mut Context, ) { self.stop_recording(cx); - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.set_clip_at_line_ends(false, cx); let text_layout_details = editor.text_layout_details(window); editor.transact(window, cx, |editor, window, cx| { @@ -87,7 +87,7 @@ impl Vim { cx: &mut Context, ) { self.stop_recording(cx); - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, window, cx| { editor.set_clip_at_line_ends(false, cx); let mut original_positions: HashMap<_, _> = Default::default(); @@ -195,7 +195,7 @@ impl Vim { let count = Vim::take_count(cx).unwrap_or(1) as u32; Vim::take_forced_motion(cx); - self.update_editor(window, cx, |vim, editor, window, cx| { + self.update_editor(cx, |vim, editor, cx| { let mut ranges = Vec::new(); let mut cursor_positions = Vec::new(); let snapshot = editor.buffer().read(cx).snapshot(cx); diff --git a/crates/vim/src/normal/delete.rs b/crates/vim/src/normal/delete.rs index 2cf40292cf..1b7557371a 100644 --- a/crates/vim/src/normal/delete.rs +++ b/crates/vim/src/normal/delete.rs @@ -22,7 +22,7 @@ impl Vim { cx: &mut Context, ) { self.stop_recording(cx); - self.update_editor(window, cx, |vim, editor, window, cx| { + self.update_editor(cx, |vim, editor, cx| { let text_layout_details = editor.text_layout_details(window); editor.transact(window, cx, |editor, window, cx| { editor.set_clip_at_line_ends(false, cx); @@ -96,7 +96,7 @@ impl Vim { cx: &mut Context, ) { self.stop_recording(cx); - self.update_editor(window, cx, |vim, editor, window, cx| { + self.update_editor(cx, |vim, editor, cx| { editor.transact(window, cx, |editor, window, cx| { editor.set_clip_at_line_ends(false, cx); // Emulates behavior in vim where if we expanded backwards to include a newline diff --git a/crates/vim/src/normal/increment.rs b/crates/vim/src/normal/increment.rs index 51f6e4a0f9..007514e472 100644 --- a/crates/vim/src/normal/increment.rs +++ b/crates/vim/src/normal/increment.rs @@ -53,7 +53,7 @@ impl Vim { cx: &mut Context, ) { self.store_visual_marks(window, cx); - self.update_editor(window, cx, |vim, editor, window, cx| { + self.update_editor(cx, |vim, editor, cx| { let mut edits = Vec::new(); let mut new_anchors = Vec::new(); diff --git a/crates/vim/src/normal/mark.rs b/crates/vim/src/normal/mark.rs index 57a6108841..1d6264d593 100644 --- a/crates/vim/src/normal/mark.rs +++ b/crates/vim/src/normal/mark.rs @@ -19,7 +19,7 @@ use crate::{ impl Vim { pub fn create_mark(&mut self, text: Arc, window: &mut Window, cx: &mut Context) { - self.update_editor(window, cx, |vim, editor, window, cx| { + self.update_editor(cx, |vim, editor, cx| { let anchors = editor .selections .disjoint_anchors() @@ -49,7 +49,7 @@ impl Vim { let mut ends = vec![]; let mut reversed = vec![]; - self.update_editor(window, cx, |vim, editor, window, cx| { + self.update_editor(cx, |vim, editor, cx| { let (map, selections) = editor.selections.all_display(cx); for selection in selections { let end = movement::saturating_left(&map, selection.end); @@ -190,7 +190,7 @@ impl Vim { self.pop_operator(window, cx); } let mark = self - .update_editor(window, cx, |vim, editor, window, cx| { + .update_editor(cx, |vim, editor, cx| { vim.get_mark(&text, editor, window, cx) }) .flatten(); @@ -209,7 +209,7 @@ impl Vim { let Some(mut anchors) = anchors else { return }; - self.update_editor(window, cx, |_, editor, _, cx| { + self.update_editor(cx, |_, editor, cx| { editor.create_nav_history_entry(cx); }); let is_active_operator = self.active_operator().is_some(); @@ -231,7 +231,7 @@ impl Vim { || self.mode == Mode::VisualLine || self.mode == Mode::VisualBlock; - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { let map = editor.snapshot(window, cx); let mut ranges: Vec> = Vec::new(); for mut anchor in anchors { diff --git a/crates/vim/src/normal/paste.rs b/crates/vim/src/normal/paste.rs index 07712fbedd..0fd17f310e 100644 --- a/crates/vim/src/normal/paste.rs +++ b/crates/vim/src/normal/paste.rs @@ -32,7 +32,7 @@ impl Vim { let count = Vim::take_count(cx).unwrap_or(1); Vim::take_forced_motion(cx); - self.update_editor(window, cx, |vim, editor, window, cx| { + self.update_editor(cx, |vim, editor, cx| { let text_layout_details = editor.text_layout_details(window); editor.transact(window, cx, |editor, window, cx| { editor.set_clip_at_line_ends(false, cx); @@ -236,7 +236,7 @@ impl Vim { ) { self.stop_recording(cx); let selected_register = self.selected_register.take(); - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, window, cx| { editor.set_clip_at_line_ends(false, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { @@ -273,7 +273,7 @@ impl Vim { ) { self.stop_recording(cx); let selected_register = self.selected_register.take(); - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { let text_layout_details = editor.text_layout_details(window); editor.transact(window, cx, |editor, window, cx| { editor.set_clip_at_line_ends(false, cx); diff --git a/crates/vim/src/normal/scroll.rs b/crates/vim/src/normal/scroll.rs index e2ae74b52b..af13bc0fd0 100644 --- a/crates/vim/src/normal/scroll.rs +++ b/crates/vim/src/normal/scroll.rs @@ -97,7 +97,7 @@ impl Vim { let amount = by(Vim::take_count(cx).map(|c| c as f32)); Vim::take_forced_motion(cx); self.exit_temporary_normal(window, cx); - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { scroll_editor(editor, move_cursor, &amount, window, cx) }); } diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index 24f2cf751f..e4e95ca48e 100644 --- a/crates/vim/src/normal/search.rs +++ b/crates/vim/src/normal/search.rs @@ -251,7 +251,7 @@ impl Vim { // If the active editor has changed during a search, don't panic. if prior_selections.iter().any(|s| { - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { !s.start .is_valid(&editor.snapshot(window, cx).buffer_snapshot) }) @@ -457,7 +457,7 @@ impl Vim { else { return; }; - if let Some(result) = self.update_editor(window, cx, |vim, editor, window, cx| { + if let Some(result) = self.update_editor(cx, |vim, editor, cx| { let range = action.range.buffer_range(vim, editor, window, cx)?; let snapshot = &editor.snapshot(window, cx).buffer_snapshot; let end_point = Point::new(range.end.0, snapshot.line_len(range.end)); diff --git a/crates/vim/src/normal/substitute.rs b/crates/vim/src/normal/substitute.rs index a9752f2887..889d487170 100644 --- a/crates/vim/src/normal/substitute.rs +++ b/crates/vim/src/normal/substitute.rs @@ -45,7 +45,7 @@ impl Vim { cx: &mut Context, ) { self.store_visual_marks(window, cx); - self.update_editor(window, cx, |vim, editor, window, cx| { + self.update_editor(cx, |vim, editor, cx| { editor.set_clip_at_line_ends(false, cx); editor.transact(window, cx, |editor, window, cx| { let text_layout_details = editor.text_layout_details(window); diff --git a/crates/vim/src/normal/toggle_comments.rs b/crates/vim/src/normal/toggle_comments.rs index 636ea9eec2..17c3b2d363 100644 --- a/crates/vim/src/normal/toggle_comments.rs +++ b/crates/vim/src/normal/toggle_comments.rs @@ -14,7 +14,7 @@ impl Vim { cx: &mut Context, ) { self.stop_recording(cx); - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { let text_layout_details = editor.text_layout_details(window); editor.transact(window, cx, |editor, window, cx| { let mut selection_starts: HashMap<_, _> = Default::default(); @@ -51,7 +51,7 @@ impl Vim { cx: &mut Context, ) { self.stop_recording(cx); - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, window, cx| { let mut original_positions: HashMap<_, _> = Default::default(); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { diff --git a/crates/vim/src/normal/yank.rs b/crates/vim/src/normal/yank.rs index 847eba3143..fe8180ffff 100644 --- a/crates/vim/src/normal/yank.rs +++ b/crates/vim/src/normal/yank.rs @@ -25,7 +25,7 @@ impl Vim { window: &mut Window, cx: &mut Context, ) { - self.update_editor(window, cx, |vim, editor, window, cx| { + self.update_editor(cx, |vim, editor, cx| { let text_layout_details = editor.text_layout_details(window); editor.transact(window, cx, |editor, window, cx| { editor.set_clip_at_line_ends(false, cx); @@ -70,7 +70,7 @@ impl Vim { window: &mut Window, cx: &mut Context, ) { - self.update_editor(window, cx, |vim, editor, window, cx| { + self.update_editor(cx, |vim, editor, cx| { editor.transact(window, cx, |editor, window, cx| { editor.set_clip_at_line_ends(false, cx); let mut start_positions: HashMap<_, _> = Default::default(); diff --git a/crates/vim/src/replace.rs b/crates/vim/src/replace.rs index aa857ef73e..eaa9fd5062 100644 --- a/crates/vim/src/replace.rs +++ b/crates/vim/src/replace.rs @@ -49,7 +49,7 @@ impl Vim { window: &mut Window, cx: &mut Context, ) { - self.update_editor(window, cx, |vim, editor, window, cx| { + self.update_editor(cx, |vim, editor, cx| { editor.transact(window, cx, |editor, window, cx| { editor.set_clip_at_line_ends(false, cx); let map = editor.snapshot(window, cx); @@ -94,7 +94,7 @@ impl Vim { window: &mut Window, cx: &mut Context, ) { - self.update_editor(window, cx, |vim, editor, window, cx| { + self.update_editor(cx, |vim, editor, cx| { editor.transact(window, cx, |editor, window, cx| { editor.set_clip_at_line_ends(false, cx); let map = editor.snapshot(window, cx); @@ -148,7 +148,7 @@ impl Vim { cx: &mut Context, ) { self.stop_recording(cx); - self.update_editor(window, cx, |vim, editor, window, cx| { + self.update_editor(cx, |vim, editor, cx| { editor.set_clip_at_line_ends(false, cx); let mut selection = editor.selections.newest_display(cx); let snapshot = editor.snapshot(window, cx); @@ -167,7 +167,7 @@ impl Vim { pub fn exchange_visual(&mut self, window: &mut Window, cx: &mut Context) { self.stop_recording(cx); - self.update_editor(window, cx, |vim, editor, window, cx| { + self.update_editor(cx, |vim, editor, cx| { let selection = editor.selections.newest_anchor(); let new_range = selection.start..selection.end; let snapshot = editor.snapshot(window, cx); @@ -178,7 +178,7 @@ impl Vim { pub fn clear_exchange(&mut self, window: &mut Window, cx: &mut Context) { self.stop_recording(cx); - self.update_editor(window, cx, |_, editor, _, cx| { + self.update_editor(cx, |_, editor, cx| { editor.clear_background_highlights::(cx); }); self.clear_operator(window, cx); @@ -193,7 +193,7 @@ impl Vim { cx: &mut Context, ) { self.stop_recording(cx); - self.update_editor(window, cx, |vim, editor, window, cx| { + self.update_editor(cx, |vim, editor, cx| { editor.set_clip_at_line_ends(false, cx); let text_layout_details = editor.text_layout_details(window); let mut selection = editor.selections.newest_display(cx); diff --git a/crates/vim/src/rewrap.rs b/crates/vim/src/rewrap.rs index 4cd9449bfa..85e1967af0 100644 --- a/crates/vim/src/rewrap.rs +++ b/crates/vim/src/rewrap.rs @@ -18,7 +18,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { Vim::take_count(cx); Vim::take_forced_motion(cx); vim.store_visual_marks(window, cx); - vim.update_editor(window, cx, |vim, editor, window, cx| { + vim.update_editor(cx, |vim, editor, cx| { editor.transact(window, cx, |editor, window, cx| { let mut positions = vim.save_selection_starts(editor, cx); editor.rewrap_impl( @@ -55,7 +55,7 @@ impl Vim { cx: &mut Context, ) { self.stop_recording(cx); - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { let text_layout_details = editor.text_layout_details(window); editor.transact(window, cx, |editor, window, cx| { let mut selection_starts: HashMap<_, _> = Default::default(); @@ -100,7 +100,7 @@ impl Vim { cx: &mut Context, ) { self.stop_recording(cx); - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, window, cx| { let mut original_positions: HashMap<_, _> = Default::default(); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { diff --git a/crates/vim/src/surrounds.rs b/crates/vim/src/surrounds.rs index 1f77ebda4a..63cd21e88c 100644 --- a/crates/vim/src/surrounds.rs +++ b/crates/vim/src/surrounds.rs @@ -29,7 +29,7 @@ impl Vim { let count = Vim::take_count(cx); let forced_motion = Vim::take_forced_motion(cx); let mode = self.mode; - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { let text_layout_details = editor.text_layout_details(window); editor.transact(window, cx, |editor, window, cx| { editor.set_clip_at_line_ends(false, cx); @@ -140,7 +140,7 @@ impl Vim { }; let surround = pair.end != *text; - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, window, cx| { editor.set_clip_at_line_ends(false, cx); @@ -228,7 +228,7 @@ impl Vim { ) { if let Some(will_replace_pair) = object_to_bracket_pair(target) { self.stop_recording(cx); - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, window, cx| { editor.set_clip_at_line_ends(false, cx); @@ -344,7 +344,7 @@ impl Vim { ) -> bool { let mut valid = false; if let Some(pair) = object_to_bracket_pair(object) { - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, window, cx| { editor.set_clip_at_line_ends(false, cx); let (display_map, selections) = editor.selections.all_adjusted_display(cx); diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 72edbe77ed..661bb71c91 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -748,7 +748,7 @@ impl Vim { editor, cx, |vim, action: &editor::actions::AcceptEditPrediction, window, cx| { - vim.update_editor(window, cx, |_, editor, window, cx| { + vim.update_editor(cx, |_, editor, cx| { editor.accept_edit_prediction(action, window, cx); }); // In non-insertion modes, predictions will be hidden and instead a jump will be @@ -847,7 +847,7 @@ impl Vim { if let Some(action) = keystroke_event.action.as_ref() { // Keystroke is handled by the vim system, so continue forward if action.name().starts_with("vim::") { - self.update_editor(window, cx, |_, editor, _, cx| { + self.update_editor(cx, |_, editor, cx| { editor.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx) }); return; @@ -909,7 +909,7 @@ impl Vim { anchor, is_deactivate, } => { - self.update_editor(window, cx, |vim, editor, window, cx| { + self.update_editor(cx, |vim, editor, cx| { let mark = if *is_deactivate { "\"".to_string() } else { @@ -972,7 +972,7 @@ impl Vim { if mode == Mode::Normal || mode != last_mode { self.current_tx.take(); self.current_anchor.take(); - self.update_editor(window, cx, |_, editor, _, _| { + self.update_editor(cx, |_, editor, _| { editor.clear_selection_drag_state(); }); } @@ -988,7 +988,7 @@ impl Vim { && self.mode != self.last_mode && (self.mode == Mode::Insert || self.last_mode == Mode::Insert) { - self.update_editor(window, cx, |vim, editor, _, cx| { + self.update_editor(cx, |vim, editor, cx| { let is_relative = vim.mode != Mode::Insert; editor.set_relative_line_number(Some(is_relative), cx) }); @@ -1003,7 +1003,7 @@ impl Vim { } // Adjust selections - self.update_editor(window, cx, |vim, editor, window, cx| { + self.update_editor(cx, |vim, editor, cx| { if last_mode != Mode::VisualBlock && last_mode.is_visual() && mode == Mode::VisualBlock { vim.visual_block_motion(true, editor, window, cx, |_, point, goal| { @@ -1214,7 +1214,7 @@ impl Vim { if preserve_selection { self.switch_mode(Mode::Visual, true, window, cx); } else { - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.set_clip_at_line_ends(false, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.move_with(|_, selection| { @@ -1232,18 +1232,18 @@ impl Vim { if let Some(old_vim) = Vim::globals(cx).focused_vim() { if old_vim.entity_id() != cx.entity().entity_id() { old_vim.update(cx, |vim, cx| { - vim.update_editor(window, cx, |_, editor, _, cx| { + vim.update_editor(cx, |_, editor, cx| { editor.set_relative_line_number(None, cx) }); }); - self.update_editor(window, cx, |vim, editor, _, cx| { + self.update_editor(cx, |vim, editor, cx| { let is_relative = vim.mode != Mode::Insert; editor.set_relative_line_number(Some(is_relative), cx) }); } } else { - self.update_editor(window, cx, |vim, editor, _, cx| { + self.update_editor(cx, |vim, editor, cx| { let is_relative = vim.mode != Mode::Insert; editor.set_relative_line_number(Some(is_relative), cx) }); @@ -1256,35 +1256,30 @@ impl Vim { self.stop_recording_immediately(NormalBefore.boxed_clone(), cx); self.store_visual_marks(window, cx); self.clear_operator(window, cx); - self.update_editor(window, cx, |vim, editor, _, cx| { + self.update_editor(cx, |vim, editor, cx| { if vim.cursor_shape(cx) == CursorShape::Block { editor.set_cursor_shape(CursorShape::Hollow, cx); } }); } - fn cursor_shape_changed(&mut self, window: &mut Window, cx: &mut Context) { - self.update_editor(window, cx, |vim, editor, _, cx| { + fn cursor_shape_changed(&mut self, _: &mut Window, cx: &mut Context) { + self.update_editor(cx, |vim, editor, cx| { editor.set_cursor_shape(vim.cursor_shape(cx), cx); }); } fn update_editor( &mut self, - window: &mut Window, cx: &mut Context, - update: impl FnOnce(&mut Self, &mut Editor, &mut Window, &mut Context) -> S, + update: impl FnOnce(&mut Self, &mut Editor, &mut Context) -> S, ) -> Option { let editor = self.editor.upgrade()?; - Some(editor.update(cx, |editor, cx| update(self, editor, window, cx))) + Some(editor.update(cx, |editor, cx| update(self, editor, cx))) } - fn editor_selections( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> Vec> { - self.update_editor(window, cx, |_, editor, _, _| { + fn editor_selections(&mut self, _: &mut Window, cx: &mut Context) -> Vec> { + self.update_editor(cx, |_, editor, _| { editor .selections .disjoint_anchors() @@ -1300,7 +1295,7 @@ impl Vim { window: &mut Window, cx: &mut Context, ) -> Option { - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { let selection = editor.selections.newest::(cx); let snapshot = &editor.snapshot(window, cx).buffer_snapshot; @@ -1489,7 +1484,7 @@ impl Vim { ) { match self.mode { Mode::VisualLine | Mode::VisualBlock | Mode::Visual => { - self.update_editor(window, cx, |vim, editor, window, cx| { + self.update_editor(cx, |vim, editor, cx| { let original_mode = vim.undo_modes.get(transaction_id); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { match original_mode { @@ -1520,7 +1515,7 @@ impl Vim { self.switch_mode(Mode::Normal, true, window, cx) } Mode::Normal => { - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.move_with(|map, selection| { selection @@ -1547,7 +1542,7 @@ impl Vim { self.current_anchor = Some(newest); } else if self.current_anchor.as_ref().unwrap() != &newest { if let Some(tx_id) = self.current_tx.take() { - self.update_editor(window, cx, |_, editor, _, cx| { + self.update_editor(cx, |_, editor, cx| { editor.group_until_transaction(tx_id, cx) }); } @@ -1694,7 +1689,7 @@ impl Vim { } Some(Operator::Register) => match self.mode { Mode::Insert => { - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { if let Some(register) = Vim::update_globals(cx, |globals, cx| { globals.read_register(text.chars().next(), Some(editor), cx) }) { @@ -1720,7 +1715,7 @@ impl Vim { } if self.mode == Mode::Normal { - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.accept_edit_prediction( &editor::actions::AcceptEditPrediction {}, window, @@ -1733,7 +1728,7 @@ impl Vim { } fn sync_vim_settings(&mut self, window: &mut Window, cx: &mut Context) { - self.update_editor(window, cx, |vim, editor, window, cx| { + self.update_editor(cx, |vim, editor, cx| { editor.set_cursor_shape(vim.cursor_shape(cx), cx); editor.set_clip_at_line_ends(vim.clip_at_line_ends(), cx); editor.set_collapse_matches(true); diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index ca8734ba8b..7bfd8dc8be 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -104,7 +104,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { let count = Vim::take_count(cx).unwrap_or(1); Vim::take_forced_motion(cx); for _ in 0..count { - vim.update_editor(window, cx, |_, editor, window, cx| { + vim.update_editor(cx, |_, editor, cx| { editor.select_larger_syntax_node(&Default::default(), window, cx); }); } @@ -117,7 +117,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { let count = Vim::take_count(cx).unwrap_or(1); Vim::take_forced_motion(cx); for _ in 0..count { - vim.update_editor(window, cx, |_, editor, window, cx| { + vim.update_editor(cx, |_, editor, cx| { editor.select_smaller_syntax_node(&Default::default(), window, cx); }); } @@ -129,7 +129,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { return; }; let marks = vim - .update_editor(window, cx, |vim, editor, window, cx| { + .update_editor(cx, |vim, editor, cx| { vim.get_mark("<", editor, window, cx) .zip(vim.get_mark(">", editor, window, cx)) }) @@ -148,7 +148,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { vim.create_visual_marks(vim.mode, window, cx); } - vim.update_editor(window, cx, |_, editor, window, cx| { + vim.update_editor(cx, |_, editor, cx| { editor.set_clip_at_line_ends(false, cx); editor.change_selections(Default::default(), window, cx, |s| { let map = s.display_map(); @@ -189,7 +189,7 @@ impl Vim { window: &mut Window, cx: &mut Context, ) { - self.update_editor(window, cx, |vim, editor, window, cx| { + self.update_editor(cx, |vim, editor, cx| { let text_layout_details = editor.text_layout_details(window); if vim.mode == Mode::VisualBlock && !matches!( @@ -397,7 +397,7 @@ impl Vim { self.switch_mode(target_mode, true, window, cx); } - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.change_selections(Default::default(), window, cx, |s| { s.move_with(|map, selection| { let mut mut_selection = selection.clone(); @@ -475,7 +475,7 @@ impl Vim { window: &mut Window, cx: &mut Context, ) { - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.split_selection_into_lines(&Default::default(), window, cx); editor.change_selections(Default::default(), window, cx, |s| { s.move_cursors_with(|map, cursor, _| { @@ -493,7 +493,7 @@ impl Vim { window: &mut Window, cx: &mut Context, ) { - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.split_selection_into_lines(&Default::default(), window, cx); editor.change_selections(Default::default(), window, cx, |s| { s.move_cursors_with(|map, cursor, _| { @@ -517,7 +517,7 @@ impl Vim { } pub fn other_end(&mut self, _: &OtherEnd, window: &mut Window, cx: &mut Context) { - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.change_selections(Default::default(), window, cx, |s| { s.move_with(|_, selection| { selection.reversed = !selection.reversed; @@ -533,7 +533,7 @@ impl Vim { cx: &mut Context, ) { let mode = self.mode; - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.change_selections(Default::default(), window, cx, |s| { s.move_with(|_, selection| { selection.reversed = !selection.reversed; @@ -547,7 +547,7 @@ impl Vim { pub fn visual_delete(&mut self, line_mode: bool, window: &mut Window, cx: &mut Context) { self.store_visual_marks(window, cx); - self.update_editor(window, cx, |vim, editor, window, cx| { + self.update_editor(cx, |vim, editor, cx| { let mut original_columns: HashMap<_, _> = Default::default(); let line_mode = line_mode || editor.selections.line_mode; editor.selections.line_mode = false; @@ -631,7 +631,7 @@ impl Vim { pub fn visual_yank(&mut self, line_mode: bool, window: &mut Window, cx: &mut Context) { self.store_visual_marks(window, cx); - self.update_editor(window, cx, |vim, editor, window, cx| { + self.update_editor(cx, |vim, editor, cx| { let line_mode = line_mode || editor.selections.line_mode; // For visual line mode, adjust selections to avoid yanking the next line when on \n @@ -679,7 +679,7 @@ impl Vim { cx: &mut Context, ) { self.stop_recording(cx); - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, window, cx| { let (display_map, selections) = editor.selections.all_adjusted_display(cx); @@ -722,7 +722,7 @@ impl Vim { Vim::take_forced_motion(cx); let count = Vim::take_count(cx).unwrap_or_else(|| if self.mode.is_visual() { 1 } else { 2 }); - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { editor.set_clip_at_line_ends(false, cx); for _ in 0..count { if editor @@ -745,7 +745,7 @@ impl Vim { Vim::take_forced_motion(cx); let count = Vim::take_count(cx).unwrap_or_else(|| if self.mode.is_visual() { 1 } else { 2 }); - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { for _ in 0..count { if editor .select_previous(&Default::default(), window, cx) @@ -773,7 +773,7 @@ impl Vim { let mut start_selection = 0usize; let mut end_selection = 0usize; - self.update_editor(window, cx, |_, editor, _, _| { + self.update_editor(cx, |_, editor, _| { editor.set_collapse_matches(false); }); if vim_is_normal { @@ -791,7 +791,7 @@ impl Vim { } }); } - self.update_editor(window, cx, |_, editor, _, cx| { + self.update_editor(cx, |_, editor, cx| { let latest = editor.selections.newest::(cx); start_selection = latest.start; end_selection = latest.end; @@ -812,7 +812,7 @@ impl Vim { self.stop_replaying(cx); return; } - self.update_editor(window, cx, |_, editor, window, cx| { + self.update_editor(cx, |_, editor, cx| { let latest = editor.selections.newest::(cx); if vim_is_normal { start_selection = latest.start; From b35e69692de2a5bd3c04e04d047ac1e9b29b12d8 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 11 Aug 2025 22:06:02 -0500 Subject: [PATCH 071/109] docs: Add a missing comma in Rust debugging JSON (#36007) Update the Rust debugging doc to include a missing comma in one of the example JSON's. --- docs/src/languages/rust.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/languages/rust.md b/docs/src/languages/rust.md index 1ee25a37b5..7695280275 100644 --- a/docs/src/languages/rust.md +++ b/docs/src/languages/rust.md @@ -326,7 +326,7 @@ When you use `cargo build` or `cargo test` as the build command, Zed can infer t [ { "label": "Build & Debug native binary", - "adapter": "CodeLLDB" + "adapter": "CodeLLDB", "build": { "command": "cargo", "args": ["build"] From 481e3e5092511222376a9fa1dcf2254e09a29a85 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Tue, 12 Aug 2025 07:53:20 +0300 Subject: [PATCH 072/109] Ignore capability registrations with empty capabilities (#36000) --- crates/project/src/lsp_store.rs | 268 ++++++++++++++++---------------- 1 file changed, 133 insertions(+), 135 deletions(-) diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index de6544f5a2..827341d60d 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -3367,20 +3367,6 @@ impl LocalLspStore { } } -fn parse_register_capabilities( - reg: lsp::Registration, -) -> anyhow::Result> { - let caps = match reg - .register_options - .map(|options| serde_json::from_value::(options)) - .transpose()? - { - None => OneOf::Left(true), - Some(options) => OneOf::Right(options), - }; - Ok(caps) -} - fn notify_server_capabilities_updated(server: &LanguageServer, cx: &mut Context) { if let Some(capabilities) = serde_json::to_string(&server.capabilities()).ok() { cx.emit(LspStoreEvent::LanguageServerUpdate { @@ -11690,190 +11676,190 @@ impl LspStore { // Ignore payload since we notify clients of setting changes unconditionally, relying on them pulling the latest settings. } "workspace/symbol" => { - let options = parse_register_capabilities(reg)?; - server.update_capabilities(|capabilities| { - capabilities.workspace_symbol_provider = Some(options); - }); - notify_server_capabilities_updated(&server, cx); + if let Some(options) = parse_register_capabilities(reg)? { + server.update_capabilities(|capabilities| { + capabilities.workspace_symbol_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); + } } "workspace/fileOperations" => { - let caps = reg - .register_options - .map(serde_json::from_value) - .transpose()? - .unwrap_or_default(); - server.update_capabilities(|capabilities| { - capabilities - .workspace - .get_or_insert_default() - .file_operations = Some(caps); - }); - notify_server_capabilities_updated(&server, cx); + if let Some(options) = reg.register_options { + let caps = serde_json::from_value(options)?; + server.update_capabilities(|capabilities| { + capabilities + .workspace + .get_or_insert_default() + .file_operations = Some(caps); + }); + notify_server_capabilities_updated(&server, cx); + } } "workspace/executeCommand" => { - let options = reg - .register_options - .map(serde_json::from_value) - .transpose()? - .unwrap_or_default(); - server.update_capabilities(|capabilities| { - capabilities.execute_command_provider = Some(options); - }); - notify_server_capabilities_updated(&server, cx); + if let Some(options) = reg.register_options { + let options = serde_json::from_value(options)?; + server.update_capabilities(|capabilities| { + capabilities.execute_command_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); + } } "textDocument/rangeFormatting" => { - let options = parse_register_capabilities(reg)?; - server.update_capabilities(|capabilities| { - capabilities.document_range_formatting_provider = Some(options); - }); - notify_server_capabilities_updated(&server, cx); + if let Some(options) = parse_register_capabilities(reg)? { + server.update_capabilities(|capabilities| { + capabilities.document_range_formatting_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); + } } "textDocument/onTypeFormatting" => { - let options = reg + if let Some(options) = reg .register_options .map(serde_json::from_value) .transpose()? - .unwrap_or_default(); - server.update_capabilities(|capabilities| { - capabilities.document_on_type_formatting_provider = Some(options); - }); - notify_server_capabilities_updated(&server, cx); + { + server.update_capabilities(|capabilities| { + capabilities.document_on_type_formatting_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); + } } "textDocument/formatting" => { - let options = parse_register_capabilities(reg)?; - server.update_capabilities(|capabilities| { - capabilities.document_formatting_provider = Some(options); - }); - notify_server_capabilities_updated(&server, cx); + if let Some(options) = parse_register_capabilities(reg)? { + server.update_capabilities(|capabilities| { + capabilities.document_formatting_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); + } } "textDocument/rename" => { - let options = parse_register_capabilities(reg)?; - server.update_capabilities(|capabilities| { - capabilities.rename_provider = Some(options); - }); - notify_server_capabilities_updated(&server, cx); + if let Some(options) = parse_register_capabilities(reg)? { + server.update_capabilities(|capabilities| { + capabilities.rename_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); + } } "textDocument/inlayHint" => { - let options = parse_register_capabilities(reg)?; - server.update_capabilities(|capabilities| { - capabilities.inlay_hint_provider = Some(options); - }); - notify_server_capabilities_updated(&server, cx); + if let Some(options) = parse_register_capabilities(reg)? { + server.update_capabilities(|capabilities| { + capabilities.inlay_hint_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); + } } "textDocument/documentSymbol" => { - let options = parse_register_capabilities(reg)?; - server.update_capabilities(|capabilities| { - capabilities.document_symbol_provider = Some(options); - }); - notify_server_capabilities_updated(&server, cx); + if let Some(options) = parse_register_capabilities(reg)? { + server.update_capabilities(|capabilities| { + capabilities.document_symbol_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); + } } "textDocument/codeAction" => { - let options = reg + if let Some(options) = reg .register_options .map(serde_json::from_value) - .transpose()?; - let provider_capability = match options { - None => lsp::CodeActionProviderCapability::Simple(true), - Some(options) => lsp::CodeActionProviderCapability::Options(options), - }; - server.update_capabilities(|capabilities| { - capabilities.code_action_provider = Some(provider_capability); - }); - notify_server_capabilities_updated(&server, cx); + .transpose()? + { + server.update_capabilities(|capabilities| { + capabilities.code_action_provider = + Some(lsp::CodeActionProviderCapability::Options(options)); + }); + notify_server_capabilities_updated(&server, cx); + } } "textDocument/definition" => { - let caps = parse_register_capabilities(reg)?; - server.update_capabilities(|capabilities| { - capabilities.definition_provider = Some(caps); - }); - notify_server_capabilities_updated(&server, cx); + if let Some(options) = parse_register_capabilities(reg)? { + server.update_capabilities(|capabilities| { + capabilities.definition_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); + } } "textDocument/completion" => { - let caps = reg + if let Some(caps) = reg .register_options .map(serde_json::from_value) .transpose()? - .unwrap_or_default(); - server.update_capabilities(|capabilities| { - capabilities.completion_provider = Some(caps); - }); - notify_server_capabilities_updated(&server, cx); + { + server.update_capabilities(|capabilities| { + capabilities.completion_provider = Some(caps); + }); + notify_server_capabilities_updated(&server, cx); + } } "textDocument/hover" => { - let caps = reg + if let Some(caps) = reg .register_options .map(serde_json::from_value) .transpose()? - .unwrap_or_else(|| lsp::HoverProviderCapability::Simple(true)); - server.update_capabilities(|capabilities| { - capabilities.hover_provider = Some(caps); - }); - notify_server_capabilities_updated(&server, cx); + { + server.update_capabilities(|capabilities| { + capabilities.hover_provider = Some(caps); + }); + notify_server_capabilities_updated(&server, cx); + } } "textDocument/signatureHelp" => { - let caps = reg + if let Some(caps) = reg .register_options .map(serde_json::from_value) .transpose()? - .unwrap_or_default(); - server.update_capabilities(|capabilities| { - capabilities.signature_help_provider = Some(caps); - }); - notify_server_capabilities_updated(&server, cx); + { + server.update_capabilities(|capabilities| { + capabilities.signature_help_provider = Some(caps); + }); + notify_server_capabilities_updated(&server, cx); + } } "textDocument/synchronization" => { - let caps = reg + if let Some(caps) = reg .register_options .map(serde_json::from_value) .transpose()? - .unwrap_or_else(|| { - lsp::TextDocumentSyncCapability::Options( - lsp::TextDocumentSyncOptions::default(), - ) + { + server.update_capabilities(|capabilities| { + capabilities.text_document_sync = Some(caps); }); - server.update_capabilities(|capabilities| { - capabilities.text_document_sync = Some(caps); - }); - notify_server_capabilities_updated(&server, cx); + notify_server_capabilities_updated(&server, cx); + } } "textDocument/codeLens" => { - let caps = reg + if let Some(caps) = reg .register_options .map(serde_json::from_value) .transpose()? - .unwrap_or_else(|| lsp::CodeLensOptions { - resolve_provider: None, + { + server.update_capabilities(|capabilities| { + capabilities.code_lens_provider = Some(caps); }); - server.update_capabilities(|capabilities| { - capabilities.code_lens_provider = Some(caps); - }); - notify_server_capabilities_updated(&server, cx); + notify_server_capabilities_updated(&server, cx); + } } "textDocument/diagnostic" => { - let caps = reg + if let Some(caps) = reg .register_options .map(serde_json::from_value) .transpose()? - .unwrap_or_else(|| { - lsp::DiagnosticServerCapabilities::RegistrationOptions( - lsp::DiagnosticRegistrationOptions::default(), - ) + { + server.update_capabilities(|capabilities| { + capabilities.diagnostic_provider = Some(caps); }); - server.update_capabilities(|capabilities| { - capabilities.diagnostic_provider = Some(caps); - }); - notify_server_capabilities_updated(&server, cx); + notify_server_capabilities_updated(&server, cx); + } } "textDocument/colorProvider" => { - let caps = reg + if let Some(caps) = reg .register_options .map(serde_json::from_value) .transpose()? - .unwrap_or_else(|| lsp::ColorProviderCapability::Simple(true)); - server.update_capabilities(|capabilities| { - capabilities.color_provider = Some(caps); - }); - notify_server_capabilities_updated(&server, cx); + { + server.update_capabilities(|capabilities| { + capabilities.color_provider = Some(caps); + }); + notify_server_capabilities_updated(&server, cx); + } } _ => log::warn!("unhandled capability registration: {reg:?}"), } @@ -12016,6 +12002,18 @@ impl LspStore { } } +// Registration with empty capabilities should be ignored. +// https://github.com/microsoft/vscode-languageserver-node/blob/d90a87f9557a0df9142cfb33e251cfa6fe27d970/client/src/common/formatting.ts#L67-L70 +fn parse_register_capabilities( + reg: lsp::Registration, +) -> anyhow::Result>> { + Ok(reg + .register_options + .map(|options| serde_json::from_value::(options)) + .transpose()? + .map(OneOf::Right)) +} + fn subscribe_to_binary_statuses( languages: &Arc, cx: &mut Context<'_, LspStore>, From 1a798830cb23586183f9a08048ac1d769cbbed8b Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 11 Aug 2025 23:08:58 -0600 Subject: [PATCH 073/109] Fix running vim tests with --features neovim (#36014) This was broken incidentally in https://github.com/zed-industries/zed/pull/33417 A better fix would be to fix app shutdown to take control of the executor so that we *can* run foreground tasks; but that is a bit fiddly (draft #36015) Release Notes: - N/A --- Cargo.lock | 1 + crates/gpui_macros/src/test.rs | 4 +++- crates/vim/Cargo.toml | 1 + crates/vim/src/test/vim_test_context.rs | 1 + 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 8a3e319a57..8d22eeafab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18025,6 +18025,7 @@ dependencies = [ "command_palette_hooks", "db", "editor", + "env_logger 0.11.8", "futures 0.3.31", "git_ui", "gpui", diff --git a/crates/gpui_macros/src/test.rs b/crates/gpui_macros/src/test.rs index 2c52149897..adb27f42ea 100644 --- a/crates/gpui_macros/src/test.rs +++ b/crates/gpui_macros/src/test.rs @@ -167,6 +167,7 @@ fn generate_test_function( )); cx_teardowns.extend(quote!( dispatcher.run_until_parked(); + #cx_varname.executor().forbid_parking(); #cx_varname.quit(); dispatcher.run_until_parked(); )); @@ -232,7 +233,7 @@ fn generate_test_function( cx_teardowns.extend(quote!( drop(#cx_varname_lock); dispatcher.run_until_parked(); - #cx_varname.update(|cx| { cx.quit() }); + #cx_varname.update(|cx| { cx.background_executor().forbid_parking(); cx.quit(); }); dispatcher.run_until_parked(); )); continue; @@ -247,6 +248,7 @@ fn generate_test_function( )); cx_teardowns.extend(quote!( dispatcher.run_until_parked(); + #cx_varname.executor().forbid_parking(); #cx_varname.quit(); dispatcher.run_until_parked(); )); diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index 9fb5c46564..434b14b07c 100644 --- a/crates/vim/Cargo.toml +++ b/crates/vim/Cargo.toml @@ -24,6 +24,7 @@ command_palette.workspace = true command_palette_hooks.workspace = true db.workspace = true editor.workspace = true +env_logger.workspace = true futures.workspace = true gpui.workspace = true itertools.workspace = true diff --git a/crates/vim/src/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index b8988b1d1f..904e48e5a3 100644 --- a/crates/vim/src/test/vim_test_context.rs +++ b/crates/vim/src/test/vim_test_context.rs @@ -15,6 +15,7 @@ impl VimTestContext { if cx.has_global::() { return; } + env_logger::try_init().ok(); cx.update(|cx| { let settings = SettingsStore::test(cx); cx.set_global(settings); From 52a9101970bc2994945445b8b7bdecb1ac43f35d Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 11 Aug 2025 23:20:09 -0600 Subject: [PATCH 074/109] vim: Add ctrl-y/e in insert mode (#36017) Closes #17292 Release Notes: - vim: Added ctrl-y/ctrl-e in insert mode to copy the next character from the line above or below --- assets/keymaps/vim.json | 4 ++ crates/vim/src/insert.rs | 46 +++++++++++++++++++- crates/vim/test_data/test_insert_ctrl_y.json | 5 +++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 crates/vim/test_data/test_insert_ctrl_y.json diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 57edb1e4c1..98f9cafc40 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -333,10 +333,14 @@ "ctrl-x ctrl-c": "editor::ShowEditPrediction", // zed specific "ctrl-x ctrl-l": "editor::ToggleCodeActions", // zed specific "ctrl-x ctrl-z": "editor::Cancel", + "ctrl-x ctrl-e": "vim::LineDown", + "ctrl-x ctrl-y": "vim::LineUp", "ctrl-w": "editor::DeleteToPreviousWordStart", "ctrl-u": "editor::DeleteToBeginningOfLine", "ctrl-t": "vim::Indent", "ctrl-d": "vim::Outdent", + "ctrl-y": "vim::InsertFromAbove", + "ctrl-e": "vim::InsertFromBelow", "ctrl-k": ["vim::PushDigraph", {}], "ctrl-v": ["vim::PushLiteral", {}], "ctrl-shift-v": "editor::Paste", // note: this is *very* similar to ctrl-v in vim, but ctrl-shift-v on linux is the typical shortcut for paste when ctrl-v is already in use. diff --git a/crates/vim/src/insert.rs b/crates/vim/src/insert.rs index 584057a8c0..8ef1cd7811 100644 --- a/crates/vim/src/insert.rs +++ b/crates/vim/src/insert.rs @@ -3,7 +3,9 @@ use editor::{Bias, Editor}; use gpui::{Action, Context, Window, actions}; use language::SelectionGoal; use settings::Settings; +use text::Point; use vim_mode_setting::HelixModeSetting; +use workspace::searchable::Direction; actions!( vim, @@ -11,13 +13,23 @@ actions!( /// Switches to normal mode with cursor positioned before the current character. NormalBefore, /// Temporarily switches to normal mode for one command. - TemporaryNormal + TemporaryNormal, + /// Inserts the next character from the line above into the current line. + InsertFromAbove, + /// Inserts the next character from the line below into the current line. + InsertFromBelow ] ); pub fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, Vim::normal_before); Vim::action(editor, cx, Vim::temporary_normal); + Vim::action(editor, cx, |vim, _: &InsertFromAbove, window, cx| { + vim.insert_around(Direction::Prev, window, cx) + }); + Vim::action(editor, cx, |vim, _: &InsertFromBelow, window, cx| { + vim.insert_around(Direction::Next, window, cx) + }) } impl Vim { @@ -71,6 +83,29 @@ impl Vim { self.switch_mode(Mode::Normal, true, window, cx); self.temp_mode = true; } + + fn insert_around(&mut self, direction: Direction, _: &mut Window, cx: &mut Context) { + self.update_editor(cx, |_, editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let mut edits = Vec::new(); + for selection in editor.selections.all::(cx) { + let point = selection.head(); + let new_row = match direction { + Direction::Next => point.row + 1, + Direction::Prev if point.row > 0 => point.row - 1, + _ => continue, + }; + let source = snapshot.clip_point(Point::new(new_row, point.column), Bias::Left); + if let Some(c) = snapshot.chars_at(source).next() + && c != '\n' + { + edits.push((point..point, c.to_string())) + } + } + + editor.edit(edits, cx); + }); + } } #[cfg(test)] @@ -156,4 +191,13 @@ mod test { .await; cx.shared_state().await.assert_eq("hehello\nˇllo\n"); } + + #[gpui::test] + async fn test_insert_ctrl_y(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state("hello\nˇ\nworld").await; + cx.simulate_shared_keystrokes("i ctrl-y ctrl-e").await; + cx.shared_state().await.assert_eq("hello\nhoˇ\nworld"); + } } diff --git a/crates/vim/test_data/test_insert_ctrl_y.json b/crates/vim/test_data/test_insert_ctrl_y.json new file mode 100644 index 0000000000..09b707a198 --- /dev/null +++ b/crates/vim/test_data/test_insert_ctrl_y.json @@ -0,0 +1,5 @@ +{"Put":{"state":"hello\nˇ\nworld"}} +{"Key":"i"} +{"Key":"ctrl-y"} +{"Key":"ctrl-e"} +{"Get":{"state":"hello\nhoˇ\nworld","mode":"Insert"}} From cc5eb2406691765ff624d217bc32b07519941280 Mon Sep 17 00:00:00 2001 From: Michael Sloan Date: Tue, 12 Aug 2025 00:47:54 -0600 Subject: [PATCH 075/109] zeta: Add latency telemetry for 1% of edit predictions (#36020) Release Notes: - N/A Co-authored-by: Oleksiy --- Cargo.lock | 1 + crates/zeta/Cargo.toml | 3 ++- crates/zeta/src/zeta.rs | 24 ++++++++++++++++++++++-- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8d22eeafab..79bce189e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20923,6 +20923,7 @@ dependencies = [ "menu", "postage", "project", + "rand 0.8.5", "regex", "release_channel", "reqwest_client", diff --git a/crates/zeta/Cargo.toml b/crates/zeta/Cargo.toml index 9f1d02b790..ee76308ff3 100644 --- a/crates/zeta/Cargo.toml +++ b/crates/zeta/Cargo.toml @@ -26,6 +26,7 @@ collections.workspace = true command_palette_hooks.workspace = true copilot.workspace = true db.workspace = true +edit_prediction.workspace = true editor.workspace = true feature_flags.workspace = true fs.workspace = true @@ -33,13 +34,13 @@ futures.workspace = true gpui.workspace = true http_client.workspace = true indoc.workspace = true -edit_prediction.workspace = true language.workspace = true language_model.workspace = true log.workspace = true menu.workspace = true postage.workspace = true project.workspace = true +rand.workspace = true regex.workspace = true release_channel.workspace = true serde.workspace = true diff --git a/crates/zeta/src/zeta.rs b/crates/zeta/src/zeta.rs index 6900082003..1a6a8c2934 100644 --- a/crates/zeta/src/zeta.rs +++ b/crates/zeta/src/zeta.rs @@ -429,6 +429,7 @@ impl Zeta { body, editable_range, } = gather_task.await?; + let done_gathering_context_at = Instant::now(); log::debug!( "Events:\n{}\nExcerpt:\n{:?}", @@ -481,6 +482,7 @@ impl Zeta { } }; + let received_response_at = Instant::now(); log::debug!("completion response: {}", &response.output_excerpt); if let Some(usage) = usage { @@ -492,7 +494,7 @@ impl Zeta { .ok(); } - Self::process_completion_response( + let edit_prediction = Self::process_completion_response( response, buffer, &snapshot, @@ -505,7 +507,25 @@ impl Zeta { buffer_snapshotted_at, &cx, ) - .await + .await; + + let finished_at = Instant::now(); + + // record latency for ~1% of requests + if rand::random::() <= 2 { + telemetry::event!( + "Edit Prediction Request", + context_latency = done_gathering_context_at + .duration_since(buffer_snapshotted_at) + .as_millis(), + request_latency = received_response_at + .duration_since(done_gathering_context_at) + .as_millis(), + process_latency = finished_at.duration_since(received_response_at).as_millis() + ); + } + + edit_prediction }) } From b61b71405d4a2d7725642ccbdda6c387efcc9693 Mon Sep 17 00:00:00 2001 From: Lukas Spiss <35728419+Spissable@users.noreply.github.com> Date: Tue, 12 Aug 2025 09:56:33 +0100 Subject: [PATCH 076/109] go: Add support for running sub-tests in table tests (#35657) One killer feature for the Go runner is to execute individual subtests within a table-test easily. Goland has had this feature forever, while in VSCode this has been notably missing. https://github.com/user-attachments/assets/363417a2-d1b1-43ca-8377-08ce062d6104 Release Notes: - Added support to run Go table-test subtests. --- crates/languages/src/go.rs | 345 +++++++++++++++++++++++++- crates/languages/src/go/runnables.scm | 100 ++++++++ 2 files changed, 439 insertions(+), 6 deletions(-) diff --git a/crates/languages/src/go.rs b/crates/languages/src/go.rs index 16c1b67203..14f646133b 100644 --- a/crates/languages/src/go.rs +++ b/crates/languages/src/go.rs @@ -487,6 +487,8 @@ const GO_MODULE_ROOT_TASK_VARIABLE: VariableName = VariableName::Custom(Cow::Borrowed("GO_MODULE_ROOT")); const GO_SUBTEST_NAME_TASK_VARIABLE: VariableName = VariableName::Custom(Cow::Borrowed("GO_SUBTEST_NAME")); +const GO_TABLE_TEST_CASE_NAME_TASK_VARIABLE: VariableName = + VariableName::Custom(Cow::Borrowed("GO_TABLE_TEST_CASE_NAME")); impl ContextProvider for GoContextProvider { fn build_context( @@ -545,10 +547,19 @@ impl ContextProvider for GoContextProvider { let go_subtest_variable = extract_subtest_name(_subtest_name.unwrap_or("")) .map(|subtest_name| (GO_SUBTEST_NAME_TASK_VARIABLE.clone(), subtest_name)); + let table_test_case_name = variables.get(&VariableName::Custom(Cow::Borrowed( + "_table_test_case_name", + ))); + + let go_table_test_case_variable = table_test_case_name + .and_then(extract_subtest_name) + .map(|case_name| (GO_TABLE_TEST_CASE_NAME_TASK_VARIABLE.clone(), case_name)); + Task::ready(Ok(TaskVariables::from_iter( [ go_package_variable, go_subtest_variable, + go_table_test_case_variable, go_module_root_variable, ] .into_iter() @@ -570,6 +581,28 @@ impl ContextProvider for GoContextProvider { let module_cwd = Some(GO_MODULE_ROOT_TASK_VARIABLE.template_value()); Task::ready(Some(TaskTemplates(vec![ + TaskTemplate { + label: format!( + "go test {} -v -run {}/{}", + GO_PACKAGE_TASK_VARIABLE.template_value(), + VariableName::Symbol.template_value(), + GO_TABLE_TEST_CASE_NAME_TASK_VARIABLE.template_value(), + ), + command: "go".into(), + args: vec![ + "test".into(), + "-v".into(), + "-run".into(), + format!( + "\\^{}\\$/\\^{}\\$", + VariableName::Symbol.template_value(), + GO_TABLE_TEST_CASE_NAME_TASK_VARIABLE.template_value(), + ), + ], + cwd: package_cwd.clone(), + tags: vec!["go-table-test-case".to_owned()], + ..TaskTemplate::default() + }, TaskTemplate { label: format!( "go test {} -run {}", @@ -842,10 +875,21 @@ mod tests { .collect() }); + let tag_strings: Vec = runnables + .iter() + .flat_map(|r| &r.runnable.tags) + .map(|tag| tag.0.to_string()) + .collect(); + assert!( - runnables.len() == 2, - "Should find test function and subtest with double quotes, found: {}", - runnables.len() + tag_strings.contains(&"go-test".to_string()), + "Should find go-test tag, found: {:?}", + tag_strings + ); + assert!( + tag_strings.contains(&"go-subtest".to_string()), + "Should find go-subtest tag, found: {:?}", + tag_strings ); let buffer = cx.new(|cx| { @@ -860,10 +904,299 @@ mod tests { .collect() }); + let tag_strings: Vec = runnables + .iter() + .flat_map(|r| &r.runnable.tags) + .map(|tag| tag.0.to_string()) + .collect(); + assert!( - runnables.len() == 2, - "Should find test function and subtest with backticks, found: {}", - runnables.len() + tag_strings.contains(&"go-test".to_string()), + "Should find go-test tag, found: {:?}", + tag_strings + ); + assert!( + tag_strings.contains(&"go-subtest".to_string()), + "Should find go-subtest tag, found: {:?}", + tag_strings + ); + } + + #[gpui::test] + fn test_go_table_test_slice_detection(cx: &mut TestAppContext) { + let language = language("go", tree_sitter_go::LANGUAGE.into()); + + let table_test = r#" + package main + + import "testing" + + func TestExample(t *testing.T) { + _ = "some random string" + + testCases := []struct{ + name string + anotherStr string + }{ + { + name: "test case 1", + anotherStr: "foo", + }, + { + name: "test case 2", + anotherStr: "bar", + }, + } + + notATableTest := []struct{ + name string + }{ + { + name: "some string", + }, + { + name: "some other string", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // test code here + }) + } + } + "#; + + let buffer = + cx.new(|cx| crate::Buffer::local(table_test, cx).with_language(language.clone(), cx)); + cx.executor().run_until_parked(); + + let runnables: Vec<_> = buffer.update(cx, |buffer, _| { + let snapshot = buffer.snapshot(); + snapshot.runnable_ranges(0..table_test.len()).collect() + }); + + let tag_strings: Vec = runnables + .iter() + .flat_map(|r| &r.runnable.tags) + .map(|tag| tag.0.to_string()) + .collect(); + + assert!( + tag_strings.contains(&"go-test".to_string()), + "Should find go-test tag, found: {:?}", + tag_strings + ); + assert!( + tag_strings.contains(&"go-table-test-case".to_string()), + "Should find go-table-test-case tag, found: {:?}", + tag_strings + ); + + let go_test_count = tag_strings.iter().filter(|&tag| tag == "go-test").count(); + let go_table_test_count = tag_strings + .iter() + .filter(|&tag| tag == "go-table-test-case") + .count(); + + assert!( + go_test_count == 1, + "Should find exactly 1 go-test, found: {}", + go_test_count + ); + assert!( + go_table_test_count == 2, + "Should find exactly 2 go-table-test-case, found: {}", + go_table_test_count + ); + } + + #[gpui::test] + fn test_go_table_test_slice_ignored(cx: &mut TestAppContext) { + let language = language("go", tree_sitter_go::LANGUAGE.into()); + + let table_test = r#" + package main + + func Example() { + _ = "some random string" + + notATableTest := []struct{ + name string + }{ + { + name: "some string", + }, + { + name: "some other string", + }, + } + } + "#; + + let buffer = + cx.new(|cx| crate::Buffer::local(table_test, cx).with_language(language.clone(), cx)); + cx.executor().run_until_parked(); + + let runnables: Vec<_> = buffer.update(cx, |buffer, _| { + let snapshot = buffer.snapshot(); + snapshot.runnable_ranges(0..table_test.len()).collect() + }); + + let tag_strings: Vec = runnables + .iter() + .flat_map(|r| &r.runnable.tags) + .map(|tag| tag.0.to_string()) + .collect(); + + assert!( + !tag_strings.contains(&"go-test".to_string()), + "Should find go-test tag, found: {:?}", + tag_strings + ); + assert!( + !tag_strings.contains(&"go-table-test-case".to_string()), + "Should find go-table-test-case tag, found: {:?}", + tag_strings + ); + } + + #[gpui::test] + fn test_go_table_test_map_detection(cx: &mut TestAppContext) { + let language = language("go", tree_sitter_go::LANGUAGE.into()); + + let table_test = r#" + package main + + import "testing" + + func TestExample(t *testing.T) { + _ = "some random string" + + testCases := map[string]struct { + someStr string + fail bool + }{ + "test failure": { + someStr: "foo", + fail: true, + }, + "test success": { + someStr: "bar", + fail: false, + }, + } + + notATableTest := map[string]struct { + someStr string + }{ + "some string": { + someStr: "foo", + }, + "some other string": { + someStr: "bar", + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + // test code here + }) + } + } + "#; + + let buffer = + cx.new(|cx| crate::Buffer::local(table_test, cx).with_language(language.clone(), cx)); + cx.executor().run_until_parked(); + + let runnables: Vec<_> = buffer.update(cx, |buffer, _| { + let snapshot = buffer.snapshot(); + snapshot.runnable_ranges(0..table_test.len()).collect() + }); + + let tag_strings: Vec = runnables + .iter() + .flat_map(|r| &r.runnable.tags) + .map(|tag| tag.0.to_string()) + .collect(); + + assert!( + tag_strings.contains(&"go-test".to_string()), + "Should find go-test tag, found: {:?}", + tag_strings + ); + assert!( + tag_strings.contains(&"go-table-test-case".to_string()), + "Should find go-table-test-case tag, found: {:?}", + tag_strings + ); + + let go_test_count = tag_strings.iter().filter(|&tag| tag == "go-test").count(); + let go_table_test_count = tag_strings + .iter() + .filter(|&tag| tag == "go-table-test-case") + .count(); + + assert!( + go_test_count == 1, + "Should find exactly 1 go-test, found: {}", + go_test_count + ); + assert!( + go_table_test_count == 2, + "Should find exactly 2 go-table-test-case, found: {}", + go_table_test_count + ); + } + + #[gpui::test] + fn test_go_table_test_map_ignored(cx: &mut TestAppContext) { + let language = language("go", tree_sitter_go::LANGUAGE.into()); + + let table_test = r#" + package main + + func Example() { + _ = "some random string" + + notATableTest := map[string]struct { + someStr string + }{ + "some string": { + someStr: "foo", + }, + "some other string": { + someStr: "bar", + }, + } + } + "#; + + let buffer = + cx.new(|cx| crate::Buffer::local(table_test, cx).with_language(language.clone(), cx)); + cx.executor().run_until_parked(); + + let runnables: Vec<_> = buffer.update(cx, |buffer, _| { + let snapshot = buffer.snapshot(); + snapshot.runnable_ranges(0..table_test.len()).collect() + }); + + let tag_strings: Vec = runnables + .iter() + .flat_map(|r| &r.runnable.tags) + .map(|tag| tag.0.to_string()) + .collect(); + + assert!( + !tag_strings.contains(&"go-test".to_string()), + "Should find go-test tag, found: {:?}", + tag_strings + ); + assert!( + !tag_strings.contains(&"go-table-test-case".to_string()), + "Should find go-table-test-case tag, found: {:?}", + tag_strings ); } diff --git a/crates/languages/src/go/runnables.scm b/crates/languages/src/go/runnables.scm index 6418cd04d8..f56262f799 100644 --- a/crates/languages/src/go/runnables.scm +++ b/crates/languages/src/go/runnables.scm @@ -91,3 +91,103 @@ ) @_ (#set! tag go-main) ) + +; Table test cases - slice and map +( + (short_var_declaration + left: (expression_list (identifier) @_collection_var) + right: (expression_list + (composite_literal + type: [ + (slice_type) + (map_type + key: (type_identifier) @_key_type + (#eq? @_key_type "string") + ) + ] + body: (literal_value + [ + (literal_element + (literal_value + (keyed_element + (literal_element + (identifier) @_field_name + ) + (literal_element + [ + (interpreted_string_literal) @run @_table_test_case_name + (raw_string_literal) @run @_table_test_case_name + ] + ) + ) + ) + ) + (keyed_element + (literal_element + [ + (interpreted_string_literal) @run @_table_test_case_name + (raw_string_literal) @run @_table_test_case_name + ] + ) + ) + ] + ) + ) + ) + ) + (for_statement + (range_clause + left: (expression_list + [ + ( + (identifier) + (identifier) @_loop_var + ) + (identifier) @_loop_var + ] + ) + right: (identifier) @_range_var + (#eq? @_range_var @_collection_var) + ) + body: (block + (expression_statement + (call_expression + function: (selector_expression + operand: (identifier) @_t_var + field: (field_identifier) @_run_method + (#eq? @_run_method "Run") + ) + arguments: (argument_list + . + [ + (selector_expression + operand: (identifier) @_tc_var + (#eq? @_tc_var @_loop_var) + field: (field_identifier) @_field_check + (#eq? @_field_check @_field_name) + ) + (identifier) @_arg_var + (#eq? @_arg_var @_loop_var) + ] + . + (func_literal + parameters: (parameter_list + (parameter_declaration + type: (pointer_type + (qualified_type + package: (package_identifier) @_pkg + name: (type_identifier) @_type + (#eq? @_pkg "testing") + (#eq? @_type "T") + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) @_ + (#set! tag go-table-test-case) +) From 13bf45dd4a773bd31a907698d0498a5ee745729f Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Tue, 12 Aug 2025 14:10:53 +0200 Subject: [PATCH 077/109] python: Fix toolchain serialization not working with multiple venvs in a single worktree (#36035) Our database did not allow more than entry for a given toolchain for a single worktree (due to incorrect primary key) Co-authored-by: Lukas Wirth Release Notes: - Python: Fixed toolchain selector not working with multiple venvs in a single worktree. Co-authored-by: Lukas Wirth --- crates/workspace/src/persistence.rs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 6fa5c969e7..b2d1340a7b 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -542,6 +542,20 @@ define_connection! { ALTER TABLE breakpoints ADD COLUMN condition TEXT; ALTER TABLE breakpoints ADD COLUMN hit_condition TEXT; ), + sql!(CREATE TABLE toolchains2 ( + workspace_id INTEGER, + worktree_id INTEGER, + language_name TEXT NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL, + raw_json TEXT NOT NULL, + relative_worktree_path TEXT NOT NULL, + PRIMARY KEY (workspace_id, worktree_id, language_name, relative_worktree_path)) STRICT; + INSERT INTO toolchains2 + SELECT * FROM toolchains; + DROP TABLE toolchains; + ALTER TABLE toolchains2 RENAME TO toolchains; + ) ]; } @@ -1428,12 +1442,12 @@ impl WorkspaceDb { self.write(move |conn| { let mut insert = conn .exec_bound(sql!( - INSERT INTO toolchains(workspace_id, worktree_id, relative_worktree_path, language_name, name, path) VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO toolchains(workspace_id, worktree_id, relative_worktree_path, language_name, name, path, raw_json) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO UPDATE SET name = ?5, - path = ?6 - + path = ?6, + raw_json = ?7 )) .context("Preparing insertion")?; @@ -1444,6 +1458,7 @@ impl WorkspaceDb { toolchain.language_name.as_ref(), toolchain.name.as_ref(), toolchain.path.as_ref(), + toolchain.as_json.to_string(), ))?; Ok(()) From 244432175669cf3bc4c1c49c794692e8f0947fd3 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 12 Aug 2025 14:17:48 +0200 Subject: [PATCH 078/109] Support profiles in agent2 (#36034) We still need a profile selector. Release Notes: - N/A --------- Co-authored-by: Ben Brandt --- Cargo.lock | 1 + crates/acp_thread/src/acp_thread.rs | 51 ++++ crates/agent2/Cargo.toml | 2 + crates/agent2/src/agent.rs | 34 ++- crates/agent2/src/tests/mod.rs | 142 +++++++++-- crates/agent2/src/thread.rs | 87 +++++-- crates/agent2/src/tools.rs | 2 + .../src/tools/context_server_registry.rs | 231 ++++++++++++++++++ crates/agent2/src/tools/diagnostics_tool.rs | 18 +- crates/agent2/src/tools/edit_file_tool.rs | 66 ++++- crates/agent2/src/tools/fetch_tool.rs | 8 +- crates/agent2/src/tools/find_path_tool.rs | 3 - crates/agent2/src/tools/grep_tool.rs | 25 +- crates/agent2/src/tools/now_tool.rs | 11 +- crates/agent_settings/src/agent_profile.rs | 14 ++ 15 files changed, 587 insertions(+), 108 deletions(-) create mode 100644 crates/agent2/src/tools/context_server_registry.rs diff --git a/Cargo.lock b/Cargo.lock index 79bce189e2..dc28a1cb44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,6 +196,7 @@ dependencies = [ "clock", "cloud_llm_client", "collections", + "context_server", "ctor", "editor", "env_logger 0.11.8", diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index d632e6e570..1c0a9479df 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -254,6 +254,15 @@ impl ToolCall { } if let Some(raw_output) = raw_output { + if self.content.is_empty() { + if let Some(markdown) = markdown_for_raw_output(&raw_output, &language_registry, cx) + { + self.content + .push(ToolCallContent::ContentBlock(ContentBlock::Markdown { + markdown, + })); + } + } self.raw_output = Some(raw_output); } } @@ -1266,6 +1275,48 @@ impl AcpThread { } } +fn markdown_for_raw_output( + raw_output: &serde_json::Value, + language_registry: &Arc, + cx: &mut App, +) -> Option> { + match raw_output { + serde_json::Value::Null => None, + serde_json::Value::Bool(value) => Some(cx.new(|cx| { + Markdown::new( + value.to_string().into(), + Some(language_registry.clone()), + None, + cx, + ) + })), + serde_json::Value::Number(value) => Some(cx.new(|cx| { + Markdown::new( + value.to_string().into(), + Some(language_registry.clone()), + None, + cx, + ) + })), + serde_json::Value::String(value) => Some(cx.new(|cx| { + Markdown::new( + value.clone().into(), + Some(language_registry.clone()), + None, + cx, + ) + })), + value => Some(cx.new(|cx| { + Markdown::new( + format!("```json\n{}\n```", value).into(), + Some(language_registry.clone()), + None, + cx, + ) + })), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml index 7ee48aca04..1030380dc0 100644 --- a/crates/agent2/Cargo.toml +++ b/crates/agent2/Cargo.toml @@ -23,6 +23,7 @@ assistant_tools.workspace = true chrono.workspace = true cloud_llm_client.workspace = true collections.workspace = true +context_server.workspace = true fs.workspace = true futures.workspace = true gpui.workspace = true @@ -60,6 +61,7 @@ workspace-hack.workspace = true ctor.workspace = true client = { workspace = true, "features" = ["test-support"] } clock = { workspace = true, "features" = ["test-support"] } +context_server = { workspace = true, "features" = ["test-support"] } editor = { workspace = true, "features" = ["test-support"] } env_logger.workspace = true fs = { workspace = true, "features" = ["test-support"] } diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index 66893f49f9..18a830b978 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -1,8 +1,8 @@ use crate::{AgentResponseEvent, Thread, templates::Templates}; use crate::{ - CopyPathTool, CreateDirectoryTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, - GrepTool, ListDirectoryTool, MovePathTool, NowTool, OpenTool, ReadFileTool, TerminalTool, - ThinkingTool, ToolCallAuthorization, WebSearchTool, + ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DiagnosticsTool, EditFileTool, + FetchTool, FindPathTool, GrepTool, ListDirectoryTool, MovePathTool, NowTool, OpenTool, + ReadFileTool, TerminalTool, ThinkingTool, ToolCallAuthorization, WebSearchTool, }; use acp_thread::ModelSelector; use agent_client_protocol as acp; @@ -55,6 +55,7 @@ pub struct NativeAgent { project_context: Rc>, project_context_needs_refresh: watch::Sender<()>, _maintain_project_context: Task>, + context_server_registry: Entity, /// Shared templates for all threads templates: Arc, project: Entity, @@ -90,6 +91,9 @@ impl NativeAgent { _maintain_project_context: cx.spawn(async move |this, cx| { Self::maintain_project_context(this, project_context_needs_refresh_rx, cx).await }), + context_server_registry: cx.new(|cx| { + ContextServerRegistry::new(project.read(cx).context_server_store(), cx) + }), templates, project, prompt_store, @@ -385,7 +389,13 @@ impl acp_thread::AgentConnection for NativeAgentConnection { // Create AcpThread let acp_thread = cx.update(|cx| { cx.new(|cx| { - acp_thread::AcpThread::new("agent2", self.clone(), project.clone(), session_id.clone(), cx) + acp_thread::AcpThread::new( + "agent2", + self.clone(), + project.clone(), + session_id.clone(), + cx, + ) }) })?; let action_log = cx.update(|cx| acp_thread.read(cx).action_log().clone())?; @@ -413,11 +423,21 @@ impl acp_thread::AgentConnection for NativeAgentConnection { }) .ok_or_else(|| { log::warn!("No default model configured in settings"); - anyhow!("No default model configured. Please configure a default model in settings.") + anyhow!( + "No default model. Please configure a default model in settings." + ) })?; let thread = cx.new(|cx| { - let mut thread = Thread::new(project.clone(), agent.project_context.clone(), action_log.clone(), agent.templates.clone(), default_model); + let mut thread = Thread::new( + project.clone(), + agent.project_context.clone(), + agent.context_server_registry.clone(), + action_log.clone(), + agent.templates.clone(), + default_model, + cx, + ); thread.add_tool(CreateDirectoryTool::new(project.clone())); thread.add_tool(CopyPathTool::new(project.clone())); thread.add_tool(DiagnosticsTool::new(project.clone())); @@ -450,7 +470,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection { acp_thread: acp_thread.downgrade(), _subscription: cx.observe_release(&acp_thread, |this, acp_thread, _cx| { this.sessions.remove(acp_thread.session_id()); - }) + }), }, ); })?; diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index d6aaddf2c2..7f4b934c08 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -2,6 +2,7 @@ use super::*; use acp_thread::AgentConnection; use action_log::ActionLog; use agent_client_protocol::{self as acp}; +use agent_settings::AgentProfileId; use anyhow::Result; use client::{Client, UserStore}; use fs::{FakeFs, Fs}; @@ -165,7 +166,9 @@ async fn test_basic_tool_calls(cx: &mut TestAppContext) { } else { false } - }) + }), + "{}", + thread.to_markdown() ); }); } @@ -469,6 +472,82 @@ async fn test_concurrent_tool_calls(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_profiles(cx: &mut TestAppContext) { + let ThreadTest { + model, thread, fs, .. + } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + thread.update(cx, |thread, _cx| { + thread.add_tool(DelayTool); + thread.add_tool(EchoTool); + thread.add_tool(InfiniteTool); + }); + + // Override profiles and wait for settings to be loaded. + fs.insert_file( + paths::settings_file(), + json!({ + "agent": { + "profiles": { + "test-1": { + "name": "Test Profile 1", + "tools": { + EchoTool.name(): true, + DelayTool.name(): true, + } + }, + "test-2": { + "name": "Test Profile 2", + "tools": { + InfiniteTool.name(): true, + } + } + } + } + }) + .to_string() + .into_bytes(), + ) + .await; + cx.run_until_parked(); + + // Test that test-1 profile (default) has echo and delay tools + thread.update(cx, |thread, cx| { + thread.set_profile(AgentProfileId("test-1".into())); + thread.send("test", cx); + }); + cx.run_until_parked(); + + let mut pending_completions = fake_model.pending_completions(); + assert_eq!(pending_completions.len(), 1); + let completion = pending_completions.pop().unwrap(); + let tool_names: Vec = completion + .tools + .iter() + .map(|tool| tool.name.clone()) + .collect(); + 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. + thread.update(cx, |thread, cx| { + thread.set_profile(AgentProfileId("test-2".into())); + thread.send("test2", cx) + }); + cx.run_until_parked(); + let mut pending_completions = fake_model.pending_completions(); + assert_eq!(pending_completions.len(), 1); + let completion = pending_completions.pop().unwrap(); + let tool_names: Vec = completion + .tools + .iter() + .map(|tool| tool.name.clone()) + .collect(); + assert_eq!(tool_names, vec![InfiniteTool.name()]); +} + #[gpui::test] #[ignore = "can't run on CI yet"] async fn test_cancellation(cx: &mut TestAppContext) { @@ -595,6 +674,7 @@ async fn test_agent_connection(cx: &mut TestAppContext) { language_models::init(user_store.clone(), client.clone(), cx); Project::init_settings(cx); LanguageModelRegistry::test(cx); + agent_settings::init(cx); }); cx.executor().forbid_parking(); @@ -790,6 +870,7 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { id: acp::ToolCallId("1".into()), fields: acp::ToolCallUpdateFields { status: Some(acp::ToolCallStatus::Completed), + raw_output: Some("Finished thinking.".into()), ..Default::default() }, } @@ -813,6 +894,7 @@ struct ThreadTest { model: Arc, thread: Entity, project_context: Rc>, + fs: Arc, } enum TestModel { @@ -835,30 +917,57 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest { cx.executor().allow_parking(); let fs = FakeFs::new(cx.background_executor.clone()); + fs.create_dir(paths::settings_file().parent().unwrap()) + .await + .unwrap(); + fs.insert_file( + paths::settings_file(), + json!({ + "agent": { + "default_profile": "test-profile", + "profiles": { + "test-profile": { + "name": "Test Profile", + "tools": { + EchoTool.name(): true, + DelayTool.name(): true, + WordListTool.name(): true, + ToolRequiringPermission.name(): true, + InfiniteTool.name(): true, + } + } + } + } + }) + .to_string() + .into_bytes(), + ) + .await; cx.update(|cx| { settings::init(cx); - watch_settings(fs.clone(), cx); Project::init_settings(cx); agent_settings::init(cx); + gpui_tokio::init(cx); + let http_client = ReqwestClient::user_agent("agent tests").unwrap(); + cx.set_http_client(Arc::new(http_client)); + + client::init_settings(cx); + let client = Client::production(cx); + let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + language_model::init(client.clone(), cx); + language_models::init(user_store.clone(), client.clone(), cx); + + watch_settings(fs.clone(), cx); }); + let templates = Templates::new(); fs.insert_tree(path!("/test"), json!({})).await; - let project = Project::test(fs, [path!("/test").as_ref()], cx).await; + let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; let model = cx .update(|cx| { - gpui_tokio::init(cx); - let http_client = ReqwestClient::user_agent("agent tests").unwrap(); - cx.set_http_client(Arc::new(http_client)); - - client::init_settings(cx); - let client = Client::production(cx); - let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - language_model::init(client.clone(), cx); - language_models::init(user_store.clone(), client.clone(), cx); - if let TestModel::Fake = model { Task::ready(Arc::new(FakeLanguageModel::default()) as Arc<_>) } else { @@ -881,20 +990,25 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest { .await; let project_context = Rc::new(RefCell::new(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(|_| { + let thread = cx.new(|cx| { Thread::new( project, project_context.clone(), + context_server_registry, action_log, templates, model.clone(), + cx, ) }); ThreadTest { model, thread, project_context, + fs, } } diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 23a0f7972d..231f83ce20 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -1,7 +1,7 @@ -use crate::{SystemPromptTemplate, Template, Templates}; +use crate::{ContextServerRegistry, SystemPromptTemplate, Template, Templates}; use action_log::ActionLog; use agent_client_protocol as acp; -use agent_settings::AgentSettings; +use agent_settings::{AgentProfileId, AgentSettings}; use anyhow::{Context as _, Result, anyhow}; use assistant_tool::adapt_schema_to_format; use cloud_llm_client::{CompletionIntent, CompletionMode}; @@ -126,6 +126,8 @@ pub struct Thread { running_turn: Option>, pending_tool_uses: HashMap, tools: BTreeMap>, + context_server_registry: Entity, + profile_id: AgentProfileId, project_context: Rc>, templates: Arc, pub selected_model: Arc, @@ -137,16 +139,21 @@ impl Thread { pub fn new( project: Entity, project_context: Rc>, + context_server_registry: Entity, action_log: Entity, templates: Arc, default_model: Arc, + cx: &mut Context, ) -> Self { + let profile_id = AgentSettings::get_global(cx).default_profile.clone(); Self { messages: Vec::new(), completion_mode: CompletionMode::Normal, running_turn: None, pending_tool_uses: HashMap::default(), tools: BTreeMap::default(), + context_server_registry, + profile_id, project_context, templates, selected_model: default_model, @@ -179,6 +186,10 @@ impl Thread { self.tools.remove(name).is_some() } + pub fn set_profile(&mut self, profile_id: AgentProfileId) { + self.profile_id = profile_id; + } + pub fn cancel(&mut self) { self.running_turn.take(); @@ -298,6 +309,7 @@ impl Thread { } else { acp::ToolCallStatus::Completed }), + raw_output: tool_result.output.clone(), ..Default::default() }, ); @@ -604,21 +616,23 @@ impl Thread { let messages = self.build_request_messages(); log::info!("Request will include {} messages", messages.len()); - let tools: Vec = self - .tools - .values() - .filter_map(|tool| { - let tool_name = tool.name().to_string(); - log::trace!("Including tool: {}", tool_name); - Some(LanguageModelRequestTool { - name: tool_name, - description: tool.description(cx).to_string(), - input_schema: tool - .input_schema(self.selected_model.tool_input_format()) - .log_err()?, + 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(self.selected_model.tool_input_format()) + .log_err()?, + }) }) - }) - .collect(); + .collect() + } else { + Vec::new() + }; log::info!("Request includes {} tools", tools.len()); @@ -639,6 +653,35 @@ impl Thread { request } + fn tools<'a>(&'a self, cx: &'a App) -> Result>> { + let profile = AgentSettings::get_global(cx) + .profiles + .get(&self.profile_id) + .context("profile not found")?; + + Ok(self + .tools + .iter() + .filter_map(|(tool_name, tool)| { + if profile.is_tool_enabled(tool_name) { + Some(tool) + } 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 + } + }) + }, + ))) + } + fn build_request_messages(&self) -> Vec { log::trace!( "Building request messages from {} thread messages", @@ -686,7 +729,7 @@ where fn name(&self) -> SharedString; - fn description(&self, _cx: &mut App) -> SharedString { + fn description(&self) -> SharedString { let schema = schemars::schema_for!(Self::Input); SharedString::new( schema @@ -722,13 +765,13 @@ where pub struct Erased(T); pub struct AgentToolOutput { - llm_output: LanguageModelToolResultContent, - raw_output: serde_json::Value, + pub llm_output: LanguageModelToolResultContent, + pub raw_output: serde_json::Value, } pub trait AnyAgentTool { fn name(&self) -> SharedString; - fn description(&self, cx: &mut App) -> SharedString; + fn description(&self) -> SharedString; fn kind(&self) -> acp::ToolKind; fn initial_title(&self, input: serde_json::Value) -> SharedString; fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result; @@ -748,8 +791,8 @@ where self.0.name() } - fn description(&self, cx: &mut App) -> SharedString { - self.0.description(cx) + fn description(&self) -> SharedString { + self.0.description() } fn kind(&self) -> agent_client_protocol::ToolKind { diff --git a/crates/agent2/src/tools.rs b/crates/agent2/src/tools.rs index 8896b14538..d1f2b3b1c7 100644 --- a/crates/agent2/src/tools.rs +++ b/crates/agent2/src/tools.rs @@ -1,3 +1,4 @@ +mod context_server_registry; mod copy_path_tool; mod create_directory_tool; mod delete_path_tool; @@ -15,6 +16,7 @@ mod terminal_tool; mod thinking_tool; mod web_search_tool; +pub use context_server_registry::*; pub use copy_path_tool::*; pub use create_directory_tool::*; pub use delete_path_tool::*; diff --git a/crates/agent2/src/tools/context_server_registry.rs b/crates/agent2/src/tools/context_server_registry.rs new file mode 100644 index 0000000000..db39e9278c --- /dev/null +++ b/crates/agent2/src/tools/context_server_registry.rs @@ -0,0 +1,231 @@ +use crate::{AgentToolOutput, AnyAgentTool, ToolCallEventStream}; +use agent_client_protocol::ToolKind; +use anyhow::{Result, anyhow, bail}; +use collections::{BTreeMap, HashMap}; +use context_server::ContextServerId; +use gpui::{App, Context, Entity, SharedString, Task}; +use project::context_server_store::{ContextServerStatus, ContextServerStore}; +use std::sync::Arc; +use util::ResultExt; + +pub struct ContextServerRegistry { + server_store: Entity, + registered_servers: HashMap, + _subscription: gpui::Subscription, +} + +struct RegisteredContextServer { + tools: BTreeMap>, + load_tools: Task>, +} + +impl ContextServerRegistry { + pub fn new(server_store: Entity, cx: &mut Context) -> Self { + let mut this = Self { + server_store: server_store.clone(), + registered_servers: HashMap::default(), + _subscription: cx.subscribe(&server_store, Self::handle_context_server_store_event), + }; + for server in server_store.read(cx).running_servers() { + this.reload_tools_for_server(server.id(), cx); + } + this + } + + pub fn servers( + &self, + ) -> impl Iterator< + Item = ( + &ContextServerId, + &BTreeMap>, + ), + > { + self.registered_servers + .iter() + .map(|(id, server)| (id, &server.tools)) + } + + fn reload_tools_for_server(&mut self, server_id: ContextServerId, cx: &mut Context) { + let Some(server) = self.server_store.read(cx).get_running_server(&server_id) else { + return; + }; + let Some(client) = server.client() else { + return; + }; + if !client.capable(context_server::protocol::ServerCapability::Tools) { + return; + } + + let registered_server = + self.registered_servers + .entry(server_id.clone()) + .or_insert(RegisteredContextServer { + tools: BTreeMap::default(), + load_tools: Task::ready(Ok(())), + }); + registered_server.load_tools = cx.spawn(async move |this, cx| { + let response = client + .request::(()) + .await; + + this.update(cx, |this, cx| { + let Some(registered_server) = this.registered_servers.get_mut(&server_id) else { + return; + }; + + registered_server.tools.clear(); + if let Some(response) = response.log_err() { + for tool in response.tools { + let tool = Arc::new(ContextServerTool::new( + this.server_store.clone(), + server.id(), + tool, + )); + registered_server.tools.insert(tool.name(), tool); + } + cx.notify(); + } + }) + }); + } + + fn handle_context_server_store_event( + &mut self, + _: Entity, + event: &project::context_server_store::Event, + cx: &mut Context, + ) { + match event { + project::context_server_store::Event::ServerStatusChanged { server_id, status } => { + match status { + ContextServerStatus::Starting => {} + ContextServerStatus::Running => { + self.reload_tools_for_server(server_id.clone(), cx); + } + ContextServerStatus::Stopped | ContextServerStatus::Error(_) => { + self.registered_servers.remove(&server_id); + cx.notify(); + } + } + } + } + } +} + +struct ContextServerTool { + store: Entity, + server_id: ContextServerId, + tool: context_server::types::Tool, +} + +impl ContextServerTool { + fn new( + store: Entity, + server_id: ContextServerId, + tool: context_server::types::Tool, + ) -> Self { + Self { + store, + server_id, + tool, + } + } +} + +impl AnyAgentTool for ContextServerTool { + fn name(&self) -> SharedString { + self.tool.name.clone().into() + } + + fn description(&self) -> SharedString { + self.tool.description.clone().unwrap_or_default().into() + } + + fn kind(&self) -> ToolKind { + ToolKind::Other + } + + fn initial_title(&self, _input: serde_json::Value) -> SharedString { + format!("Run MCP tool `{}`", self.tool.name).into() + } + + fn input_schema( + &self, + format: language_model::LanguageModelToolSchemaFormat, + ) -> Result { + let mut schema = self.tool.input_schema.clone(); + assistant_tool::adapt_schema_to_format(&mut schema, format)?; + Ok(match schema { + serde_json::Value::Null => { + serde_json::json!({ "type": "object", "properties": [] }) + } + serde_json::Value::Object(map) if map.is_empty() => { + serde_json::json!({ "type": "object", "properties": [] }) + } + _ => schema, + }) + } + + fn run( + self: Arc, + input: serde_json::Value, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let Some(server) = self.store.read(cx).get_running_server(&self.server_id) else { + return Task::ready(Err(anyhow!("Context server not found"))); + }; + let tool_name = self.tool.name.clone(); + let server_clone = server.clone(); + let input_clone = input.clone(); + + cx.spawn(async move |_cx| { + let Some(protocol) = server_clone.client() else { + bail!("Context server not initialized"); + }; + + let arguments = if let serde_json::Value::Object(map) = input_clone { + Some(map.into_iter().collect()) + } else { + None + }; + + log::trace!( + "Running tool: {} with arguments: {:?}", + tool_name, + arguments + ); + let response = protocol + .request::( + context_server::types::CallToolParams { + name: tool_name, + arguments, + meta: None, + }, + ) + .await?; + + let mut result = String::new(); + for content in response.content { + match content { + context_server::types::ToolResponseContent::Text { text } => { + result.push_str(&text); + } + context_server::types::ToolResponseContent::Image { .. } => { + log::warn!("Ignoring image content from tool response"); + } + context_server::types::ToolResponseContent::Audio { .. } => { + log::warn!("Ignoring audio content from tool response"); + } + context_server::types::ToolResponseContent::Resource { .. } => { + log::warn!("Ignoring resource content from tool response"); + } + } + } + Ok(AgentToolOutput { + raw_output: result.clone().into(), + llm_output: result.into(), + }) + }) + } +} diff --git a/crates/agent2/src/tools/diagnostics_tool.rs b/crates/agent2/src/tools/diagnostics_tool.rs index bd0b20df5a..6ba8b7b377 100644 --- a/crates/agent2/src/tools/diagnostics_tool.rs +++ b/crates/agent2/src/tools/diagnostics_tool.rs @@ -85,7 +85,7 @@ impl AgentTool for DiagnosticsTool { fn run( self: Arc, input: Self::Input, - event_stream: ToolCallEventStream, + _event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { match input.path { @@ -119,11 +119,6 @@ impl AgentTool for DiagnosticsTool { range.start.row + 1, entry.diagnostic.message )?; - - event_stream.update_fields(acp::ToolCallUpdateFields { - content: Some(vec![output.clone().into()]), - ..Default::default() - }); } if output.is_empty() { @@ -158,18 +153,9 @@ impl AgentTool for DiagnosticsTool { } if has_diagnostics { - event_stream.update_fields(acp::ToolCallUpdateFields { - content: Some(vec![output.clone().into()]), - ..Default::default() - }); Task::ready(Ok(output)) } else { - let text = "No errors or warnings found in the project."; - event_stream.update_fields(acp::ToolCallUpdateFields { - content: Some(vec![text.into()]), - ..Default::default() - }); - Task::ready(Ok(text.into())) + Task::ready(Ok("No errors or warnings found in the project.".into())) } } } diff --git a/crates/agent2/src/tools/edit_file_tool.rs b/crates/agent2/src/tools/edit_file_tool.rs index 88764d1953..134bc5e5e4 100644 --- a/crates/agent2/src/tools/edit_file_tool.rs +++ b/crates/agent2/src/tools/edit_file_tool.rs @@ -454,9 +454,8 @@ fn resolve_path( #[cfg(test)] mod tests { - use crate::Templates; - use super::*; + use crate::{ContextServerRegistry, Templates}; use action_log::ActionLog; use client::TelemetrySettings; use fs::Fs; @@ -475,9 +474,20 @@ mod tests { fs.insert_tree("/root", json!({})).await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; 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()); - let thread = - cx.new(|_| Thread::new(project, Rc::default(), action_log, Templates::new(), model)); + let thread = cx.new(|cx| { + Thread::new( + project, + Rc::default(), + context_server_registry, + action_log, + Templates::new(), + model, + cx, + ) + }); let result = cx .update(|cx| { let input = EditFileToolInput { @@ -661,14 +671,18 @@ 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()); - let thread = cx.new(|_| { + let thread = cx.new(|cx| { Thread::new( project, Rc::default(), + context_server_registry, action_log.clone(), Templates::new(), model.clone(), + cx, ) }); @@ -792,15 +806,19 @@ mod tests { .unwrap(); let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + 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(|_| { + let thread = cx.new(|cx| { Thread::new( project, Rc::default(), + context_server_registry, action_log.clone(), Templates::new(), model.clone(), + cx, ) }); @@ -914,15 +932,19 @@ mod tests { init_test(cx); let fs = project::FakeFs::new(cx.executor()); let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + 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(|_| { + let thread = cx.new(|cx| { Thread::new( project, Rc::default(), + context_server_registry, action_log.clone(), Templates::new(), model.clone(), + cx, ) }); let tool = Arc::new(EditFileTool { thread }); @@ -1041,15 +1063,19 @@ mod tests { let fs = project::FakeFs::new(cx.executor()); fs.insert_tree("/project", json!({})).await; let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + 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(|_| { + let thread = cx.new(|cx| { Thread::new( project, Rc::default(), + context_server_registry, action_log.clone(), Templates::new(), model.clone(), + cx, ) }); let tool = Arc::new(EditFileTool { thread }); @@ -1148,14 +1174,18 @@ mod tests { .await; 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()); - let thread = cx.new(|_| { + let thread = cx.new(|cx| { Thread::new( project.clone(), Rc::default(), + context_server_registry.clone(), action_log.clone(), Templates::new(), model.clone(), + cx, ) }); let tool = Arc::new(EditFileTool { thread }); @@ -1225,14 +1255,18 @@ mod tests { .await; let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; 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()); - let thread = cx.new(|_| { + let thread = cx.new(|cx| { Thread::new( project.clone(), Rc::default(), + context_server_registry.clone(), action_log.clone(), Templates::new(), model.clone(), + cx, ) }); let tool = Arc::new(EditFileTool { thread }); @@ -1305,14 +1339,18 @@ mod tests { .await; let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; 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()); - let thread = cx.new(|_| { + let thread = cx.new(|cx| { Thread::new( project.clone(), Rc::default(), + context_server_registry.clone(), action_log.clone(), Templates::new(), model.clone(), + cx, ) }); let tool = Arc::new(EditFileTool { thread }); @@ -1382,14 +1420,18 @@ mod tests { let fs = project::FakeFs::new(cx.executor()); let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; 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()); - let thread = cx.new(|_| { + let thread = cx.new(|cx| { Thread::new( project.clone(), Rc::default(), + context_server_registry, action_log.clone(), Templates::new(), model.clone(), + cx, ) }); let tool = Arc::new(EditFileTool { thread }); diff --git a/crates/agent2/src/tools/fetch_tool.rs b/crates/agent2/src/tools/fetch_tool.rs index 7f3752843c..ae26c5fe19 100644 --- a/crates/agent2/src/tools/fetch_tool.rs +++ b/crates/agent2/src/tools/fetch_tool.rs @@ -136,7 +136,7 @@ impl AgentTool for FetchTool { fn run( self: Arc, input: Self::Input, - event_stream: ToolCallEventStream, + _event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { let text = cx.background_spawn({ @@ -149,12 +149,6 @@ impl AgentTool for FetchTool { if text.trim().is_empty() { bail!("no textual content found"); } - - event_stream.update_fields(acp::ToolCallUpdateFields { - content: Some(vec![text.clone().into()]), - ..Default::default() - }); - Ok(text) }) } diff --git a/crates/agent2/src/tools/find_path_tool.rs b/crates/agent2/src/tools/find_path_tool.rs index 611d34e701..552de144a7 100644 --- a/crates/agent2/src/tools/find_path_tool.rs +++ b/crates/agent2/src/tools/find_path_tool.rs @@ -139,9 +139,6 @@ impl AgentTool for FindPathTool { }) .collect(), ), - raw_output: Some(serde_json::json!({ - "paths": &matches, - })), ..Default::default() }); diff --git a/crates/agent2/src/tools/grep_tool.rs b/crates/agent2/src/tools/grep_tool.rs index 3266cb5734..e5d92b3c1d 100644 --- a/crates/agent2/src/tools/grep_tool.rs +++ b/crates/agent2/src/tools/grep_tool.rs @@ -101,7 +101,7 @@ impl AgentTool for GrepTool { fn run( self: Arc, input: Self::Input, - event_stream: ToolCallEventStream, + _event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { const CONTEXT_LINES: u32 = 2; @@ -282,33 +282,22 @@ impl AgentTool for GrepTool { } } - event_stream.update_fields(acp::ToolCallUpdateFields { - content: Some(vec![output.clone().into()]), - ..Default::default() - }); matches_found += 1; } } - let output = if matches_found == 0 { - "No matches found".to_string() + if matches_found == 0 { + Ok("No matches found".into()) } else if has_more_matches { - format!( + Ok(format!( "Showing matches {}-{} (there were more matches found; use offset: {} to see next page):\n{output}", input.offset + 1, input.offset + matches_found, input.offset + RESULTS_PER_PAGE, - ) + )) } else { - format!("Found {matches_found} matches:\n{output}") - }; - - event_stream.update_fields(acp::ToolCallUpdateFields { - content: Some(vec![output.clone().into()]), - ..Default::default() - }); - - Ok(output) + Ok(format!("Found {matches_found} matches:\n{output}")) + } }) } } diff --git a/crates/agent2/src/tools/now_tool.rs b/crates/agent2/src/tools/now_tool.rs index 71698b8275..a72ede26fe 100644 --- a/crates/agent2/src/tools/now_tool.rs +++ b/crates/agent2/src/tools/now_tool.rs @@ -47,20 +47,13 @@ impl AgentTool for NowTool { fn run( self: Arc, input: Self::Input, - event_stream: ToolCallEventStream, + _event_stream: ToolCallEventStream, _cx: &mut App, ) -> Task> { let now = match input.timezone { Timezone::Utc => Utc::now().to_rfc3339(), Timezone::Local => Local::now().to_rfc3339(), }; - let content = format!("The current datetime is {now}."); - - event_stream.update_fields(acp::ToolCallUpdateFields { - content: Some(vec![content.clone().into()]), - ..Default::default() - }); - - Task::ready(Ok(content)) + Task::ready(Ok(format!("The current datetime is {now}."))) } } diff --git a/crates/agent_settings/src/agent_profile.rs b/crates/agent_settings/src/agent_profile.rs index a6b8633b34..402cf81678 100644 --- a/crates/agent_settings/src/agent_profile.rs +++ b/crates/agent_settings/src/agent_profile.rs @@ -48,6 +48,20 @@ pub struct AgentProfileSettings { pub context_servers: IndexMap, ContextServerPreset>, } +impl AgentProfileSettings { + pub fn is_tool_enabled(&self, tool_name: &str) -> bool { + self.tools.get(tool_name) == Some(&true) + } + + pub fn is_context_server_tool_enabled(&self, server_id: &str, tool_name: &str) -> bool { + self.enable_all_context_servers + || self + .context_servers + .get(server_id) + .map_or(false, |preset| preset.tools.get(tool_name) == Some(&true)) + } +} + #[derive(Debug, Clone, Default)] pub struct ContextServerPreset { pub tools: IndexMap, bool>, From 44953375cc9c9829ae43a686f1112fb331bcaa38 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Tue, 12 Aug 2025 10:12:58 -0300 Subject: [PATCH 079/109] Include mention context in acp-based native agent (#36006) Also adds data-layer support for symbols, thread, and rules. Release Notes: - N/A --------- Co-authored-by: Cole Miller --- Cargo.lock | 1 + crates/acp_thread/Cargo.toml | 1 + crates/acp_thread/src/acp_thread.rs | 63 ++--- crates/acp_thread/src/mention.rs | 122 ++++++++ crates/agent2/src/agent.rs | 46 +-- crates/agent2/src/tests/mod.rs | 41 +-- crates/agent2/src/thread.rs | 261 +++++++++++++++++- .../agent_ui/src/acp/completion_provider.rs | 77 +++++- crates/agent_ui/src/acp/thread_view.rs | 249 ++++++++++------- 9 files changed, 630 insertions(+), 231 deletions(-) create mode 100644 crates/acp_thread/src/mention.rs diff --git a/Cargo.lock b/Cargo.lock index dc28a1cb44..5ee4e94281 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,7 @@ dependencies = [ "tempfile", "terminal", "ui", + "url", "util", "workspace-hack", ] diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index 33e88df761..1fef342c01 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -34,6 +34,7 @@ settings.workspace = true smol.workspace = true terminal.workspace = true ui.workspace = true +url.workspace = true util.workspace = true workspace-hack.workspace = true diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 1c0a9479df..eccbef96b8 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -1,13 +1,15 @@ mod connection; mod diff; +mod mention; mod terminal; pub use connection::*; pub use diff::*; +pub use mention::*; pub use terminal::*; use action_log::ActionLog; -use agent_client_protocol as acp; +use agent_client_protocol::{self as acp}; use anyhow::{Context as _, Result}; use editor::Bias; use futures::{FutureExt, channel::oneshot, future::BoxFuture}; @@ -21,12 +23,7 @@ use std::error::Error; use std::fmt::Formatter; use std::process::ExitStatus; use std::rc::Rc; -use std::{ - fmt::Display, - mem, - path::{Path, PathBuf}, - sync::Arc, -}; +use std::{fmt::Display, mem, path::PathBuf, sync::Arc}; use ui::App; use util::ResultExt; @@ -53,38 +50,6 @@ impl UserMessage { } } -#[derive(Debug)] -pub struct MentionPath<'a>(&'a Path); - -impl<'a> MentionPath<'a> { - const PREFIX: &'static str = "@file:"; - - pub fn new(path: &'a Path) -> Self { - MentionPath(path) - } - - pub fn try_parse(url: &'a str) -> Option { - let path = url.strip_prefix(Self::PREFIX)?; - Some(MentionPath(Path::new(path))) - } - - pub fn path(&self) -> &Path { - self.0 - } -} - -impl Display for MentionPath<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "[@{}]({}{})", - self.0.file_name().unwrap_or_default().display(), - Self::PREFIX, - self.0.display() - ) - } -} - #[derive(Debug, PartialEq)] pub struct AssistantMessage { pub chunks: Vec, @@ -367,16 +332,24 @@ impl ContentBlock { ) { let new_content = match block { acp::ContentBlock::Text(text_content) => text_content.text.clone(), - acp::ContentBlock::ResourceLink(resource_link) => { - if let Some(path) = resource_link.uri.strip_prefix("file://") { - format!("{}", MentionPath(path.as_ref())) + acp::ContentBlock::Resource(acp::EmbeddedResource { + resource: + acp::EmbeddedResourceResource::TextResourceContents(acp::TextResourceContents { + uri, + .. + }), + .. + }) => { + if let Some(uri) = MentionUri::parse(&uri).log_err() { + uri.to_link() } else { - resource_link.uri.clone() + uri.clone() } } acp::ContentBlock::Image(_) | acp::ContentBlock::Audio(_) - | acp::ContentBlock::Resource(_) => String::new(), + | acp::ContentBlock::Resource(acp::EmbeddedResource { .. }) + | acp::ContentBlock::ResourceLink(_) => String::new(), }; match self { @@ -1329,7 +1302,7 @@ mod tests { use serde_json::json; use settings::SettingsStore; use smol::stream::StreamExt as _; - use std::{cell::RefCell, rc::Rc, time::Duration}; + use std::{cell::RefCell, path::Path, rc::Rc, time::Duration}; use util::path; diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs new file mode 100644 index 0000000000..1fcd27ad4c --- /dev/null +++ b/crates/acp_thread/src/mention.rs @@ -0,0 +1,122 @@ +use agent_client_protocol as acp; +use anyhow::{Result, bail}; +use std::path::PathBuf; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MentionUri { + File(PathBuf), + Symbol(PathBuf, String), + Thread(acp::SessionId), + Rule(String), +} + +impl MentionUri { + pub fn parse(input: &str) -> Result { + let url = url::Url::parse(input)?; + let path = url.path(); + match url.scheme() { + "file" => { + if let Some(fragment) = url.fragment() { + Ok(Self::Symbol(path.into(), fragment.into())) + } else { + Ok(Self::File(path.into())) + } + } + "zed" => { + if let Some(thread) = path.strip_prefix("/agent/thread/") { + Ok(Self::Thread(acp::SessionId(thread.into()))) + } else if let Some(rule) = path.strip_prefix("/agent/rule/") { + Ok(Self::Rule(rule.into())) + } else { + bail!("invalid zed url: {:?}", input); + } + } + other => bail!("unrecognized scheme {:?}", other), + } + } + + pub fn name(&self) -> String { + match self { + MentionUri::File(path) => path.file_name().unwrap().to_string_lossy().into_owned(), + MentionUri::Symbol(_path, name) => name.clone(), + MentionUri::Thread(thread) => thread.to_string(), + MentionUri::Rule(rule) => rule.clone(), + } + } + + pub fn to_link(&self) -> String { + let name = self.name(); + let uri = self.to_uri(); + format!("[{name}]({uri})") + } + + pub fn to_uri(&self) -> String { + match self { + MentionUri::File(path) => { + format!("file://{}", path.display()) + } + MentionUri::Symbol(path, name) => { + format!("file://{}#{}", path.display(), name) + } + MentionUri::Thread(thread) => { + format!("zed:///agent/thread/{}", thread.0) + } + MentionUri::Rule(rule) => { + format!("zed:///agent/rule/{}", rule) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mention_uri_parse_and_display() { + // Test file URI + let file_uri = "file:///path/to/file.rs"; + let parsed = MentionUri::parse(file_uri).unwrap(); + match &parsed { + MentionUri::File(path) => assert_eq!(path.to_str().unwrap(), "/path/to/file.rs"), + _ => panic!("Expected File variant"), + } + assert_eq!(parsed.to_uri(), file_uri); + + // Test symbol URI + let symbol_uri = "file:///path/to/file.rs#MySymbol"; + let parsed = MentionUri::parse(symbol_uri).unwrap(); + match &parsed { + MentionUri::Symbol(path, symbol) => { + assert_eq!(path.to_str().unwrap(), "/path/to/file.rs"); + assert_eq!(symbol, "MySymbol"); + } + _ => panic!("Expected Symbol variant"), + } + assert_eq!(parsed.to_uri(), symbol_uri); + + // Test thread URI + let thread_uri = "zed:///agent/thread/session123"; + let parsed = MentionUri::parse(thread_uri).unwrap(); + match &parsed { + MentionUri::Thread(session_id) => assert_eq!(session_id.0.as_ref(), "session123"), + _ => panic!("Expected Thread variant"), + } + assert_eq!(parsed.to_uri(), thread_uri); + + // Test rule URI + let rule_uri = "zed:///agent/rule/my_rule"; + let parsed = MentionUri::parse(rule_uri).unwrap(); + match &parsed { + MentionUri::Rule(rule) => assert_eq!(rule, "my_rule"), + _ => panic!("Expected Rule variant"), + } + assert_eq!(parsed.to_uri(), rule_uri); + + // Test invalid scheme + assert!(MentionUri::parse("http://example.com").is_err()); + + // Test invalid zed path + assert!(MentionUri::parse("zed:///invalid/path").is_err()); + } +} diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index 18a830b978..7439b2a088 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -1,8 +1,8 @@ use crate::{AgentResponseEvent, Thread, templates::Templates}; use crate::{ ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DiagnosticsTool, EditFileTool, - FetchTool, FindPathTool, GrepTool, ListDirectoryTool, MovePathTool, NowTool, OpenTool, - ReadFileTool, TerminalTool, ThinkingTool, ToolCallAuthorization, WebSearchTool, + FetchTool, FindPathTool, GrepTool, ListDirectoryTool, MessageContent, MovePathTool, NowTool, + OpenTool, ReadFileTool, TerminalTool, ThinkingTool, ToolCallAuthorization, WebSearchTool, }; use acp_thread::ModelSelector; use agent_client_protocol as acp; @@ -516,10 +516,13 @@ impl acp_thread::AgentConnection for NativeAgentConnection { })?; log::debug!("Found session for: {}", session_id); - // Convert prompt to message - let message = convert_prompt_to_message(params.prompt); + let message: Vec = params + .prompt + .into_iter() + .map(Into::into) + .collect::>(); log::info!("Converted prompt to message: {} chars", message.len()); - log::debug!("Message content: {}", message); + log::debug!("Message content: {:?}", message); // Get model using the ModelSelector capability (always available for agent2) // Get the selected model from the thread directly @@ -623,39 +626,6 @@ impl acp_thread::AgentConnection for NativeAgentConnection { } } -/// Convert ACP content blocks to a message string -fn convert_prompt_to_message(blocks: Vec) -> String { - log::debug!("Converting {} content blocks to message", blocks.len()); - let mut message = String::new(); - - for block in blocks { - match block { - acp::ContentBlock::Text(text) => { - log::trace!("Processing text block: {} chars", text.text.len()); - message.push_str(&text.text); - } - acp::ContentBlock::ResourceLink(link) => { - log::trace!("Processing resource link: {}", link.uri); - message.push_str(&format!(" @{} ", link.uri)); - } - acp::ContentBlock::Image(_) => { - log::trace!("Processing image block"); - message.push_str(" [image] "); - } - acp::ContentBlock::Audio(_) => { - log::trace!("Processing audio block"); - message.push_str(" [audio] "); - } - acp::ContentBlock::Resource(resource) => { - log::trace!("Processing resource block: {:?}", resource.resource); - message.push_str(&format!(" [resource: {:?}] ", resource.resource)); - } - } - } - - message -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index 7f4b934c08..88cf92836b 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -1,4 +1,5 @@ use super::*; +use crate::MessageContent; use acp_thread::AgentConnection; use action_log::ActionLog; use agent_client_protocol::{self as acp}; @@ -13,8 +14,8 @@ use gpui::{ use indoc::indoc; use language_model::{ LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, - LanguageModelRegistry, LanguageModelToolResult, LanguageModelToolUse, MessageContent, Role, - StopReason, fake_provider::FakeLanguageModel, + LanguageModelRegistry, LanguageModelToolResult, LanguageModelToolUse, Role, StopReason, + fake_provider::FakeLanguageModel, }; use project::Project; use prompt_store::ProjectContext; @@ -272,14 +273,14 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { assert_eq!( message.content, vec![ - MessageContent::ToolResult(LanguageModelToolResult { + language_model::MessageContent::ToolResult(LanguageModelToolResult { tool_use_id: tool_call_auth_1.tool_call.id.0.to_string().into(), tool_name: ToolRequiringPermission.name().into(), is_error: false, content: "Allowed".into(), output: Some("Allowed".into()) }), - MessageContent::ToolResult(LanguageModelToolResult { + language_model::MessageContent::ToolResult(LanguageModelToolResult { tool_use_id: tool_call_auth_2.tool_call.id.0.to_string().into(), tool_name: ToolRequiringPermission.name().into(), is_error: true, @@ -312,13 +313,15 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { let message = completion.messages.last().unwrap(); assert_eq!( message.content, - vec![MessageContent::ToolResult(LanguageModelToolResult { - tool_use_id: tool_call_auth_3.tool_call.id.0.to_string().into(), - tool_name: ToolRequiringPermission.name().into(), - is_error: false, - content: "Allowed".into(), - output: Some("Allowed".into()) - })] + 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(), + is_error: false, + content: "Allowed".into(), + output: Some("Allowed".into()) + } + )] ); // Simulate a final tool call, ensuring we don't trigger authorization. @@ -337,13 +340,15 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { let message = completion.messages.last().unwrap(); assert_eq!( message.content, - vec![MessageContent::ToolResult(LanguageModelToolResult { - tool_use_id: "tool_id_4".into(), - tool_name: ToolRequiringPermission.name().into(), - is_error: false, - content: "Allowed".into(), - output: Some("Allowed".into()) - })] + vec![language_model::MessageContent::ToolResult( + LanguageModelToolResult { + tool_use_id: "tool_id_4".into(), + tool_name: ToolRequiringPermission.name().into(), + is_error: false, + content: "Allowed".into(), + output: Some("Allowed".into()) + } + )] ); } diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 231f83ce20..678e4cb5d2 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -1,4 +1,5 @@ use crate::{ContextServerRegistry, SystemPromptTemplate, Template, Templates}; +use acp_thread::MentionUri; use action_log::ActionLog; use agent_client_protocol as acp; use agent_settings::{AgentProfileId, AgentSettings}; @@ -13,10 +14,10 @@ use futures::{ }; use gpui::{App, Context, Entity, SharedString, Task}; use language_model::{ - LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, + LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelImage, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelToolResult, LanguageModelToolResultContent, LanguageModelToolSchemaFormat, - LanguageModelToolUse, LanguageModelToolUseId, MessageContent, Role, StopReason, + LanguageModelToolUse, LanguageModelToolUseId, Role, StopReason, }; use log; use project::Project; @@ -25,7 +26,8 @@ use schemars::{JsonSchema, Schema}; use serde::{Deserialize, Serialize}; use settings::{Settings, update_settings_file}; use smol::stream::StreamExt; -use std::{cell::RefCell, collections::BTreeMap, fmt::Write, rc::Rc, sync::Arc}; +use std::fmt::Write; +use std::{cell::RefCell, collections::BTreeMap, path::Path, rc::Rc, sync::Arc}; use util::{ResultExt, markdown::MarkdownCodeBlock}; #[derive(Debug, Clone)] @@ -34,6 +36,23 @@ pub struct AgentMessage { pub content: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MessageContent { + Text(String), + Thinking { + text: String, + signature: Option, + }, + Mention { + uri: MentionUri, + content: String, + }, + RedactedThinking(String), + Image(LanguageModelImage), + ToolUse(LanguageModelToolUse), + ToolResult(LanguageModelToolResult), +} + impl AgentMessage { pub fn to_markdown(&self) -> String { let mut markdown = format!("## {}\n", self.role); @@ -93,6 +112,9 @@ impl AgentMessage { .unwrap(); } } + MessageContent::Mention { uri, .. } => { + write!(markdown, "{}", uri.to_link()).ok(); + } } } @@ -214,10 +236,11 @@ impl Thread { /// The returned channel will report all the occurrences in which the model stops before erroring or ending its turn. pub fn send( &mut self, - content: impl Into, + content: impl Into, cx: &mut Context, ) -> mpsc::UnboundedReceiver> { - let content = content.into(); + let content = content.into().0; + let model = self.selected_model.clone(); log::info!("Thread::send called with model: {:?}", model.name()); log::debug!("Thread::send content: {:?}", content); @@ -230,7 +253,7 @@ impl Thread { let user_message_ix = self.messages.len(); self.messages.push(AgentMessage { role: Role::User, - content: vec![content], + content, }); log::info!("Total messages in thread: {}", self.messages.len()); self.running_turn = Some(cx.spawn(async move |thread, cx| { @@ -353,7 +376,7 @@ impl Thread { log::debug!("System message built"); AgentMessage { role: Role::System, - content: vec![prompt.into()], + content: vec![prompt.as_str().into()], } } @@ -701,11 +724,7 @@ impl Thread { }, message.content.len() ); - LanguageModelRequestMessage { - role: message.role, - content: message.content.clone(), - cache: false, - } + message.to_request() }) .collect(); messages @@ -720,6 +739,20 @@ impl Thread { } } +pub struct UserMessage(Vec); + +impl From> for UserMessage { + fn from(content: Vec) -> Self { + UserMessage(content) + } +} + +impl> From for UserMessage { + fn from(content: T) -> Self { + UserMessage(vec![content.into()]) + } +} + pub trait AgentTool where Self: 'static + Sized, @@ -1102,3 +1135,207 @@ impl std::ops::DerefMut for ToolCallEventStreamReceiver { &mut self.0 } } + +impl AgentMessage { + fn to_request(&self) -> language_model::LanguageModelRequestMessage { + let mut message = LanguageModelRequestMessage { + role: self.role, + content: Vec::with_capacity(self.content.len()), + cache: false, + }; + + const OPEN_CONTEXT: &str = "\n\ + The following items were attached by the user. \ + They are up-to-date and don't need to be re-read.\n\n"; + + const OPEN_FILES_TAG: &str = ""; + const OPEN_SYMBOLS_TAG: &str = ""; + const OPEN_THREADS_TAG: &str = ""; + const OPEN_RULES_TAG: &str = + "\nThe user has specified the following rules that should be applied:\n"; + + let mut file_context = OPEN_FILES_TAG.to_string(); + let mut symbol_context = OPEN_SYMBOLS_TAG.to_string(); + let mut thread_context = OPEN_THREADS_TAG.to_string(); + let mut rules_context = OPEN_RULES_TAG.to_string(); + + for chunk in &self.content { + let chunk = match chunk { + MessageContent::Text(text) => language_model::MessageContent::Text(text.clone()), + MessageContent::Thinking { text, signature } => { + language_model::MessageContent::Thinking { + text: text.clone(), + signature: signature.clone(), + } + } + MessageContent::RedactedThinking(value) => { + language_model::MessageContent::RedactedThinking(value.clone()) + } + MessageContent::ToolUse(value) => { + language_model::MessageContent::ToolUse(value.clone()) + } + MessageContent::ToolResult(value) => { + language_model::MessageContent::ToolResult(value.clone()) + } + MessageContent::Image(value) => { + language_model::MessageContent::Image(value.clone()) + } + MessageContent::Mention { uri, content } => { + match uri { + MentionUri::File(path) | MentionUri::Symbol(path, _) => { + write!( + &mut symbol_context, + "\n{}", + MarkdownCodeBlock { + tag: &codeblock_tag(&path), + text: &content.to_string(), + } + ) + .ok(); + } + MentionUri::Thread(_session_id) => { + write!(&mut thread_context, "\n{}\n", content).ok(); + } + MentionUri::Rule(_user_prompt_id) => { + write!( + &mut rules_context, + "\n{}", + MarkdownCodeBlock { + tag: "", + text: &content + } + ) + .ok(); + } + } + + language_model::MessageContent::Text(uri.to_link()) + } + }; + + message.content.push(chunk); + } + + let len_before_context = message.content.len(); + + if file_context.len() > OPEN_FILES_TAG.len() { + file_context.push_str("\n"); + message + .content + .push(language_model::MessageContent::Text(file_context)); + } + + if symbol_context.len() > OPEN_SYMBOLS_TAG.len() { + symbol_context.push_str("\n"); + message + .content + .push(language_model::MessageContent::Text(symbol_context)); + } + + if thread_context.len() > OPEN_THREADS_TAG.len() { + thread_context.push_str("\n"); + message + .content + .push(language_model::MessageContent::Text(thread_context)); + } + + if rules_context.len() > OPEN_RULES_TAG.len() { + rules_context.push_str("\n"); + message + .content + .push(language_model::MessageContent::Text(rules_context)); + } + + if message.content.len() > len_before_context { + message.content.insert( + len_before_context, + language_model::MessageContent::Text(OPEN_CONTEXT.into()), + ); + message + .content + .push(language_model::MessageContent::Text("".into())); + } + + message + } +} + +fn codeblock_tag(full_path: &Path) -> String { + let mut result = String::new(); + + if let Some(extension) = full_path.extension().and_then(|ext| ext.to_str()) { + let _ = write!(result, "{} ", extension); + } + + let _ = write!(result, "{}", full_path.display()); + + result +} + +impl From for MessageContent { + fn from(value: acp::ContentBlock) -> Self { + match value { + acp::ContentBlock::Text(text_content) => MessageContent::Text(text_content.text), + acp::ContentBlock::Image(image_content) => { + MessageContent::Image(convert_image(image_content)) + } + acp::ContentBlock::Audio(_) => { + // TODO + MessageContent::Text("[audio]".to_string()) + } + acp::ContentBlock::ResourceLink(resource_link) => { + match MentionUri::parse(&resource_link.uri) { + Ok(uri) => Self::Mention { + uri, + content: String::new(), + }, + Err(err) => { + log::error!("Failed to parse mention link: {}", err); + MessageContent::Text(format!( + "[{}]({})", + resource_link.name, resource_link.uri + )) + } + } + } + acp::ContentBlock::Resource(resource) => match resource.resource { + acp::EmbeddedResourceResource::TextResourceContents(resource) => { + match MentionUri::parse(&resource.uri) { + Ok(uri) => Self::Mention { + uri, + content: resource.text, + }, + Err(err) => { + log::error!("Failed to parse mention link: {}", err); + MessageContent::Text( + MarkdownCodeBlock { + tag: &resource.uri, + text: &resource.text, + } + .to_string(), + ) + } + } + } + acp::EmbeddedResourceResource::BlobResourceContents(_) => { + // TODO + MessageContent::Text("[blob]".to_string()) + } + }, + } + } +} + +fn convert_image(image_content: acp::ImageContent) -> LanguageModelImage { + LanguageModelImage { + source: image_content.data.into(), + // TODO: make this optional? + size: gpui::Size::new(0.into(), 0.into()), + } +} + +impl From<&str> for MessageContent { + fn from(text: &str) -> Self { + MessageContent::Text(text.into()) + } +} diff --git a/crates/agent_ui/src/acp/completion_provider.rs b/crates/agent_ui/src/acp/completion_provider.rs index d8f452afa5..3c2bea53a7 100644 --- a/crates/agent_ui/src/acp/completion_provider.rs +++ b/crates/agent_ui/src/acp/completion_provider.rs @@ -1,18 +1,20 @@ use std::ops::Range; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::AtomicBool; -use anyhow::Result; +use acp_thread::MentionUri; +use anyhow::{Context as _, Result}; use collections::HashMap; use editor::display_map::CreaseId; use editor::{CompletionProvider, Editor, ExcerptId}; use file_icons::FileIcons; +use futures::future::try_join_all; use gpui::{App, Entity, Task, WeakEntity}; use language::{Buffer, CodeLabel, HighlightId}; use lsp::CompletionContext; use parking_lot::Mutex; -use project::{Completion, CompletionIntent, CompletionResponse, ProjectPath, WorktreeId}; +use project::{Completion, CompletionIntent, CompletionResponse, Project, ProjectPath, WorktreeId}; use rope::Point; use text::{Anchor, ToPoint}; use ui::prelude::*; @@ -23,21 +25,63 @@ use crate::context_picker::file_context_picker::{extract_file_name_and_directory #[derive(Default)] pub struct MentionSet { - paths_by_crease_id: HashMap, + paths_by_crease_id: HashMap, } impl MentionSet { - pub fn insert(&mut self, crease_id: CreaseId, path: ProjectPath) { - self.paths_by_crease_id.insert(crease_id, path); - } - - pub fn path_for_crease_id(&self, crease_id: CreaseId) -> Option { - self.paths_by_crease_id.get(&crease_id).cloned() + pub fn insert(&mut self, crease_id: CreaseId, path: PathBuf) { + self.paths_by_crease_id + .insert(crease_id, MentionUri::File(path)); } pub fn drain(&mut self) -> impl Iterator { self.paths_by_crease_id.drain().map(|(id, _)| id) } + + pub fn contents( + &self, + project: Entity, + cx: &mut App, + ) -> Task>> { + let contents = self + .paths_by_crease_id + .iter() + .map(|(crease_id, uri)| match uri { + MentionUri::File(path) => { + let crease_id = *crease_id; + let uri = uri.clone(); + let path = path.to_path_buf(); + let buffer_task = project.update(cx, |project, cx| { + let path = project + .find_project_path(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 { uri, content })) + }) + } + _ => { + // TODO + unimplemented!() + } + }) + .collect::>(); + + cx.spawn(async move |_cx| { + let contents = try_join_all(contents).await?.into_iter().collect(); + anyhow::Ok(contents) + }) + } +} + +pub struct Mention { + pub uri: MentionUri, + pub content: String, } pub struct ContextPickerCompletionProvider { @@ -68,6 +112,7 @@ impl ContextPickerCompletionProvider { source_range: Range, editor: Entity, mention_set: Arc>, + project: Entity, cx: &App, ) -> Completion { let (file_name, directory) = @@ -112,6 +157,7 @@ impl ContextPickerCompletionProvider { new_text_len - 1, editor, mention_set, + project, )), } } @@ -159,6 +205,7 @@ impl CompletionProvider for ContextPickerCompletionProvider { return Task::ready(Ok(Vec::new())); }; + let project = workspace.read(cx).project().clone(); let snapshot = buffer.read(cx).snapshot(); let source_range = snapshot.anchor_before(state.source_range.start) ..snapshot.anchor_after(state.source_range.end); @@ -195,6 +242,7 @@ impl CompletionProvider for ContextPickerCompletionProvider { source_range.clone(), editor.clone(), mention_set.clone(), + project.clone(), cx, ) }) @@ -254,6 +302,7 @@ fn confirm_completion_callback( content_len: usize, editor: Entity, mention_set: Arc>, + project: Entity, ) -> Arc bool + Send + Sync> { Arc::new(move |_, window, cx| { let crease_text = crease_text.clone(); @@ -261,6 +310,7 @@ fn confirm_completion_callback( let editor = editor.clone(); let project_path = project_path.clone(); let mention_set = mention_set.clone(); + let project = project.clone(); window.defer(cx, move |window, cx| { let crease_id = crate::context_picker::insert_crease_for_mention( excerpt_id, @@ -272,8 +322,13 @@ fn confirm_completion_callback( window, cx, ); + + let Some(path) = project.read(cx).absolute_path(&project_path, cx) else { + return; + }; + if let Some(crease_id) = crease_id { - mention_set.lock().insert(crease_id, project_path); + mention_set.lock().insert(crease_id, path); } }); false diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index f37deac26e..6d8dccd18f 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -1,6 +1,6 @@ use acp_thread::{ AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk, - LoadError, MentionPath, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus, + LoadError, MentionUri, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus, }; use acp_thread::{AgentConnection, Plan}; use action_log::ActionLog; @@ -28,6 +28,7 @@ use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle}; use parking_lot::Mutex; use project::{CompletionIntent, Project}; use settings::{Settings as _, SettingsStore}; +use std::path::PathBuf; use std::{ cell::RefCell, collections::BTreeMap, path::Path, process::ExitStatus, rc::Rc, sync::Arc, time::Duration, @@ -376,81 +377,101 @@ impl AcpThreadView { let mut ix = 0; let mut chunks: Vec = Vec::new(); let project = self.project.clone(); - self.message_editor.update(cx, |editor, cx| { - let text = editor.text(cx); - 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; - } - if let Some(project_path) = - self.mention_set.lock().path_for_crease_id(crease_id) - { - let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot); - if crease_range.start > ix { - chunks.push(text[ix..crease_range.start].into()); + let contents = self.mention_set.lock().contents(project, cx); + + cx.spawn_in(window, async move |this, cx| { + let contents = match contents.await { + Ok(contents) => contents, + Err(e) => { + this.update(cx, |this, cx| { + this.last_error = + Some(cx.new(|cx| Markdown::new(e.to_string().into(), None, None, cx))); + }) + .ok(); + return; + } + }; + + this.update_in(cx, |this, window, cx| { + this.message_editor.update(cx, |editor, cx| { + let text = editor.text(cx); + 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; + } + + if let Some(mention) = contents.get(&crease_id) { + let crease_range = + crease.range().to_offset(&snapshot.buffer_snapshot); + if crease_range.start > ix { + chunks.push(text[ix..crease_range.start].into()); + } + chunks.push(acp::ContentBlock::Resource(acp::EmbeddedResource { + annotations: None, + resource: acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents { + mime_type: None, + text: mention.content.clone(), + uri: mention.uri.to_uri(), + }, + ), + })); + ix = crease_range.end; + } } - if let Some(abs_path) = project.read(cx).absolute_path(&project_path, cx) { - let path_str = abs_path.display().to_string(); - chunks.push(acp::ContentBlock::ResourceLink(acp::ResourceLink { - uri: path_str.clone(), - name: path_str, - annotations: None, - description: None, - mime_type: None, - size: None, - title: None, - })); + + if ix < text.len() { + let last_chunk = text[ix..].trim_end(); + if !last_chunk.is_empty() { + chunks.push(last_chunk.into()); + } } - ix = crease_range.end; - } + }) + }); + + if chunks.is_empty() { + return; } - if ix < text.len() { - let last_chunk = text[ix..].trim_end(); - if !last_chunk.is_empty() { - chunks.push(last_chunk.into()); - } - } - }) - }); - - if chunks.is_empty() { - return; - } - - let Some(thread) = self.thread() else { - return; - }; - let task = thread.update(cx, |thread, cx| thread.send(chunks.clone(), cx)); - - cx.spawn(async move |this, cx| { - let result = task.await; - - this.update(cx, |this, cx| { - if let Err(err) = result { - this.last_error = - Some(cx.new(|cx| Markdown::new(err.to_string().into(), None, None, cx))) - } + let Some(thread) = this.thread() else { + return; + }; + let task = thread.update(cx, |thread, cx| thread.send(chunks.clone(), cx)); + + cx.spawn(async move |this, cx| { + let result = task.await; + + this.update(cx, |this, cx| { + if let Err(err) = result { + this.last_error = + Some(cx.new(|cx| { + Markdown::new(err.to_string().into(), None, None, cx) + })) + } + }) + }) + .detach(); + + let mention_set = this.mention_set.clone(); + + this.set_editor_is_expanded(false, cx); + + this.message_editor.update(cx, |editor, cx| { + editor.clear(window, cx); + editor.remove_creases(mention_set.lock().drain(), cx) + }); + + this.scroll_to_bottom(cx); + + this.message_history.borrow_mut().push(chunks); }) + .ok(); }) .detach(); - - let mention_set = self.mention_set.clone(); - - self.set_editor_is_expanded(false, cx); - - self.message_editor.update(cx, |editor, cx| { - editor.clear(window, cx); - editor.remove_creases(mention_set.lock().drain(), cx) - }); - - self.scroll_to_bottom(cx); - - self.message_history.borrow_mut().push(chunks); } fn previous_history_message( @@ -563,16 +584,19 @@ impl AcpThreadView { acp::ContentBlock::Text(text_content) => { text.push_str(&text_content.text); } - acp::ContentBlock::ResourceLink(resource_link) => { - let path = Path::new(&resource_link.uri); + acp::ContentBlock::Resource(acp::EmbeddedResource { + resource: acp::EmbeddedResourceResource::TextResourceContents(resource), + .. + }) => { + let path = PathBuf::from(&resource.uri); + let project_path = project.read(cx).project_path_for_absolute_path(&path, cx); let start = text.len(); - let content = MentionPath::new(&path).to_string(); + let content = MentionUri::File(path).to_uri(); text.push_str(&content); let end = text.len(); - if let Some(project_path) = - project.read(cx).project_path_for_absolute_path(&path, cx) - { - let filename: SharedString = path + if let Some(project_path) = project_path { + let filename: SharedString = project_path + .path .file_name() .unwrap_or_default() .to_string_lossy() @@ -583,7 +607,8 @@ impl AcpThreadView { } acp::ContentBlock::Image(_) | acp::ContentBlock::Audio(_) - | acp::ContentBlock::Resource(_) => {} + | acp::ContentBlock::Resource(_) + | acp::ContentBlock::ResourceLink(_) => {} } } @@ -602,18 +627,21 @@ impl AcpThreadView { }; let anchor = snapshot.anchor_before(range.start); - let crease_id = crate::context_picker::insert_crease_for_mention( - anchor.excerpt_id, - anchor.text_anchor, - range.end - range.start, - filename, - crease_icon_path, - message_editor.clone(), - window, - cx, - ); - if let Some(crease_id) = crease_id { - mention_set.lock().insert(crease_id, project_path); + if let Some(project_path) = project.read(cx).absolute_path(&project_path, cx) { + let crease_id = crate::context_picker::insert_crease_for_mention( + anchor.excerpt_id, + anchor.text_anchor, + range.end - range.start, + filename, + crease_icon_path, + message_editor.clone(), + window, + cx, + ); + + if let Some(crease_id) = crease_id { + mention_set.lock().insert(crease_id, project_path); + } } } @@ -2562,25 +2590,31 @@ impl AcpThreadView { return; }; - if let Some(mention_path) = MentionPath::try_parse(&url) { - workspace.update(cx, |workspace, cx| { - let project = workspace.project(); - let Some((path, entry)) = project.update(cx, |project, cx| { - let path = project.find_project_path(mention_path.path(), cx)?; - let entry = project.entry_for_path(&path, cx)?; - Some((path, entry)) - }) else { - return; - }; + if let Some(mention) = MentionUri::parse(&url).log_err() { + workspace.update(cx, |workspace, cx| match mention { + MentionUri::File(path) => { + let project = workspace.project(); + let Some((path, entry)) = project.update(cx, |project, cx| { + let path = project.find_project_path(path, cx)?; + let entry = project.entry_for_path(&path, cx)?; + Some((path, entry)) + }) else { + return; + }; - if entry.is_dir() { - project.update(cx, |_, cx| { - cx.emit(project::Event::RevealInProjectPanel(entry.id)); - }); - } else { - workspace - .open_path(path, None, true, window, cx) - .detach_and_log_err(cx); + if entry.is_dir() { + project.update(cx, |_, cx| { + cx.emit(project::Event::RevealInProjectPanel(entry.id)); + }); + } else { + workspace + .open_path(path, None, true, window, cx) + .detach_and_log_err(cx); + } + } + _ => { + // TODO + unimplemented!() } }) } else { @@ -2975,6 +3009,7 @@ impl AcpThreadView { anchor..anchor, self.message_editor.clone(), self.mention_set.clone(), + self.project.clone(), cx, ); @@ -3117,7 +3152,7 @@ fn user_message_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { style.base_text_style = text_style; style.link_callback = Some(Rc::new(move |url, cx| { - if MentionPath::try_parse(url).is_some() { + if MentionUri::parse(url).is_ok() { let colors = cx.theme().colors(); Some(TextStyleRefinement { background_color: Some(colors.element_background), From 360d4db87c9ae1072ee92dcb286a4056fa23102e Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Tue, 12 Aug 2025 15:36:28 +0200 Subject: [PATCH 080/109] python: Fix flickering in the status bar (#36039) - **util: Have maybe! use async closures instead of async blocks** - **python: Fix flickering of virtual environment indicator in status bar** Closes #30723 Release Notes: - Python: Fixed flickering of the status bar virtual environment indicator --------- Co-authored-by: Lukas Wirth --- .../src/active_toolchain.rs | 105 +++++++++++------- crates/util/src/util.rs | 4 +- 2 files changed, 66 insertions(+), 43 deletions(-) diff --git a/crates/toolchain_selector/src/active_toolchain.rs b/crates/toolchain_selector/src/active_toolchain.rs index 631f66a83c..01bd7b0a9c 100644 --- a/crates/toolchain_selector/src/active_toolchain.rs +++ b/crates/toolchain_selector/src/active_toolchain.rs @@ -8,6 +8,7 @@ use gpui::{ use language::{Buffer, BufferEvent, LanguageName, Toolchain}; use project::{Project, ProjectPath, WorktreeId, toolchain_store::ToolchainStoreEvent}; use ui::{Button, ButtonCommon, Clickable, FluentBuilder, LabelSize, SharedString, Tooltip}; +use util::maybe; use workspace::{StatusItemView, Workspace, item::ItemHandle}; use crate::ToolchainSelector; @@ -55,49 +56,61 @@ impl ActiveToolchain { } fn spawn_tracker_task(window: &mut Window, cx: &mut Context) -> Task> { cx.spawn_in(window, async move |this, cx| { - let active_file = this - .read_with(cx, |this, _| { - this.active_buffer - .as_ref() - .map(|(_, buffer, _)| buffer.clone()) - }) - .ok() - .flatten()?; - let workspace = this.read_with(cx, |this, _| this.workspace.clone()).ok()?; - let language_name = active_file - .read_with(cx, |this, _| Some(this.language()?.name())) - .ok() - .flatten()?; - let term = workspace - .update(cx, |workspace, cx| { - let languages = workspace.project().read(cx).languages(); - Project::toolchain_term(languages.clone(), language_name.clone()) - }) - .ok()? - .await?; - let _ = this.update(cx, |this, cx| { - this.term = term; - cx.notify(); - }); - let (worktree_id, path) = active_file - .update(cx, |this, cx| { - this.file().and_then(|file| { - Some(( - file.worktree_id(cx), - Arc::::from(file.path().parent()?), - )) + let did_set_toolchain = maybe!(async { + let active_file = this + .read_with(cx, |this, _| { + this.active_buffer + .as_ref() + .map(|(_, buffer, _)| buffer.clone()) }) + .ok() + .flatten()?; + let workspace = this.read_with(cx, |this, _| this.workspace.clone()).ok()?; + let language_name = active_file + .read_with(cx, |this, _| Some(this.language()?.name())) + .ok() + .flatten()?; + let term = workspace + .update(cx, |workspace, cx| { + let languages = workspace.project().read(cx).languages(); + Project::toolchain_term(languages.clone(), language_name.clone()) + }) + .ok()? + .await?; + let _ = this.update(cx, |this, cx| { + this.term = term; + cx.notify(); + }); + let (worktree_id, path) = active_file + .update(cx, |this, cx| { + this.file().and_then(|file| { + Some(( + file.worktree_id(cx), + Arc::::from(file.path().parent()?), + )) + }) + }) + .ok() + .flatten()?; + let toolchain = + Self::active_toolchain(workspace, worktree_id, path, language_name, cx).await?; + this.update(cx, |this, cx| { + this.active_toolchain = Some(toolchain); + + cx.notify(); }) .ok() - .flatten()?; - let toolchain = - Self::active_toolchain(workspace, worktree_id, path, language_name, cx).await?; - let _ = this.update(cx, |this, cx| { - this.active_toolchain = Some(toolchain); - - cx.notify(); - }); - Some(()) + }) + .await + .is_some(); + if !did_set_toolchain { + this.update(cx, |this, cx| { + this.active_toolchain = None; + cx.notify(); + }) + .ok(); + } + did_set_toolchain.then_some(()) }) } @@ -110,6 +123,17 @@ impl ActiveToolchain { let editor = editor.read(cx); if let Some((_, buffer, _)) = editor.active_excerpt(cx) { if 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, @@ -231,7 +255,6 @@ impl StatusItemView for ActiveToolchain { cx: &mut Context, ) { if let Some(editor) = active_pane_item.and_then(|item| item.downcast::()) { - self.active_toolchain.take(); self.update_lister(editor, window, cx); } cx.notify(); diff --git a/crates/util/src/util.rs b/crates/util/src/util.rs index b526f53ce4..e1b25f4dba 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -887,10 +887,10 @@ macro_rules! maybe { (|| $block)() }; (async $block:block) => { - (|| async $block)() + (async || $block)() }; (async move $block:block) => { - (|| async move $block)() + (async move || $block)() }; } From d2162446d0bb6c4b3a3ba5cb1f77889c8100aff8 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Tue, 12 Aug 2025 16:33:46 +0200 Subject: [PATCH 081/109] python: Fix venv activation in remote projects (#36043) Crux of the issue was that we were checking whether a venv activation script exists on local filesystem, which is obviously wrong for remote projects. This PR also does away with `source` for venv activation in favor of `.`, which is compliant with `sh` Co-authored-by: Lukas Wirth Closes #34648 Release Notes: - Python: fixed activation of virtual environments in terminals for remote projects Co-authored-by: Lukas Wirth --- crates/project/src/terminals.rs | 59 ++++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index 41d8c4b2fd..5ea7b87fbe 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -256,7 +256,7 @@ impl Project { let local_path = if is_ssh_terminal { None } else { path.clone() }; - let mut python_venv_activate_command = None; + let mut python_venv_activate_command = Task::ready(None); let (spawn_task, shell) = match kind { TerminalKind::Shell(_) => { @@ -265,6 +265,7 @@ impl Project { python_venv_directory, &settings.detect_venv, &settings.shell, + cx, ); } @@ -419,9 +420,12 @@ impl Project { }) .detach(); - if let Some(activate_command) = python_venv_activate_command { - this.activate_python_virtual_environment(activate_command, &terminal_handle, cx); - } + this.activate_python_virtual_environment( + python_venv_activate_command, + &terminal_handle, + cx, + ); + terminal_handle }) } @@ -539,12 +543,15 @@ impl Project { venv_base_directory: &Path, venv_settings: &VenvSettings, shell: &Shell, - ) -> Option { - let venv_settings = venv_settings.as_option()?; + cx: &mut App, + ) -> Task> { + let Some(venv_settings) = venv_settings.as_option() else { + return Task::ready(None); + }; let activate_keyword = match venv_settings.activate_script { terminal_settings::ActivateScript::Default => match std::env::consts::OS { "windows" => ".", - _ => "source", + _ => ".", }, terminal_settings::ActivateScript::Nushell => "overlay use", terminal_settings::ActivateScript::PowerShell => ".", @@ -589,30 +596,44 @@ impl Project { .join(activate_script_name) .to_string_lossy() .to_string(); - let quoted = shlex::try_quote(&path).ok()?; - smol::block_on(self.fs.metadata(path.as_ref())) - .ok() - .flatten()?; - Some(format!( - "{} {} ; clear{}", - activate_keyword, quoted, line_ending - )) + let is_valid_path = self.resolve_abs_path(path.as_ref(), cx); + cx.background_spawn(async move { + let quoted = shlex::try_quote(&path).ok()?; + if is_valid_path.await.is_some_and(|meta| meta.is_file()) { + Some(format!( + "{} {} ; clear{}", + activate_keyword, quoted, line_ending + )) + } else { + None + } + }) } else { - Some(format!( + Task::ready(Some(format!( "{activate_keyword} {activate_script_name} {name}; clear{line_ending}", name = venv_settings.venv_name - )) + ))) } } fn activate_python_virtual_environment( &self, - command: String, + command: Task>, terminal_handle: &Entity, cx: &mut App, ) { - terminal_handle.update(cx, |terminal, _| terminal.input(command.into_bytes())); + terminal_handle.update(cx, |_, cx| { + cx.spawn(async move |this, cx| { + if let Some(command) = command.await { + this.update(cx, |this, _| { + this.input(command.into_bytes()); + }) + .ok(); + } + }) + .detach() + }); } pub fn local_terminal_handles(&self) -> &Vec> { From b105028c058c3333e9866b8d6a20325c42312d1b Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 12 Aug 2025 12:39:27 -0300 Subject: [PATCH 082/109] agent2: Add custom UI for resource link content blocks (#36005) Release Notes: - N/A --------- Co-authored-by: Agus Zubiaga --- crates/acp_thread/src/acp_thread.rs | 89 +++++++---- crates/acp_thread/src/mention.rs | 5 +- crates/agent_ui/src/acp/thread_view.rs | 212 +++++++++++++++---------- 3 files changed, 192 insertions(+), 114 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index eccbef96b8..cadab3d62c 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -299,6 +299,7 @@ impl Display for ToolCallStatus { pub enum ContentBlock { Empty, Markdown { markdown: Entity }, + ResourceLink { resource_link: acp::ResourceLink }, } impl ContentBlock { @@ -330,8 +331,56 @@ impl ContentBlock { language_registry: &Arc, cx: &mut App, ) { - let new_content = match block { + if matches!(self, ContentBlock::Empty) { + if let acp::ContentBlock::ResourceLink(resource_link) = block { + *self = ContentBlock::ResourceLink { resource_link }; + return; + } + } + + let new_content = self.extract_content_from_block(block); + + match self { + ContentBlock::Empty => { + *self = Self::create_markdown_block(new_content, language_registry, cx); + } + ContentBlock::Markdown { markdown } => { + markdown.update(cx, |markdown, cx| markdown.append(&new_content, cx)); + } + ContentBlock::ResourceLink { resource_link } => { + let existing_content = Self::resource_link_to_content(&resource_link.uri); + let combined = format!("{}\n{}", existing_content, new_content); + + *self = Self::create_markdown_block(combined, language_registry, cx); + } + } + } + + fn resource_link_to_content(uri: &str) -> String { + if let Some(uri) = MentionUri::parse(&uri).log_err() { + uri.to_link() + } else { + uri.to_string().clone() + } + } + + fn create_markdown_block( + content: String, + language_registry: &Arc, + cx: &mut App, + ) -> ContentBlock { + ContentBlock::Markdown { + markdown: cx + .new(|cx| Markdown::new(content.into(), Some(language_registry.clone()), None, cx)), + } + } + + fn extract_content_from_block(&self, block: acp::ContentBlock) -> String { + match block { acp::ContentBlock::Text(text_content) => text_content.text.clone(), + acp::ContentBlock::ResourceLink(resource_link) => { + Self::resource_link_to_content(&resource_link.uri) + } acp::ContentBlock::Resource(acp::EmbeddedResource { resource: acp::EmbeddedResourceResource::TextResourceContents(acp::TextResourceContents { @@ -339,35 +388,10 @@ impl ContentBlock { .. }), .. - }) => { - if let Some(uri) = MentionUri::parse(&uri).log_err() { - uri.to_link() - } else { - uri.clone() - } - } + }) => Self::resource_link_to_content(&uri), acp::ContentBlock::Image(_) | acp::ContentBlock::Audio(_) - | acp::ContentBlock::Resource(acp::EmbeddedResource { .. }) - | acp::ContentBlock::ResourceLink(_) => String::new(), - }; - - match self { - ContentBlock::Empty => { - *self = ContentBlock::Markdown { - markdown: cx.new(|cx| { - Markdown::new( - new_content.into(), - Some(language_registry.clone()), - None, - cx, - ) - }), - }; - } - ContentBlock::Markdown { markdown } => { - markdown.update(cx, |markdown, cx| markdown.append(&new_content, cx)); - } + | acp::ContentBlock::Resource(_) => String::new(), } } @@ -375,6 +399,7 @@ impl ContentBlock { match self { ContentBlock::Empty => "", ContentBlock::Markdown { markdown } => markdown.read(cx).source(), + ContentBlock::ResourceLink { resource_link } => &resource_link.uri, } } @@ -382,6 +407,14 @@ impl ContentBlock { match self { ContentBlock::Empty => None, ContentBlock::Markdown { markdown } => Some(markdown), + ContentBlock::ResourceLink { .. } => None, + } + } + + pub fn resource_link(&self) -> Option<&acp::ResourceLink> { + match self { + ContentBlock::ResourceLink { resource_link } => Some(resource_link), + _ => None, } } } diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index 1fcd27ad4c..59c479d87b 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -19,7 +19,10 @@ impl MentionUri { if let Some(fragment) = url.fragment() { Ok(Self::Symbol(path.into(), fragment.into())) } else { - Ok(Self::File(path.into())) + let file_path = + PathBuf::from(format!("{}{}", url.host_str().unwrap_or(""), path)); + + Ok(Self::File(file_path)) } } "zed" => { diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 6d8dccd18f..791542cf26 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -1108,10 +1108,10 @@ impl AcpThreadView { .size(IconSize::Small) .color(Color::Muted); + let base_container = h_flex().size_4().justify_center(); + if is_collapsible { - h_flex() - .size_4() - .justify_center() + base_container .child( div() .group_hover(&group_name, |s| s.invisible().w_0()) @@ -1142,7 +1142,7 @@ impl AcpThreadView { ), ) } else { - div().child(tool_icon) + base_container.child(tool_icon) } } @@ -1205,8 +1205,10 @@ impl AcpThreadView { ToolCallContent::Diff(diff) => diff.read(cx).has_revealed_range(cx), _ => false, }); - let is_collapsible = - !tool_call.content.is_empty() && !needs_confirmation && !is_edit && !has_diff; + let use_card_layout = needs_confirmation || is_edit || has_diff; + + let is_collapsible = !tool_call.content.is_empty() && !use_card_layout; + let is_open = tool_call.content.is_empty() || needs_confirmation || has_nonempty_diff @@ -1225,9 +1227,39 @@ impl AcpThreadView { 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 + }; + + let tool_output_display = match &tool_call.status { + ToolCallStatus::WaitingForConfirmation { options, .. } => v_flex() + .w_full() + .children(tool_call.content.iter().map(|content| { + div() + .child(self.render_tool_call_content(content, tool_call, window, cx)) + .into_any_element() + })) + .child(self.render_permission_buttons( + options, + entry_ix, + tool_call.id.clone(), + tool_call.content.is_empty(), + cx, + )), + ToolCallStatus::Allowed { .. } | ToolCallStatus::Canceled => v_flex() + .w_full() + .children(tool_call.content.iter().map(|content| { + div() + .child(self.render_tool_call_content(content, tool_call, window, cx)) + .into_any_element() + })), + ToolCallStatus::Rejected => v_flex().size_0(), + }; v_flex() - .when(needs_confirmation || is_edit || has_diff, |this| { + .when(use_card_layout, |this| { this.rounded_lg() .border_1() .border_color(self.tool_card_border_color(cx)) @@ -1241,7 +1273,7 @@ impl AcpThreadView { .gap_1() .justify_between() .map(|this| { - if needs_confirmation || is_edit || has_diff { + if use_card_layout { this.pl_2() .pr_1() .py_1() @@ -1258,13 +1290,6 @@ impl AcpThreadView { .group(&card_header_id) .relative() .w_full() - .map(|this| { - if tool_call.locations.len() == 1 { - this.gap_0() - } else { - this.gap_1p5() - } - }) .text_size(self.tool_name_font_size()) .child(self.render_tool_call_icon( card_header_id, @@ -1308,6 +1333,7 @@ impl AcpThreadView { .id("non-card-label-container") .w_full() .relative() + .ml_1p5() .overflow_hidden() .child( h_flex() @@ -1324,17 +1350,7 @@ impl AcpThreadView { ), )), ) - .map(|this| { - if needs_confirmation { - this.child(gradient_overlay( - self.tool_card_header_bg(cx), - )) - } else { - this.child(gradient_overlay( - cx.theme().colors().panel_background, - )) - } - }) + .child(gradient_overlay(gradient_color)) .on_click(cx.listener({ let id = tool_call.id.clone(); move |this: &mut Self, _, _, cx: &mut Context| { @@ -1351,54 +1367,7 @@ impl AcpThreadView { ) .children(status_icon), ) - .when(is_open, |this| { - this.child( - v_flex() - .text_xs() - .when(is_collapsible, |this| { - this.mt_1() - .border_1() - .border_color(self.tool_card_border_color(cx)) - .bg(cx.theme().colors().editor_background) - .rounded_lg() - }) - .map(|this| { - if is_open { - match &tool_call.status { - ToolCallStatus::WaitingForConfirmation { options, .. } => this - .children(tool_call.content.iter().map(|content| { - div() - .py_1p5() - .child(self.render_tool_call_content( - content, tool_call, window, cx, - )) - .into_any_element() - })) - .child(self.render_permission_buttons( - options, - entry_ix, - tool_call.id.clone(), - tool_call.content.is_empty(), - cx, - )), - ToolCallStatus::Allowed { .. } | ToolCallStatus::Canceled => { - this.children(tool_call.content.iter().map(|content| { - div() - .py_1p5() - .child(self.render_tool_call_content( - content, tool_call, window, cx, - )) - .into_any_element() - })) - } - ToolCallStatus::Rejected => this, - } - } else { - this - } - }), - ) - }) + .when(is_open, |this| this.child(tool_output_display)) } fn render_tool_call_content( @@ -1410,16 +1379,10 @@ impl AcpThreadView { ) -> AnyElement { match content { ToolCallContent::ContentBlock(content) => { - if let Some(md) = content.markdown() { - div() - .p_2() - .child( - self.render_markdown( - md.clone(), - default_markdown_style(false, window, cx), - ), - ) - .into_any_element() + if let Some(resource_link) = content.resource_link() { + self.render_resource_link(resource_link, cx) + } else if let Some(markdown) = content.markdown() { + self.render_markdown_output(markdown.clone(), tool_call.id.clone(), window, cx) } else { Empty.into_any_element() } @@ -1431,6 +1394,83 @@ impl AcpThreadView { } } + fn render_markdown_output( + &self, + markdown: Entity, + tool_call_id: acp::ToolCallId, + window: &Window, + cx: &Context, + ) -> AnyElement { + let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id.clone())); + + v_flex() + .mt_1p5() + .ml(px(7.)) + .px_3p5() + .gap_2() + .border_l_1() + .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( + Button::new(button_id, "Collapse Output") + .full_width() + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .icon(IconName::ChevronUp) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .on_click(cx.listener({ + let id = tool_call_id.clone(); + move |this: &mut Self, _, _, cx: &mut Context| { + this.expanded_tool_calls.remove(&id); + cx.notify(); + } + })), + ) + .into_any_element() + } + + fn render_resource_link( + &self, + resource_link: &acp::ResourceLink, + cx: &Context, + ) -> AnyElement { + let uri: SharedString = resource_link.uri.clone().into(); + + let label: SharedString = if let Some(path) = resource_link.uri.strip_prefix("file://") { + path.to_string().into() + } else { + uri.clone() + }; + + let button_id = SharedString::from(format!("item-{}", uri.clone())); + + div() + .ml(px(7.)) + .pl_2p5() + .border_l_1() + .border_color(self.tool_card_border_color(cx)) + .overflow_hidden() + .child( + 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) + .on_click(cx.listener({ + let workspace = self.workspace.clone(); + move |_, _, window, cx: &mut Context| { + Self::open_link(uri.clone(), &workspace, window, cx); + } + })), + ) + .into_any_element() + } + fn render_permission_buttons( &self, options: &[acp::PermissionOption], @@ -1706,7 +1746,9 @@ impl AcpThreadView { .overflow_hidden() .child( v_flex() - .p_2() + .pt_1() + .pb_2() + .px_2() .gap_0p5() .bg(header_bg) .text_xs() From 39c19abdfdb7f64226afdcc688eb74cd26de7f4e Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Tue, 12 Aug 2025 11:55:10 -0400 Subject: [PATCH 083/109] Update windows alpha GitHub Issue template (#36049) Release Notes: - N/A --- .github/ISSUE_TEMPLATE/07_bug_windows_alpha.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/07_bug_windows_alpha.yml b/.github/ISSUE_TEMPLATE/07_bug_windows_alpha.yml index bf39560a3c..826c2b8027 100644 --- a/.github/ISSUE_TEMPLATE/07_bug_windows_alpha.yml +++ b/.github/ISSUE_TEMPLATE/07_bug_windows_alpha.yml @@ -1,15 +1,15 @@ -name: Bug Report (Windows) -description: Zed Windows-Related Bugs +name: Bug Report (Windows Alpha) +description: Zed Windows Alpha Related Bugs type: "Bug" labels: ["windows"] -title: "Windows: " +title: "Windows Alpha: " body: - type: textarea attributes: label: Summary - description: Describe the bug with a one line summary, and provide detailed reproduction steps + description: Describe the bug with a one-line summary, and provide detailed reproduction steps value: | - + SUMMARY_SENTENCE_HERE ### Description From d8fc53608ec8cac89ef3caa7d16f3919308dfc61 Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Tue, 12 Aug 2025 19:03:13 +0300 Subject: [PATCH 084/109] docs: Update OpenAI models list (#36050) Closes #ISSUE Release Notes: - N/A --- docs/src/ai/llm-providers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index 64995e6eb8..21ff2a8a51 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -391,7 +391,7 @@ Zed will also use the `OPENAI_API_KEY` environment variable if it's defined. #### Custom Models {#openai-custom-models} -The Zed agent comes pre-configured to use the latest version for common models (GPT-3.5 Turbo, GPT-4, GPT-4 Turbo, GPT-4o, GPT-4o mini). +The Zed agent comes pre-configured to use the latest version for common models (GPT-5, GPT-5 mini, o4-mini, GPT-4.1, and others). To use alternate models, perhaps a preview release or a dated model release, or if you wish to control the request parameters, you can do so by adding the following to your Zed `settings.json`: ```json From 9de04ce21528d23ac81e5292b4d963579539abf6 Mon Sep 17 00:00:00 2001 From: Rishabh Bothra <37180068+07rjain@users.noreply.github.com> Date: Tue, 12 Aug 2025 21:34:51 +0530 Subject: [PATCH 085/109] language_models: Add vision support for OpenAI gpt-5, gpt-5-mini, and gpt-5-nano models (#36047) ## Summary Enable image processing capabilities for GPT-5 series models by updating the `supports_images()` method. ## Changes - Add vision support for `gpt-5`, `gpt-5-mini`, and `gpt-5-nano` models - Update `supports_images()` method in `crates/language_models/src/provider/open_ai.rs` ## Models with Vision Support (after this PR) - gpt-4o - gpt-4o-mini - gpt-4.1 - gpt-4.1-mini - gpt-4.1-nano - gpt-5 (new) - gpt-5-mini (new) - gpt-5-nano (new) - o1 - o3 - o4-mini This brings GPT-5 vision capabilities in line with other OpenAI models that support image processing. Release Notes: - Added vision support for OpenAI models --- .../language_models/src/provider/open_ai.rs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index 7a6c8e09ed..2879b01ff3 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -301,7 +301,25 @@ impl LanguageModel for OpenAiLanguageModel { } fn supports_images(&self) -> bool { - false + use open_ai::Model; + match &self.model { + Model::FourOmni + | Model::FourOmniMini + | Model::FourPointOne + | Model::FourPointOneMini + | Model::FourPointOneNano + | Model::Five + | Model::FiveMini + | Model::FiveNano + | Model::O1 + | Model::O3 + | Model::O4Mini => true, + Model::ThreePointFiveTurbo + | Model::Four + | Model::FourTurbo + | Model::O3Mini + | Model::Custom { .. } => false, + } } fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { From 1f20d5bf54e2b69759b669abde8b2896dab983e0 Mon Sep 17 00:00:00 2001 From: localcc Date: Tue, 12 Aug 2025 18:18:42 +0200 Subject: [PATCH 086/109] Fix nightly icon (#36051) Release Notes: - N/A --- .../zed/resources/windows/app-icon-nightly.ico | Bin 0 -> 193385 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/crates/zed/resources/windows/app-icon-nightly.ico b/crates/zed/resources/windows/app-icon-nightly.ico index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..875c0d7b3546ad22f626a3b1d9236de5ea244594 100644 GIT binary patch literal 193385 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 z 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%0Dy+wN5tp8J#<^QX*gYG2d{-5RM{NGJl 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 z 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∾`0pwq^k0*W?Y{~K%l{Zfng6;h4F9wIz5hp8Tl}Bj+w*^6Z_oePjSc@N z7Z?7YTUGXdeP8>3P&sJ8&ivnom;JxHr0{>x{aK(pnBAl#{+n~K|IhLB`9HU(>wmP3 z)&EEf)Bh2sM*qVN_5X+K>io}kaQMG};mrT$9BlusdD;GZ%ZP#7&$?W!zdfWS|J(BN z{WoA{`k&+H`#(Rx|9_5;_y24!&;J>2&i~UK?f)m)*!<77w)yYK4xQhY68~?&$@<%b zhxM|flo)uP)(+GM1l@53YJ;$V*5NS!H)3J=Z_CR1--m6Mo>>3uvP0%ij6ipH iv9tU) Date: Tue, 12 Aug 2025 18:13:36 +0100 Subject: [PATCH 087/109] vim: Support filename in :tabedit and :tabnew commands (#35775) Update both `:tabedit` and `:tabnew` commands in order to support a single argument, a filename, that, when provided, ensures that the new tab either opens an existing file or associates the new tab with the filename, so that when saving the buffer's content, the file is created. Relates to #21112 Release Notes: - vim: Added support for filenames in both `:tabnew` and `:tabedit` commands --- crates/vim/src/command.rs | 112 +++++++++++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 2 deletions(-) diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index f7889d8cd8..264fa4bf2f 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -1175,8 +1175,10 @@ fn generate_commands(_: &App) -> Vec { VimCommand::str(("ls", ""), "tab_switcher::ToggleAll"), VimCommand::new(("new", ""), workspace::NewFileSplitHorizontal), VimCommand::new(("vne", "w"), workspace::NewFileSplitVertical), - VimCommand::new(("tabe", "dit"), workspace::NewFile), - VimCommand::new(("tabnew", ""), workspace::NewFile), + VimCommand::new(("tabe", "dit"), workspace::NewFile) + .args(|_action, args| Some(VimEdit { filename: args }.boxed_clone())), + VimCommand::new(("tabnew", ""), workspace::NewFile) + .args(|_action, args| Some(VimEdit { filename: args }.boxed_clone())), VimCommand::new(("tabn", "ext"), workspace::ActivateNextItem).count(), VimCommand::new(("tabp", "revious"), workspace::ActivatePreviousItem).count(), VimCommand::new(("tabN", "ext"), workspace::ActivatePreviousItem).count(), @@ -2476,4 +2478,110 @@ mod test { "}); // Once ctrl-v to input character literals is added there should be a test for redo } + + #[gpui::test] + async fn test_command_tabnew(cx: &mut TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + // Create a new file to ensure that, when the filename is used with + // `:tabnew`, it opens the existing file in a new tab. + let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone()); + fs.as_fake() + .insert_file(path!("/root/dir/file_2.rs"), "file_2".as_bytes().to_vec()) + .await; + + cx.simulate_keystrokes(": tabnew"); + cx.simulate_keystrokes("enter"); + cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2)); + + // Assert that the new tab is empty and not associated with any file, as + // no file path was provided to the `:tabnew` command. + cx.workspace(|workspace, _window, cx| { + let active_editor = workspace.active_item_as::(cx).unwrap(); + let buffer = active_editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .unwrap(); + + assert!(&buffer.read(cx).file().is_none()); + }); + + // Leverage the filename as an argument to the `:tabnew` command, + // ensuring that the file, instead of an empty buffer, is opened in a + // new tab. + cx.simulate_keystrokes(": tabnew space dir/file_2.rs"); + cx.simulate_keystrokes("enter"); + + cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 3)); + cx.workspace(|workspace, _, cx| { + assert_active_item(workspace, path!("/root/dir/file_2.rs"), "file_2", cx); + }); + + // If the `filename` argument provided to the `:tabnew` command is for a + // file that doesn't yet exist, it should still associate the buffer + // with that file path, so that when the buffer contents are saved, the + // file is created. + cx.simulate_keystrokes(": tabnew space dir/file_3.rs"); + cx.simulate_keystrokes("enter"); + + cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 4)); + cx.workspace(|workspace, _, cx| { + assert_active_item(workspace, path!("/root/dir/file_3.rs"), "", cx); + }); + } + + #[gpui::test] + async fn test_command_tabedit(cx: &mut TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + // Create a new file to ensure that, when the filename is used with + // `:tabedit`, it opens the existing file in a new tab. + let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone()); + fs.as_fake() + .insert_file(path!("/root/dir/file_2.rs"), "file_2".as_bytes().to_vec()) + .await; + + cx.simulate_keystrokes(": tabedit"); + cx.simulate_keystrokes("enter"); + cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2)); + + // Assert that the new tab is empty and not associated with any file, as + // no file path was provided to the `:tabedit` command. + cx.workspace(|workspace, _window, cx| { + let active_editor = workspace.active_item_as::(cx).unwrap(); + let buffer = active_editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .unwrap(); + + assert!(&buffer.read(cx).file().is_none()); + }); + + // Leverage the filename as an argument to the `:tabedit` command, + // ensuring that the file, instead of an empty buffer, is opened in a + // new tab. + cx.simulate_keystrokes(": tabedit space dir/file_2.rs"); + cx.simulate_keystrokes("enter"); + + cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 3)); + cx.workspace(|workspace, _, cx| { + assert_active_item(workspace, path!("/root/dir/file_2.rs"), "file_2", cx); + }); + + // If the `filename` argument provided to the `:tabedit` command is for a + // file that doesn't yet exist, it should still associate the buffer + // with that file path, so that when the buffer contents are saved, the + // file is created. + cx.simulate_keystrokes(": tabedit space dir/file_3.rs"); + cx.simulate_keystrokes("enter"); + + cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 4)); + cx.workspace(|workspace, _, cx| { + assert_active_item(workspace, path!("/root/dir/file_3.rs"), "", cx); + }); + } } From bfbb18476f73c2aa912bb1deb8fe12d28f93ee8f Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 12 Aug 2025 10:26:56 -0700 Subject: [PATCH 088/109] Fix management of rust-analyzer binaries on windows (#36056) Closes https://github.com/zed-industries/zed/issues/34472 * Avoid removing the just-downloaded exe * Invoke exe within nested version directory Release Notes: - Fix issue where Rust-analyzer was not installed correctly on windows Co-authored-by: Lukas Wirth --- crates/languages/src/rust.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index e79f0c9e8e..3baaec1842 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -238,7 +238,7 @@ impl LspAdapter for RustLspAdapter { ) .await?; make_file_executable(&server_path).await?; - remove_matching(&container_dir, |path| server_path != path).await; + remove_matching(&container_dir, |path| path != destination_path).await; GithubBinaryMetadata::write_to_file( &GithubBinaryMetadata { metadata_version: 1, @@ -1023,8 +1023,14 @@ async fn get_cached_server_binary(container_dir: PathBuf) -> Option path.clone(), // Tar and gzip extract in place. + AssetKind::Zip => path.clone().join("rust-analyzer.exe"), // zip contains a .exe + }; + anyhow::Ok(LanguageServerBinary { - path: last.context("no cached binary")?, + path, env: None, arguments: Default::default(), }) From 42b7dbeaeee8182c96c09102239e44ceacf055a3 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Tue, 12 Aug 2025 10:53:19 -0700 Subject: [PATCH 089/109] Remove beta tag from cursor keymap (#36061) Release Notes: - N/A Co-authored-by: Anthony Eid --- crates/settings/src/base_keymap_setting.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/settings/src/base_keymap_setting.rs b/crates/settings/src/base_keymap_setting.rs index 6916d98ae3..91dda03d00 100644 --- a/crates/settings/src/base_keymap_setting.rs +++ b/crates/settings/src/base_keymap_setting.rs @@ -44,7 +44,7 @@ impl BaseKeymap { ("Sublime Text", Self::SublimeText), ("Emacs (beta)", Self::Emacs), ("TextMate", Self::TextMate), - ("Cursor (beta)", Self::Cursor), + ("Cursor", Self::Cursor), ]; #[cfg(not(target_os = "macos"))] @@ -54,7 +54,7 @@ impl BaseKeymap { ("JetBrains", Self::JetBrains), ("Sublime Text", Self::SublimeText), ("Emacs (beta)", Self::Emacs), - ("Cursor (beta)", Self::Cursor), + ("Cursor", Self::Cursor), ]; pub fn asset_path(&self) -> Option<&'static str> { From 3a0465773050d0ce8319cc4a2276f688d72e7635 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Tue, 12 Aug 2025 14:24:25 -0400 Subject: [PATCH 090/109] emmet: Add workaround for leading `/` on Windows paths (#36064) This PR adds a workaround for the leading `/` on Windows paths (https://github.com/zed-industries/zed/issues/20559). Release Notes: - N/A --- extensions/emmet/src/emmet.rs | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/extensions/emmet/src/emmet.rs b/extensions/emmet/src/emmet.rs index 83fe809c34..e4fb3cf814 100644 --- a/extensions/emmet/src/emmet.rs +++ b/extensions/emmet/src/emmet.rs @@ -70,8 +70,7 @@ impl zed::Extension for EmmetExtension { Ok(zed::Command { command: zed::node_binary_path()?, args: vec![ - env::current_dir() - .unwrap() + zed_ext::sanitize_windows_path(env::current_dir().unwrap()) .join(&server_path) .to_string_lossy() .to_string(), @@ -83,3 +82,25 @@ impl zed::Extension for EmmetExtension { } zed::register_extension!(EmmetExtension); + +/// Extensions to the Zed extension API that have not yet stabilized. +mod zed_ext { + /// Sanitizes the given path to remove the leading `/` on Windows. + /// + /// On macOS and Linux this is a no-op. + /// + /// This is a workaround for https://github.com/bytecodealliance/wasmtime/issues/10415. + pub fn sanitize_windows_path(path: std::path::PathBuf) -> std::path::PathBuf { + use zed_extension_api::{Os, current_platform}; + + let (os, _arch) = current_platform(); + match os { + Os::Mac | Os::Linux => path, + Os::Windows => path + .to_string_lossy() + .to_string() + .trim_start_matches('/') + .into(), + } + } +} From b62f9595286d322e0a78daa73f58921c23a51d03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=B0=8F=E7=99=BD?= <364772080@qq.com> Date: Wed, 13 Aug 2025 02:28:47 +0800 Subject: [PATCH 091/109] windows: Fix message loop using too much CPU (#35969) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #34374 This is a leftover issue from #34374. Back in #34374, I wanted to use DirectX to handle vsync, after all, that’s how 99% of Windows apps do it. But after discussing with @maxbrunsfeld , we decided to stick with the original vsync approach given gpui’s architecture. In my tests, there’s no noticeable performance difference between this PR’s approach and DirectX vsync. That said, this PR’s method does have a theoretical advantage, it doesn’t block the main thread while waiting for vsync. The only difference is that in this PR, on Windows 11 we use a newer API instead of `DwmFlush`, since Chrome’s tests have shown that `DwmFlush` has some problems. This PR also removes the use of `MsgWaitForMultipleObjects`. Release Notes: - N/A --------- Co-authored-by: Max Brunsfeld --- Cargo.toml | 1 + crates/gpui/src/platform/windows.rs | 2 + .../src/platform/windows/directx_renderer.rs | 27 +-- crates/gpui/src/platform/windows/platform.rs | 67 +++---- crates/gpui/src/platform/windows/util.rs | 24 ++- crates/gpui/src/platform/windows/vsync.rs | 174 ++++++++++++++++++ crates/gpui/src/platform/windows/wrapper.rs | 30 ++- 7 files changed, 269 insertions(+), 56 deletions(-) create mode 100644 crates/gpui/src/platform/windows/vsync.rs diff --git a/Cargo.toml b/Cargo.toml index 48a11c27da..dd14078dd2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -714,6 +714,7 @@ features = [ "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Ole", + "Win32_System_Performance", "Win32_System_Pipes", "Win32_System_SystemInformation", "Win32_System_SystemServices", diff --git a/crates/gpui/src/platform/windows.rs b/crates/gpui/src/platform/windows.rs index 5268d3ccba..77e0ca41bf 100644 --- a/crates/gpui/src/platform/windows.rs +++ b/crates/gpui/src/platform/windows.rs @@ -10,6 +10,7 @@ mod keyboard; mod platform; mod system_settings; mod util; +mod vsync; mod window; mod wrapper; @@ -25,6 +26,7 @@ pub(crate) use keyboard::*; pub(crate) use platform::*; pub(crate) use system_settings::*; pub(crate) use util::*; +pub(crate) use vsync::*; pub(crate) use window::*; pub(crate) use wrapper::*; diff --git a/crates/gpui/src/platform/windows/directx_renderer.rs b/crates/gpui/src/platform/windows/directx_renderer.rs index 585b1dab1c..4e72ded534 100644 --- a/crates/gpui/src/platform/windows/directx_renderer.rs +++ b/crates/gpui/src/platform/windows/directx_renderer.rs @@ -4,16 +4,15 @@ use ::util::ResultExt; use anyhow::{Context, Result}; use windows::{ Win32::{ - Foundation::{FreeLibrary, HMODULE, HWND}, + Foundation::{HMODULE, HWND}, Graphics::{ Direct3D::*, Direct3D11::*, DirectComposition::*, Dxgi::{Common::*, *}, }, - System::LibraryLoader::LoadLibraryA, }, - core::{Interface, PCSTR}, + core::Interface, }; use crate::{ @@ -208,7 +207,7 @@ impl DirectXRenderer { fn present(&mut self) -> Result<()> { unsafe { - let result = self.resources.swap_chain.Present(1, DXGI_PRESENT(0)); + let result = self.resources.swap_chain.Present(0, DXGI_PRESENT(0)); // Presenting the swap chain can fail if the DirectX device was removed or reset. if result == DXGI_ERROR_DEVICE_REMOVED || result == DXGI_ERROR_DEVICE_RESET { let reason = self.devices.device.GetDeviceRemovedReason(); @@ -1619,22 +1618,6 @@ pub(crate) mod shader_resources { } } -fn with_dll_library(dll_name: PCSTR, f: F) -> Result -where - F: FnOnce(HMODULE) -> Result, -{ - let library = unsafe { - LoadLibraryA(dll_name).with_context(|| format!("Loading dll: {}", dll_name.display()))? - }; - let result = f(library); - unsafe { - FreeLibrary(library) - .with_context(|| format!("Freeing dll: {}", dll_name.display())) - .log_err(); - } - result -} - mod nvidia { use std::{ ffi::CStr, @@ -1644,7 +1627,7 @@ mod nvidia { use anyhow::Result; use windows::{Win32::System::LibraryLoader::GetProcAddress, core::s}; - use crate::platform::windows::directx_renderer::with_dll_library; + use crate::with_dll_library; // https://github.com/NVIDIA/nvapi/blob/7cb76fce2f52de818b3da497af646af1ec16ce27/nvapi_lite_common.h#L180 const NVAPI_SHORT_STRING_MAX: usize = 64; @@ -1711,7 +1694,7 @@ mod amd { use anyhow::Result; use windows::{Win32::System::LibraryLoader::GetProcAddress, core::s}; - use crate::platform::windows::directx_renderer::with_dll_library; + use crate::with_dll_library; // https://github.com/GPUOpen-LibrariesAndSDKs/AGS_SDK/blob/5d8812d703d0335741b6f7ffc37838eeb8b967f7/ags_lib/inc/amd_ags.h#L145 const AGS_CURRENT_VERSION: i32 = (6 << 22) | (3 << 12); diff --git a/crates/gpui/src/platform/windows/platform.rs b/crates/gpui/src/platform/windows/platform.rs index 01b043a755..9e5d359e43 100644 --- a/crates/gpui/src/platform/windows/platform.rs +++ b/crates/gpui/src/platform/windows/platform.rs @@ -32,7 +32,7 @@ use crate::*; pub(crate) struct WindowsPlatform { state: RefCell, - raw_window_handles: RwLock>, + raw_window_handles: Arc>>, // The below members will never change throughout the entire lifecycle of the app. icon: HICON, main_receiver: flume::Receiver, @@ -114,7 +114,7 @@ impl WindowsPlatform { }; let icon = load_icon().unwrap_or_default(); let state = RefCell::new(WindowsPlatformState::new()); - let raw_window_handles = RwLock::new(SmallVec::new()); + let raw_window_handles = Arc::new(RwLock::new(SmallVec::new())); let windows_version = WindowsVersion::new().context("Error retrieve windows version")?; Ok(Self { @@ -134,22 +134,12 @@ impl WindowsPlatform { }) } - fn redraw_all(&self) { - for handle in self.raw_window_handles.read().iter() { - unsafe { - RedrawWindow(Some(*handle), None, None, RDW_INVALIDATE | RDW_UPDATENOW) - .ok() - .log_err(); - } - } - } - pub fn window_from_hwnd(&self, hwnd: HWND) -> Option> { self.raw_window_handles .read() .iter() - .find(|entry| *entry == &hwnd) - .and_then(|hwnd| window_from_hwnd(*hwnd)) + .find(|entry| entry.as_raw() == hwnd) + .and_then(|hwnd| window_from_hwnd(hwnd.as_raw())) } #[inline] @@ -158,7 +148,7 @@ impl WindowsPlatform { .read() .iter() .for_each(|handle| unsafe { - PostMessageW(Some(*handle), message, wparam, lparam).log_err(); + PostMessageW(Some(handle.as_raw()), message, wparam, lparam).log_err(); }); } @@ -166,7 +156,7 @@ impl WindowsPlatform { let mut lock = self.raw_window_handles.write(); let index = lock .iter() - .position(|handle| *handle == target_window) + .position(|handle| handle.as_raw() == target_window) .unwrap(); lock.remove(index); @@ -226,19 +216,19 @@ impl WindowsPlatform { } } - // Returns true if the app should quit. - fn handle_events(&self) -> bool { + // Returns if the app should quit. + fn handle_events(&self) { let mut msg = MSG::default(); unsafe { - while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() { + while GetMessageW(&mut msg, None, 0, 0).as_bool() { match msg.message { - WM_QUIT => return true, + WM_QUIT => return, WM_INPUTLANGCHANGE | WM_GPUI_CLOSE_ONE_WINDOW | WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD | WM_GPUI_DOCK_MENU_ACTION => { if self.handle_gpui_evnets(msg.message, msg.wParam, msg.lParam, &msg) { - return true; + return; } } _ => { @@ -247,7 +237,6 @@ impl WindowsPlatform { } } } - false } // Returns true if the app should quit. @@ -315,8 +304,28 @@ impl WindowsPlatform { self.raw_window_handles .read() .iter() - .find(|&&hwnd| hwnd == active_window_hwnd) - .copied() + .find(|hwnd| hwnd.as_raw() == active_window_hwnd) + .map(|hwnd| hwnd.as_raw()) + } + + fn begin_vsync_thread(&self) { + let all_windows = Arc::downgrade(&self.raw_window_handles); + std::thread::spawn(move || { + let vsync_provider = VSyncProvider::new(); + loop { + vsync_provider.wait_for_vsync(); + let Some(all_windows) = all_windows.upgrade() else { + break; + }; + for hwnd in all_windows.read().iter() { + unsafe { + RedrawWindow(Some(hwnd.as_raw()), None, None, RDW_INVALIDATE) + .ok() + .log_err(); + } + } + } + }); } } @@ -347,12 +356,8 @@ impl Platform for WindowsPlatform { fn run(&self, on_finish_launching: Box) { on_finish_launching(); - loop { - if self.handle_events() { - break; - } - self.redraw_all(); - } + self.begin_vsync_thread(); + self.handle_events(); if let Some(ref mut callback) = self.state.borrow_mut().callbacks.quit { callback(); @@ -445,7 +450,7 @@ impl Platform for WindowsPlatform { ) -> Result> { let window = WindowsWindow::new(handle, options, self.generate_creation_info())?; let handle = window.get_raw_handle(); - self.raw_window_handles.write().push(handle); + self.raw_window_handles.write().push(handle.into()); Ok(Box::new(window)) } diff --git a/crates/gpui/src/platform/windows/util.rs b/crates/gpui/src/platform/windows/util.rs index 5fb8febe3b..af71dfe4a1 100644 --- a/crates/gpui/src/platform/windows/util.rs +++ b/crates/gpui/src/platform/windows/util.rs @@ -1,14 +1,18 @@ use std::sync::OnceLock; use ::util::ResultExt; +use anyhow::Context; use windows::{ UI::{ Color, ViewManagement::{UIColorType, UISettings}, }, Wdk::System::SystemServices::RtlGetVersion, - Win32::{Foundation::*, Graphics::Dwm::*, UI::WindowsAndMessaging::*}, - core::{BOOL, HSTRING}, + Win32::{ + Foundation::*, Graphics::Dwm::*, System::LibraryLoader::LoadLibraryA, + UI::WindowsAndMessaging::*, + }, + core::{BOOL, HSTRING, PCSTR}, }; use crate::*; @@ -197,3 +201,19 @@ pub(crate) fn show_error(title: &str, content: String) { ) }; } + +pub(crate) fn with_dll_library(dll_name: PCSTR, f: F) -> Result +where + F: FnOnce(HMODULE) -> Result, +{ + let library = unsafe { + LoadLibraryA(dll_name).with_context(|| format!("Loading dll: {}", dll_name.display()))? + }; + let result = f(library); + unsafe { + FreeLibrary(library) + .with_context(|| format!("Freeing dll: {}", dll_name.display())) + .log_err(); + } + result +} diff --git a/crates/gpui/src/platform/windows/vsync.rs b/crates/gpui/src/platform/windows/vsync.rs new file mode 100644 index 0000000000..09dbfd0231 --- /dev/null +++ b/crates/gpui/src/platform/windows/vsync.rs @@ -0,0 +1,174 @@ +use std::{ + sync::LazyLock, + time::{Duration, Instant}, +}; + +use anyhow::{Context, Result}; +use util::ResultExt; +use windows::{ + Win32::{ + Foundation::{HANDLE, HWND}, + Graphics::{ + DirectComposition::{ + COMPOSITION_FRAME_ID_COMPLETED, COMPOSITION_FRAME_ID_TYPE, COMPOSITION_FRAME_STATS, + COMPOSITION_TARGET_ID, + }, + Dwm::{DWM_TIMING_INFO, DwmFlush, DwmGetCompositionTimingInfo}, + }, + System::{ + LibraryLoader::{GetModuleHandleA, GetProcAddress}, + Performance::QueryPerformanceFrequency, + Threading::INFINITE, + }, + }, + core::{HRESULT, s}, +}; + +static QPC_TICKS_PER_SECOND: LazyLock = LazyLock::new(|| { + let mut frequency = 0; + // On systems that run Windows XP or later, the function will always succeed and + // will thus never return zero. + unsafe { QueryPerformanceFrequency(&mut frequency).unwrap() }; + frequency as u64 +}); + +const VSYNC_INTERVAL_THRESHOLD: Duration = Duration::from_millis(1); +const DEFAULT_VSYNC_INTERVAL: Duration = Duration::from_micros(16_666); // ~60Hz + +// Here we are using dynamic loading of DirectComposition functions, +// or the app will refuse to start on windows systems that do not support DirectComposition. +type DCompositionGetFrameId = + unsafe extern "system" fn(frameidtype: COMPOSITION_FRAME_ID_TYPE, frameid: *mut u64) -> HRESULT; +type DCompositionGetStatistics = unsafe extern "system" fn( + frameid: u64, + framestats: *mut COMPOSITION_FRAME_STATS, + targetidcount: u32, + targetids: *mut COMPOSITION_TARGET_ID, + actualtargetidcount: *mut u32, +) -> HRESULT; +type DCompositionWaitForCompositorClock = + unsafe extern "system" fn(count: u32, handles: *const HANDLE, timeoutinms: u32) -> u32; + +pub(crate) struct VSyncProvider { + interval: Duration, + f: Box bool>, +} + +impl VSyncProvider { + pub(crate) fn new() -> Self { + if let Some((get_frame_id, get_statistics, wait_for_comp_clock)) = + initialize_direct_composition() + .context("Retrieving DirectComposition functions") + .log_with_level(log::Level::Warn) + { + let interval = get_dwm_interval_from_direct_composition(get_frame_id, get_statistics) + .context("Failed to get DWM interval from DirectComposition") + .log_err() + .unwrap_or(DEFAULT_VSYNC_INTERVAL); + log::info!( + "DirectComposition is supported for VSync, interval: {:?}", + interval + ); + let f = Box::new(move || unsafe { + wait_for_comp_clock(0, std::ptr::null(), INFINITE) == 0 + }); + Self { interval, f } + } else { + let interval = get_dwm_interval() + .context("Failed to get DWM interval") + .log_err() + .unwrap_or(DEFAULT_VSYNC_INTERVAL); + log::info!( + "DirectComposition is not supported for VSync, falling back to DWM, interval: {:?}", + interval + ); + let f = Box::new(|| unsafe { DwmFlush().is_ok() }); + Self { interval, f } + } + } + + pub(crate) fn wait_for_vsync(&self) { + let vsync_start = Instant::now(); + let wait_succeeded = (self.f)(); + let elapsed = vsync_start.elapsed(); + // DwmFlush and DCompositionWaitForCompositorClock returns very early + // instead of waiting until vblank when the monitor goes to sleep or is + // unplugged (nothing to present due to desktop occlusion). We use 1ms as + // a threshhold for the duration of the wait functions and fallback to + // Sleep() if it returns before that. This could happen during normal + // operation for the first call after the vsync thread becomes non-idle, + // but it shouldn't happen often. + if !wait_succeeded || elapsed < VSYNC_INTERVAL_THRESHOLD { + log::warn!("VSyncProvider::wait_for_vsync() took shorter than expected"); + std::thread::sleep(self.interval); + } + } +} + +fn initialize_direct_composition() -> Result<( + DCompositionGetFrameId, + DCompositionGetStatistics, + DCompositionWaitForCompositorClock, +)> { + unsafe { + // Load DLL at runtime since older Windows versions don't have dcomp. + let hmodule = GetModuleHandleA(s!("dcomp.dll")).context("Loading dcomp.dll")?; + let get_frame_id_addr = GetProcAddress(hmodule, s!("DCompositionGetFrameId")) + .context("Function DCompositionGetFrameId not found")?; + let get_statistics_addr = GetProcAddress(hmodule, s!("DCompositionGetStatistics")) + .context("Function DCompositionGetStatistics not found")?; + let wait_for_compositor_clock_addr = + GetProcAddress(hmodule, s!("DCompositionWaitForCompositorClock")) + .context("Function DCompositionWaitForCompositorClock not found")?; + let get_frame_id: DCompositionGetFrameId = std::mem::transmute(get_frame_id_addr); + let get_statistics: DCompositionGetStatistics = std::mem::transmute(get_statistics_addr); + let wait_for_compositor_clock: DCompositionWaitForCompositorClock = + std::mem::transmute(wait_for_compositor_clock_addr); + Ok((get_frame_id, get_statistics, wait_for_compositor_clock)) + } +} + +fn get_dwm_interval_from_direct_composition( + get_frame_id: DCompositionGetFrameId, + get_statistics: DCompositionGetStatistics, +) -> Result { + let mut frame_id = 0; + unsafe { get_frame_id(COMPOSITION_FRAME_ID_COMPLETED, &mut frame_id) }.ok()?; + let mut stats = COMPOSITION_FRAME_STATS::default(); + unsafe { + get_statistics( + frame_id, + &mut stats, + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + } + .ok()?; + Ok(retrieve_duration(stats.framePeriod, *QPC_TICKS_PER_SECOND)) +} + +fn get_dwm_interval() -> Result { + let mut timing_info = DWM_TIMING_INFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + unsafe { DwmGetCompositionTimingInfo(HWND::default(), &mut timing_info) }?; + let interval = retrieve_duration(timing_info.qpcRefreshPeriod, *QPC_TICKS_PER_SECOND); + // Check for interval values that are impossibly low. A 29 microsecond + // interval was seen (from a qpcRefreshPeriod of 60). + if interval < VSYNC_INTERVAL_THRESHOLD { + Ok(retrieve_duration( + timing_info.rateRefresh.uiDenominator as u64, + timing_info.rateRefresh.uiNumerator as u64, + )) + } else { + Ok(interval) + } +} + +#[inline] +fn retrieve_duration(counts: u64, ticks_per_second: u64) -> Duration { + let ticks_per_microsecond = ticks_per_second / 1_000_000; + Duration::from_micros(counts / ticks_per_microsecond) +} diff --git a/crates/gpui/src/platform/windows/wrapper.rs b/crates/gpui/src/platform/windows/wrapper.rs index a1fe98a392..60bbc433ca 100644 --- a/crates/gpui/src/platform/windows/wrapper.rs +++ b/crates/gpui/src/platform/windows/wrapper.rs @@ -1,6 +1,6 @@ use std::ops::Deref; -use windows::Win32::UI::WindowsAndMessaging::HCURSOR; +use windows::Win32::{Foundation::HWND, UI::WindowsAndMessaging::HCURSOR}; #[derive(Debug, Clone, Copy)] pub(crate) struct SafeCursor { @@ -23,3 +23,31 @@ impl Deref for SafeCursor { &self.raw } } + +#[derive(Debug, Clone, Copy)] +pub(crate) struct SafeHwnd { + raw: HWND, +} + +impl SafeHwnd { + pub(crate) fn as_raw(&self) -> HWND { + self.raw + } +} + +unsafe impl Send for SafeHwnd {} +unsafe impl Sync for SafeHwnd {} + +impl From for SafeHwnd { + fn from(value: HWND) -> Self { + SafeHwnd { raw: value } + } +} + +impl Deref for SafeHwnd { + type Target = HWND; + + fn deref(&self) -> &Self::Target { + &self.raw + } +} From d030bb62817ef073ce96a743b7cbc9121d9980b1 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Tue, 12 Aug 2025 14:41:26 -0400 Subject: [PATCH 092/109] emmet: Bump to v0.0.5 (#36066) This PR bumps the Emmet extension to v0.0.5. Changes: - https://github.com/zed-industries/zed/pull/35599 - https://github.com/zed-industries/zed/pull/36064 Release Notes: - N/A --- Cargo.lock | 2 +- extensions/emmet/Cargo.toml | 2 +- extensions/emmet/extension.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5ee4e94281..72c5da3a34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20686,7 +20686,7 @@ dependencies = [ [[package]] name = "zed_emmet" -version = "0.0.4" +version = "0.0.5" dependencies = [ "zed_extension_api 0.1.0", ] diff --git a/extensions/emmet/Cargo.toml b/extensions/emmet/Cargo.toml index 9d72a6c5c4..ff9debdea9 100644 --- a/extensions/emmet/Cargo.toml +++ b/extensions/emmet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_emmet" -version = "0.0.4" +version = "0.0.5" edition.workspace = true publish.workspace = true license = "Apache-2.0" diff --git a/extensions/emmet/extension.toml b/extensions/emmet/extension.toml index 9fa14d091f..0ebb801f9d 100644 --- a/extensions/emmet/extension.toml +++ b/extensions/emmet/extension.toml @@ -1,7 +1,7 @@ id = "emmet" name = "Emmet" description = "Emmet support" -version = "0.0.4" +version = "0.0.5" schema_version = 1 authors = ["Piotr Osiewicz "] repository = "https://github.com/zed-industries/zed" From 7df8e05ad946b01a12bbcf0f71ac573c89f119b5 Mon Sep 17 00:00:00 2001 From: Filip Binkiewicz Date: Tue, 12 Aug 2025 19:47:15 +0100 Subject: [PATCH 093/109] Ignore whitespace in git blame invocation (#35960) This works around a bug wherein inline git blame is unavailable for files with CRLF line endings. At the same time, this prevents users from seeing whitespace-only changes in the editor's git blame Closes #35836 Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/git/src/blame.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/git/src/blame.rs b/crates/git/src/blame.rs index 2128fa55c3..6f12681ea0 100644 --- a/crates/git/src/blame.rs +++ b/crates/git/src/blame.rs @@ -73,6 +73,7 @@ async fn run_git_blame( .current_dir(working_directory) .arg("blame") .arg("--incremental") + .arg("-w") .arg("--contents") .arg("-") .arg(path.as_os_str()) From 7ff0f1525e42f948495970a0bb6227d4c3dfac43 Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Tue, 12 Aug 2025 21:49:19 +0300 Subject: [PATCH 094/109] open_ai: Log inputs that caused parsing errors (#36063) Release Notes: - N/A Co-authored-by: Michael Sloan --- Cargo.lock | 1 + crates/open_ai/Cargo.toml | 1 + crates/open_ai/src/open_ai.rs | 10 +++++++++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 72c5da3a34..d24a399c1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11242,6 +11242,7 @@ dependencies = [ "anyhow", "futures 0.3.31", "http_client", + "log", "schemars", "serde", "serde_json", diff --git a/crates/open_ai/Cargo.toml b/crates/open_ai/Cargo.toml index 2d40cd2735..bae00f0a8e 100644 --- a/crates/open_ai/Cargo.toml +++ b/crates/open_ai/Cargo.toml @@ -20,6 +20,7 @@ anyhow.workspace = true futures.workspace = true http_client.workspace = true schemars = { workspace = true, optional = true } +log.workspace = true serde.workspace = true serde_json.workspace = true strum.workspace = true diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index 4697d71ed3..a6fd03a296 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -445,7 +445,15 @@ pub async fn stream_completion( Ok(ResponseStreamResult::Err { error }) => { Some(Err(anyhow!(error))) } - Err(error) => Some(Err(anyhow!(error))), + Err(error) => { + log::error!( + "Failed to parse OpenAI response into ResponseStreamResult: `{}`\n\ + Response: `{}`", + error, + line, + ); + Some(Err(anyhow!(error))) + } } } } From 7167f193c02520440420a8e88099620fc81b8470 Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Tue, 12 Aug 2025 21:51:23 +0300 Subject: [PATCH 095/109] open_ai: Send `prompt_cache_key` to improve caching (#36065) Release Notes: - N/A Co-authored-by: Michael Sloan --- crates/language_models/src/provider/open_ai.rs | 1 + crates/open_ai/src/open_ai.rs | 2 ++ 2 files changed, 3 insertions(+) diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index 2879b01ff3..9eac58c880 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -473,6 +473,7 @@ pub fn into_open_ai( } else { None }, + prompt_cache_key: request.thread_id, tools: request .tools .into_iter() diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index a6fd03a296..919b1d9ebf 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -244,6 +244,8 @@ pub struct Request { pub parallel_tool_calls: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tools: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt_cache_key: Option, } #[derive(Debug, Serialize, Deserialize)] From 628b1058bee19e6d5093b13826e7942654fbab35 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 12 Aug 2025 16:31:54 -0300 Subject: [PATCH 096/109] agent2: Fix some UI glitches (#36067) Release Notes: - N/A --- crates/agent_ui/src/acp/thread_view.rs | 88 ++++++++++++++++---------- crates/markdown/src/markdown.rs | 5 +- 2 files changed, 58 insertions(+), 35 deletions(-) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 791542cf26..f47c7a0bc5 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -1278,8 +1278,6 @@ impl AcpThreadView { .pr_1() .py_1() .rounded_t_md() - .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.)) @@ -1387,7 +1385,9 @@ impl AcpThreadView { Empty.into_any_element() } } - ToolCallContent::Diff(diff) => self.render_diff_editor(&diff.read(cx).multibuffer()), + ToolCallContent::Diff(diff) => { + self.render_diff_editor(&diff.read(cx).multibuffer(), cx) + } ToolCallContent::Terminal(terminal) => { self.render_terminal_tool_call(terminal, tool_call, window, cx) } @@ -1531,9 +1531,15 @@ impl AcpThreadView { }))) } - fn render_diff_editor(&self, multibuffer: &Entity) -> AnyElement { + fn render_diff_editor( + &self, + multibuffer: &Entity, + cx: &Context, + ) -> AnyElement { v_flex() .h_full() + .border_t_1() + .border_color(self.tool_card_border_color(cx)) .child( if let Some(editor) = self.diff_editors.get(&multibuffer.entity_id()) { editor.clone().into_any_element() @@ -1746,9 +1752,9 @@ impl AcpThreadView { .overflow_hidden() .child( v_flex() - .pt_1() - .pb_2() - .px_2() + .py_1p5() + .pl_2() + .pr_1p5() .gap_0p5() .bg(header_bg) .text_xs() @@ -2004,24 +2010,26 @@ impl AcpThreadView { parent.child(self.render_plan_entries(plan, window, cx)) }) }) - .when(!changed_buffers.is_empty(), |this| { + .when(!plan.is_empty() && !changed_buffers.is_empty(), |this| { this.child(Divider::horizontal().color(DividerColor::Border)) - .child(self.render_edits_summary( + }) + .when(!changed_buffers.is_empty(), |this| { + this.child(self.render_edits_summary( + action_log, + &changed_buffers, + self.edits_expanded, + pending_edits, + window, + cx, + )) + .when(self.edits_expanded, |parent| { + parent.child(self.render_edited_files( action_log, &changed_buffers, - self.edits_expanded, pending_edits, - window, cx, )) - .when(self.edits_expanded, |parent| { - parent.child(self.render_edited_files( - action_log, - &changed_buffers, - pending_edits, - cx, - )) - }) + }) }) .into_any() .into() @@ -2940,7 +2948,8 @@ impl AcpThreadView { fn render_thread_controls(&self, cx: &Context) -> impl IntoElement { let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown) - .icon_size(IconSize::XSmall) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) .icon_color(Color::Ignored) .tooltip(Tooltip::text("Open Thread as Markdown")) .on_click(cx.listener(move |this, _, window, cx| { @@ -2951,7 +2960,8 @@ impl AcpThreadView { })); let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp) - .icon_size(IconSize::XSmall) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) .icon_color(Color::Ignored) .tooltip(Tooltip::text("Scroll To Top")) .on_click(cx.listener(move |this, _, _, cx| { @@ -2962,7 +2972,6 @@ impl AcpThreadView { .w_full() .mr_1() .pb_2() - .gap_1() .px(RESPONSE_PADDING_X) .opacity(0.4) .hover(|style| style.opacity(1.)) @@ -3079,6 +3088,8 @@ impl Focusable for AcpThreadView { impl Render for AcpThreadView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let has_messages = self.list_state.item_count() > 0; + v_flex() .size_full() .key_context("AcpThread") @@ -3125,7 +3136,7 @@ impl Render for AcpThreadView { let thread_clone = thread.clone(); v_flex().flex_1().map(|this| { - if self.list_state.item_count() > 0 { + if has_messages { this.child( list( self.list_state.clone(), @@ -3144,23 +3155,32 @@ impl Render for AcpThreadView { .into_any(), ) .child(self.render_vertical_scrollbar(cx)) - .children(match thread_clone.read(cx).status() { - ThreadStatus::Idle | ThreadStatus::WaitingForToolConfirmation => { - None - } - ThreadStatus::Generating => div() - .px_5() - .py_2() - .child(LoadingLabel::new("").size(LabelSize::Small)) - .into(), - }) - .children(self.render_activity_bar(&thread_clone, window, cx)) + .children( + match thread_clone.read(cx).status() { + ThreadStatus::Idle + | ThreadStatus::WaitingForToolConfirmation => None, + ThreadStatus::Generating => div() + .px_5() + .py_2() + .child(LoadingLabel::new("").size(LabelSize::Small)) + .into(), + }, + ) } else { this.child(self.render_empty_state(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 + // the scrollbar to stop exactly at the activity bar start. + .when(has_messages, |this| match &self.thread_state { + ThreadState::Ready { thread, .. } => { + this.children(self.render_activity_bar(thread, window, cx)) + } + _ => this, + }) .when_some(self.last_error.clone(), |el, error| { el.child( div() diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index dba4bc64b1..a3235a9773 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -1084,7 +1084,9 @@ impl Element for MarkdownElement { self.markdown.clone(), cx, ); - el.child(div().absolute().top_1().right_1().w_5().child(codeblock)) + el.child( + div().absolute().top_1().right_0p5().w_5().child(codeblock), + ) }); } @@ -1312,6 +1314,7 @@ fn render_copy_code_block_button( }, ) .icon_color(Color::Muted) + .icon_size(IconSize::Small) .shape(ui::IconButtonShape::Square) .tooltip(Tooltip::text("Copy Code")) .on_click({ From 255bb0a3f87563cb2162a620bb069e31c7fa3b0b Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Tue, 12 Aug 2025 15:56:27 -0400 Subject: [PATCH 097/109] telemetry: Reduce the amount of telemetry events fired (#36060) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Extension loaded events are now condensed into a single event with a Vec of (extension_id, extension_version) called id_and_versions. 2. Editor Saved & AutoSaved are merged into a singular event with a type field that is either "manual" or "autosave”. 3. Editor Edited event will only fire once every 10 minutes now. 4. Editor Closed event is fired when an editor item (tab) is removed from a pane cc: @katie-z-geer Release Notes: - N/A --------- Co-authored-by: Marshall Bowers --- crates/client/src/telemetry.rs | 35 +++++++++---- crates/editor/src/editor.rs | 57 ++++++++++++++++----- crates/editor/src/items.rs | 34 ++++++++---- crates/extension_host/src/extension_host.rs | 20 ++++---- crates/workspace/src/item.rs | 6 +++ crates/workspace/src/pane.rs | 1 + 6 files changed, 110 insertions(+), 43 deletions(-) diff --git a/crates/client/src/telemetry.rs b/crates/client/src/telemetry.rs index 43a1a0b7a4..54b3d3f801 100644 --- a/crates/client/src/telemetry.rs +++ b/crates/client/src/telemetry.rs @@ -340,22 +340,35 @@ impl Telemetry { } pub fn log_edit_event(self: &Arc, environment: &'static str, is_via_ssh: bool) { + static LAST_EVENT_TIME: Mutex> = Mutex::new(None); + let mut state = self.state.lock(); let period_data = state.event_coalescer.log_event(environment); drop(state); - if let Some((start, end, environment)) = period_data { - let duration = end - .saturating_duration_since(start) - .min(Duration::from_secs(60 * 60 * 24)) - .as_millis() as i64; + if let Some(mut last_event) = LAST_EVENT_TIME.try_lock() { + let current_time = std::time::Instant::now(); + let last_time = last_event.get_or_insert(current_time); - telemetry::event!( - "Editor Edited", - duration = duration, - environment = environment, - is_via_ssh = is_via_ssh - ); + if current_time.duration_since(*last_time) > Duration::from_secs(60 * 10) { + *last_time = current_time; + } else { + return; + } + + if let Some((start, end, environment)) = period_data { + let duration = end + .saturating_duration_since(start) + .min(Duration::from_secs(60 * 60 * 24)) + .as_millis() as i64; + + telemetry::event!( + "Editor Edited", + duration = duration, + environment = environment, + is_via_ssh = is_via_ssh + ); + } } } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index d1bf95c794..8a9398e71f 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -250,6 +250,24 @@ pub type RenderDiffHunkControlsFn = Arc< ) -> AnyElement, >; +enum ReportEditorEvent { + Saved { auto_saved: bool }, + EditorOpened, + ZetaTosClicked, + Closed, +} + +impl ReportEditorEvent { + pub fn event_type(&self) -> &'static str { + match self { + Self::Saved { .. } => "Editor Saved", + Self::EditorOpened => "Editor Opened", + Self::ZetaTosClicked => "Edit Prediction Provider ToS Clicked", + Self::Closed => "Editor Closed", + } + } +} + struct InlineValueCache { enabled: bool, inlays: Vec, @@ -2325,7 +2343,7 @@ impl Editor { } if editor.mode.is_full() { - editor.report_editor_event("Editor Opened", None, cx); + editor.report_editor_event(ReportEditorEvent::EditorOpened, None, cx); } editor @@ -9124,7 +9142,7 @@ impl Editor { .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) .on_click(cx.listener(|this, _event, window, cx| { cx.stop_propagation(); - this.report_editor_event("Edit Prediction Provider ToS Clicked", None, cx); + this.report_editor_event(ReportEditorEvent::ZetaTosClicked, None, cx); window.dispatch_action( zed_actions::OpenZedPredictOnboarding.boxed_clone(), cx, @@ -20547,7 +20565,7 @@ impl Editor { fn report_editor_event( &self, - event_type: &'static str, + reported_event: ReportEditorEvent, file_extension: Option, cx: &App, ) { @@ -20581,15 +20599,30 @@ impl Editor { .show_edit_predictions; let project = project.read(cx); - telemetry::event!( - event_type, - file_extension, - vim_mode, - copilot_enabled, - copilot_enabled_for_language, - edit_predictions_provider, - is_via_ssh = project.is_via_ssh(), - ); + let event_type = reported_event.event_type(); + + if let ReportEditorEvent::Saved { auto_saved } = reported_event { + telemetry::event!( + event_type, + type = if auto_saved {"autosave"} else {"manual"}, + file_extension, + vim_mode, + copilot_enabled, + copilot_enabled_for_language, + edit_predictions_provider, + is_via_ssh = project.is_via_ssh(), + ); + } else { + telemetry::event!( + event_type, + file_extension, + vim_mode, + copilot_enabled, + copilot_enabled_for_language, + edit_predictions_provider, + is_via_ssh = project.is_via_ssh(), + ); + }; } /// Copy the highlighted chunks to the clipboard as JSON. The format is an array of lines, diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 231aaa1d00..1da82c605d 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -1,7 +1,7 @@ use crate::{ Anchor, Autoscroll, Editor, EditorEvent, EditorSettings, ExcerptId, ExcerptRange, FormatTarget, - MultiBuffer, MultiBufferSnapshot, NavigationData, SearchWithinRange, SelectionEffects, - ToPoint as _, + MultiBuffer, MultiBufferSnapshot, NavigationData, ReportEditorEvent, SearchWithinRange, + SelectionEffects, ToPoint as _, display_map::HighlightKey, editor_settings::SeedQuerySetting, persistence::{DB, SerializedEditor}, @@ -776,6 +776,10 @@ impl Item for Editor { } } + fn on_removed(&self, cx: &App) { + self.report_editor_event(ReportEditorEvent::Closed, None, cx); + } + fn deactivated(&mut self, _: &mut Window, cx: &mut Context) { let selection = self.selections.newest_anchor(); self.push_to_nav_history(selection.head(), None, true, false, cx); @@ -815,9 +819,9 @@ impl Item for Editor { ) -> Task> { // Add meta data tracking # of auto saves if options.autosave { - self.report_editor_event("Editor Autosaved", None, cx); + self.report_editor_event(ReportEditorEvent::Saved { auto_saved: true }, None, cx); } else { - self.report_editor_event("Editor Saved", None, cx); + self.report_editor_event(ReportEditorEvent::Saved { auto_saved: false }, None, cx); } let buffers = self.buffer().clone().read(cx).all_buffers(); @@ -896,7 +900,11 @@ impl Item for Editor { .path .extension() .map(|a| a.to_string_lossy().to_string()); - self.report_editor_event("Editor Saved", file_extension, cx); + self.report_editor_event( + ReportEditorEvent::Saved { auto_saved: false }, + file_extension, + cx, + ); project.update(cx, |project, cx| project.save_buffer_as(buffer, path, cx)) } @@ -997,12 +1005,16 @@ impl Item for Editor { ) { self.workspace = Some((workspace.weak_handle(), workspace.database_id())); if let Some(workspace) = &workspace.weak_handle().upgrade() { - cx.subscribe(&workspace, |editor, _, event: &workspace::Event, _cx| { - if matches!(event, workspace::Event::ModalOpened) { - editor.mouse_context_menu.take(); - editor.inline_blame_popover.take(); - } - }) + cx.subscribe( + &workspace, + |editor, _, event: &workspace::Event, _cx| match event { + workspace::Event::ModalOpened => { + editor.mouse_context_menu.take(); + editor.inline_blame_popover.take(); + } + _ => {} + }, + ) .detach(); } } diff --git a/crates/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs index dc38c244f1..67baf4e692 100644 --- a/crates/extension_host/src/extension_host.rs +++ b/crates/extension_host/src/extension_host.rs @@ -1118,15 +1118,17 @@ impl ExtensionStore { extensions_to_unload.len() - reload_count ); - for extension_id in &extensions_to_load { - if let Some(extension) = new_index.extensions.get(extension_id) { - telemetry::event!( - "Extension Loaded", - extension_id, - version = extension.manifest.version - ); - } - } + let extension_ids = extensions_to_load + .iter() + .filter_map(|id| { + Some(( + id.clone(), + new_index.extensions.get(id)?.manifest.version.clone(), + )) + }) + .collect::>(); + + telemetry::event!("Extensions Loaded", id_and_versions = extension_ids); let themes_to_remove = old_index .themes diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index c8ebe4550b..bba50e4431 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -293,6 +293,7 @@ pub trait Item: Focusable + EventEmitter + Render + Sized { fn deactivated(&mut self, _window: &mut Window, _: &mut Context) {} fn discarded(&self, _project: Entity, _window: &mut Window, _cx: &mut Context) {} + fn on_removed(&self, _cx: &App) {} fn workspace_deactivated(&mut self, _window: &mut Window, _: &mut Context) {} fn navigate(&mut self, _: Box, _window: &mut Window, _: &mut Context) -> bool { false @@ -532,6 +533,7 @@ pub trait ItemHandle: 'static + Send { ); fn deactivated(&self, window: &mut Window, cx: &mut App); fn discarded(&self, project: Entity, window: &mut Window, cx: &mut App); + fn on_removed(&self, cx: &App); fn workspace_deactivated(&self, window: &mut Window, cx: &mut App); fn navigate(&self, data: Box, window: &mut Window, cx: &mut App) -> bool; fn item_id(&self) -> EntityId; @@ -968,6 +970,10 @@ impl ItemHandle for Entity { self.update(cx, |this, cx| this.deactivated(window, cx)); } + fn on_removed(&self, cx: &App) { + self.read(cx).on_removed(cx); + } + fn workspace_deactivated(&self, window: &mut Window, cx: &mut App) { self.update(cx, |this, cx| this.workspace_deactivated(window, cx)); } diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 0c35752165..cffeea0a8d 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -1829,6 +1829,7 @@ impl Pane { let mode = self.nav_history.mode(); self.nav_history.set_mode(NavigationMode::ClosingItem); item.deactivated(window, cx); + item.on_removed(cx); self.nav_history.set_mode(mode); if self.is_active_preview_item(item.item_id()) { From 48ae02c1cace50491f7e3d471a87634ddf31563d Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 12 Aug 2025 17:06:01 -0400 Subject: [PATCH 098/109] Don't retry for PaymentRequiredError or ModelRequestLimitReachedError (#36075) Release Notes: - Don't auto-retry for "payment required" or "model request limit reached" errors (since retrying won't help) --- crates/agent/src/thread.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 20d482f60d..1d417efbba 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -2268,6 +2268,15 @@ impl Thread { max_attempts: 3, }) } + Other(err) + if err.is::() + || err.is::() => + { + // Retrying won't help for Payment Required or Model Request Limit errors (where + // the user must upgrade to usage-based billing to get more requests, or else wait + // for a significant amount of time for the request limit to reset). + None + } // Conservatively assume that any other errors are non-retryable HttpResponseError { .. } | Other(..) => Some(RetryStrategy::Fixed { delay: BASE_RETRY_DELAY, From b564b1d5d0c07aff10ab8f351d70604220a4497f Mon Sep 17 00:00:00 2001 From: Michael Sloan Date: Tue, 12 Aug 2025 15:08:19 -0600 Subject: [PATCH 099/109] outline: Fix nesting in multi-name declarations in Go and C++ (#36076) An alternative might be to adjust the logic to not nest items when their ranges are the same, but then clicking them doesn't work properly / moving the cursor does not change which is selected. This could probably be made to work with some extra logic there, but it seems overkill. The downside of fixing it at the query level is that other parts of the declaration are not inside the item range. This seems to be fine for single line declarations - the nearest outline item is highlighted. However, if a part of the declaration is not included in an item range and is on its own line, then no outline item is highlighted. Release Notes: - Outline Panel: Fixed nesting of var and field declarations with multiple identifiers in Go and C++ C++ before: image C++ after: image Go before: image Go after: image --- crates/languages/src/cpp/outline.scm | 6 ++++-- crates/languages/src/go/outline.scm | 13 ++++++++----- crates/languages/src/javascript/outline.scm | 4 ++++ crates/languages/src/tsx/outline.scm | 4 ++++ crates/languages/src/typescript/outline.scm | 4 ++++ 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/crates/languages/src/cpp/outline.scm b/crates/languages/src/cpp/outline.scm index 448fe35220..c897366558 100644 --- a/crates/languages/src/cpp/outline.scm +++ b/crates/languages/src/cpp/outline.scm @@ -149,7 +149,9 @@ parameters: (parameter_list "(" @context ")" @context))) - ] - (type_qualifier)? @context) @item + ; Fields declarations may define multiple fields, and so @item is on the + ; declarator so they each get distinct ranges. + ] @item + (type_qualifier)? @context) (comment) @annotation diff --git a/crates/languages/src/go/outline.scm b/crates/languages/src/go/outline.scm index e37ae7e572..c745f55aff 100644 --- a/crates/languages/src/go/outline.scm +++ b/crates/languages/src/go/outline.scm @@ -1,4 +1,5 @@ (comment) @annotation + (type_declaration "type" @context [ @@ -42,13 +43,13 @@ (var_declaration "var" @context [ + ; The declaration may define multiple variables, and so @item is on + ; the identifier so they get distinct ranges. (var_spec - name: (identifier) @name) @item + name: (identifier) @name @item) (var_spec_list - "(" (var_spec - name: (identifier) @name) @item - ")" + name: (identifier) @name @item) ) ] ) @@ -60,5 +61,7 @@ "(" @context ")" @context)) @item +; Fields declarations may define multiple fields, and so @item is on the +; declarator so they each get distinct ranges. (field_declaration - name: (_) @name) @item + name: (_) @name @item) diff --git a/crates/languages/src/javascript/outline.scm b/crates/languages/src/javascript/outline.scm index 026c71e1f9..ca16c27a27 100644 --- a/crates/languages/src/javascript/outline.scm +++ b/crates/languages/src/javascript/outline.scm @@ -31,12 +31,16 @@ (export_statement (lexical_declaration ["let" "const"] @context + ; Multiple names may be exported - @item is on the declarator to keep + ; ranges distinct. (variable_declarator name: (_) @name) @item))) (program (lexical_declaration ["let" "const"] @context + ; Multiple names may be defined - @item is on the declarator to keep + ; ranges distinct. (variable_declarator name: (_) @name) @item)) diff --git a/crates/languages/src/tsx/outline.scm b/crates/languages/src/tsx/outline.scm index 5dafe791e4..f4261b9697 100644 --- a/crates/languages/src/tsx/outline.scm +++ b/crates/languages/src/tsx/outline.scm @@ -34,12 +34,16 @@ (export_statement (lexical_declaration ["let" "const"] @context + ; Multiple names may be exported - @item is on the declarator to keep + ; ranges distinct. (variable_declarator name: (_) @name) @item)) (program (lexical_declaration ["let" "const"] @context + ; Multiple names may be defined - @item is on the declarator to keep + ; ranges distinct. (variable_declarator name: (_) @name) @item)) diff --git a/crates/languages/src/typescript/outline.scm b/crates/languages/src/typescript/outline.scm index 5dafe791e4..f4261b9697 100644 --- a/crates/languages/src/typescript/outline.scm +++ b/crates/languages/src/typescript/outline.scm @@ -34,12 +34,16 @@ (export_statement (lexical_declaration ["let" "const"] @context + ; Multiple names may be exported - @item is on the declarator to keep + ; ranges distinct. (variable_declarator name: (_) @name) @item)) (program (lexical_declaration ["let" "const"] @context + ; Multiple names may be defined - @item is on the declarator to keep + ; ranges distinct. (variable_declarator name: (_) @name) @item)) From cd234e28ce528b8f9c811aa4c5c5d358b9a9eb5d Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 12 Aug 2025 14:36:48 -0700 Subject: [PATCH 100/109] Eliminate host targets from rust toolchain file (#36077) Only cross-compilation targets need to be listed in the rust toolchain. So we only need to list the wasi target for extensions, and the musl target for the linux remote server. Previously, we were causing mac, linux, and windows target to get installed onto all developer workstations, which is unnecessary. Release Notes: - N/A --- rust-toolchain.toml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 3d87025a27..2c909e0e1e 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -3,11 +3,6 @@ channel = "1.89" profile = "minimal" components = [ "rustfmt", "clippy" ] targets = [ - "x86_64-apple-darwin", - "aarch64-apple-darwin", - "x86_64-unknown-freebsd", - "x86_64-unknown-linux-gnu", - "x86_64-pc-windows-msvc", "wasm32-wasip2", # extensions "x86_64-unknown-linux-musl", # remote server ] From 13a2c53381467cf572d282183e53b04ff1d5c674 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Tue, 12 Aug 2025 18:02:10 -0400 Subject: [PATCH 101/109] onboarding: Fix onboarding font context menu not scrolling to selected entry open (#36080) The fix was changing the picker kind we used from `list` variant to a `uniform` list `Picker::list()` still has a bug where it's unable to scroll to it's selected entry when the list is first openned. This is likely caused by list not knowing the pixel offset of each element it would have to scroll pass to get to the selected element Release Notes: - N/A Co-authored-by: Danilo Leal --- crates/onboarding/src/editing_page.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/onboarding/src/editing_page.rs b/crates/onboarding/src/editing_page.rs index 13b4f6a5c1..e8fbc36c30 100644 --- a/crates/onboarding/src/editing_page.rs +++ b/crates/onboarding/src/editing_page.rs @@ -573,7 +573,7 @@ fn font_picker( ) -> FontPicker { let delegate = FontPickerDelegate::new(current_font, on_font_changed, cx); - Picker::list(delegate, window, cx) + Picker::uniform_list(delegate, window, cx) .show_scrollbar(true) .width(rems_from_px(210.)) .max_height(Some(rems(20.).into())) From 658d56bd726ff44d8105da75302b6a2c24e726cd Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Wed, 13 Aug 2025 00:37:11 +0200 Subject: [PATCH 102/109] cli: Do not rely on Spotlight for --channel support (#36082) I've recently disabled Spotlight on my Mac and found that this code path (which I rely on a lot) ceased working for me. Closes #ISSUE Release Notes: - N/A --- crates/cli/src/main.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 8d6cd2544a..67591167df 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -957,17 +957,14 @@ mod mac_os { ) -> Result<()> { use anyhow::bail; - let app_id_prompt = format!("id of app \"{}\"", channel.display_name()); - let app_id_output = Command::new("osascript") + let app_path_prompt = format!( + "POSIX path of (path to application \"{}\")", + channel.display_name() + ); + let app_path_output = Command::new("osascript") .arg("-e") - .arg(&app_id_prompt) + .arg(&app_path_prompt) .output()?; - if !app_id_output.status.success() { - bail!("Could not determine app id for {}", channel.display_name()); - } - let app_name = String::from_utf8(app_id_output.stdout)?.trim().to_owned(); - let app_path_prompt = format!("kMDItemCFBundleIdentifier == '{app_name}'"); - let app_path_output = Command::new("mdfind").arg(app_path_prompt).output()?; if !app_path_output.status.success() { bail!( "Could not determine app path for {}", From 32975c420807d4ac84b89a914be3e32819ff37f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=B0=8F=E7=99=BD?= <364772080@qq.com> Date: Wed, 13 Aug 2025 08:04:30 +0800 Subject: [PATCH 103/109] windows: Fix auto update failure when launching from the cli (#34303) Release Notes: - N/A --------- Co-authored-by: Max Brunsfeld --- .../src/activity_indicator.rs | 12 +- crates/auto_update/src/auto_update.rs | 112 +++++++++++------- .../src/auto_update_helper.rs | 86 +++++++++++++- crates/auto_update_helper/src/dialog.rs | 4 +- crates/auto_update_helper/src/updater.rs | 14 ++- crates/editor/src/editor_tests.rs | 2 +- crates/gpui/src/app.rs | 31 ++++- crates/gpui/src/app/context.rs | 35 ++++-- crates/gpui/src/platform/windows/platform.rs | 4 +- crates/title_bar/src/title_bar.rs | 2 +- crates/workspace/src/workspace.rs | 22 ++-- crates/zed/src/main.rs | 51 +++----- crates/zed/src/zed/windows_only_instance.rs | 6 +- 13 files changed, 250 insertions(+), 131 deletions(-) diff --git a/crates/activity_indicator/src/activity_indicator.rs b/crates/activity_indicator/src/activity_indicator.rs index f8ea7173d8..7c562aaba4 100644 --- a/crates/activity_indicator/src/activity_indicator.rs +++ b/crates/activity_indicator/src/activity_indicator.rs @@ -716,18 +716,10 @@ impl ActivityIndicator { })), tooltip_message: Some(Self::version_tooltip_message(&version)), }), - AutoUpdateStatus::Updated { - binary_path, - version, - } => Some(Content { + AutoUpdateStatus::Updated { version } => Some(Content { icon: None, message: "Click to restart and update Zed".to_string(), - on_click: Some(Arc::new({ - let reload = workspace::Reload { - binary_path: Some(binary_path.clone()), - }; - move |_, _, cx| workspace::reload(&reload, cx) - })), + on_click: Some(Arc::new(move |_, _, cx| workspace::reload(cx))), tooltip_message: Some(Self::version_tooltip_message(&version)), }), AutoUpdateStatus::Errored => Some(Content { diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index 074aaa6fea..4d0d2d5984 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -59,16 +59,9 @@ pub enum VersionCheckType { pub enum AutoUpdateStatus { Idle, Checking, - Downloading { - version: VersionCheckType, - }, - Installing { - version: VersionCheckType, - }, - Updated { - binary_path: PathBuf, - version: VersionCheckType, - }, + Downloading { version: VersionCheckType }, + Installing { version: VersionCheckType }, + Updated { version: VersionCheckType }, Errored, } @@ -83,6 +76,7 @@ pub struct AutoUpdater { current_version: SemanticVersion, http_client: Arc, pending_poll: Option>>, + quit_subscription: Option, } #[derive(Deserialize, Clone, Debug)] @@ -164,7 +158,7 @@ pub fn init(http_client: Arc, cx: &mut App) { AutoUpdateSetting::register(cx); cx.observe_new(|workspace: &mut Workspace, _window, _cx| { - workspace.register_action(|_, action: &Check, window, cx| check(action, window, cx)); + workspace.register_action(|_, action, window, cx| check(action, window, cx)); workspace.register_action(|_, action, _, cx| { view_release_notes(action, cx); @@ -174,7 +168,7 @@ pub fn init(http_client: Arc, cx: &mut App) { let version = release_channel::AppVersion::global(cx); let auto_updater = cx.new(|cx| { - let updater = AutoUpdater::new(version, http_client); + let updater = AutoUpdater::new(version, http_client, cx); let poll_for_updates = ReleaseChannel::try_global(cx) .map(|channel| channel.poll_for_updates()) @@ -321,12 +315,34 @@ impl AutoUpdater { cx.default_global::().0.clone() } - fn new(current_version: SemanticVersion, http_client: Arc) -> Self { + fn new( + current_version: SemanticVersion, + http_client: Arc, + cx: &mut Context, + ) -> Self { + // On windows, executable files cannot be overwritten while they are + // running, so we must wait to overwrite the application until quitting + // or restarting. When quitting the app, we spawn the auto update helper + // to finish the auto update process after Zed exits. When restarting + // the app after an update, we use `set_restart_path` to run the auto + // update helper instead of the app, so that it can overwrite the app + // and then spawn the new binary. + let quit_subscription = Some(cx.on_app_quit(|_, _| async move { + #[cfg(target_os = "windows")] + finalize_auto_update_on_quit(); + })); + + cx.on_app_restart(|this, _| { + this.quit_subscription.take(); + }) + .detach(); + Self { status: AutoUpdateStatus::Idle, current_version, http_client, pending_poll: None, + quit_subscription, } } @@ -536,6 +552,8 @@ impl AutoUpdater { ) })?; + Self::check_dependencies()?; + this.update(&mut cx, |this, cx| { this.status = AutoUpdateStatus::Checking; cx.notify(); @@ -582,13 +600,15 @@ impl AutoUpdater { cx.notify(); })?; - let binary_path = Self::binary_path(installer_dir, target_path, &cx).await?; + let new_binary_path = Self::install_release(installer_dir, target_path, &cx).await?; + if let Some(new_binary_path) = new_binary_path { + cx.update(|cx| cx.set_restart_path(new_binary_path))?; + } this.update(&mut cx, |this, cx| { this.set_should_show_update_notification(true, cx) .detach_and_log_err(cx); this.status = AutoUpdateStatus::Updated { - binary_path, version: newer_version, }; cx.notify(); @@ -639,6 +659,15 @@ impl AutoUpdater { } } + fn check_dependencies() -> Result<()> { + #[cfg(not(target_os = "windows"))] + anyhow::ensure!( + which::which("rsync").is_ok(), + "Aborting. Could not find rsync which is required for auto-updates." + ); + Ok(()) + } + async fn target_path(installer_dir: &InstallerDir) -> Result { let filename = match OS { "macos" => anyhow::Ok("Zed.dmg"), @@ -647,20 +676,14 @@ impl AutoUpdater { unsupported_os => anyhow::bail!("not supported: {unsupported_os}"), }?; - #[cfg(not(target_os = "windows"))] - anyhow::ensure!( - which::which("rsync").is_ok(), - "Aborting. Could not find rsync which is required for auto-updates." - ); - Ok(installer_dir.path().join(filename)) } - async fn binary_path( + async fn install_release( installer_dir: InstallerDir, target_path: PathBuf, cx: &AsyncApp, - ) -> Result { + ) -> Result> { match OS { "macos" => install_release_macos(&installer_dir, target_path, cx).await, "linux" => install_release_linux(&installer_dir, target_path, cx).await, @@ -801,7 +824,7 @@ async fn install_release_linux( temp_dir: &InstallerDir, downloaded_tar_gz: PathBuf, cx: &AsyncApp, -) -> Result { +) -> Result> { let channel = cx.update(|cx| ReleaseChannel::global(cx).dev_name())?; let home_dir = PathBuf::from(env::var("HOME").context("no HOME env var set")?); let running_app_path = cx.update(|cx| cx.app_path())??; @@ -861,14 +884,14 @@ async fn install_release_linux( String::from_utf8_lossy(&output.stderr) ); - Ok(to.join(expected_suffix)) + Ok(Some(to.join(expected_suffix))) } async fn install_release_macos( temp_dir: &InstallerDir, downloaded_dmg: PathBuf, cx: &AsyncApp, -) -> Result { +) -> Result> { let running_app_path = cx.update(|cx| cx.app_path())??; let running_app_filename = running_app_path .file_name() @@ -910,10 +933,10 @@ async fn install_release_macos( String::from_utf8_lossy(&output.stderr) ); - Ok(running_app_path) + Ok(None) } -async fn install_release_windows(downloaded_installer: PathBuf) -> Result { +async fn install_release_windows(downloaded_installer: PathBuf) -> Result> { let output = Command::new(downloaded_installer) .arg("/verysilent") .arg("/update=true") @@ -926,29 +949,36 @@ async fn install_release_windows(downloaded_installer: PathBuf) -> Result bool { +pub fn finalize_auto_update_on_quit() { let Some(installer_path) = std::env::current_exe() .ok() .and_then(|p| p.parent().map(|p| p.join("updates"))) else { - return false; + return; }; // The installer will create a flag file after it finishes updating let flag_file = installer_path.join("versions.txt"); - if flag_file.exists() { - if let Some(helper) = installer_path + if flag_file.exists() + && let Some(helper) = installer_path .parent() .map(|p| p.join("tools\\auto_update_helper.exe")) - { - let _ = std::process::Command::new(helper).spawn(); - return true; - } + { + let mut command = std::process::Command::new(helper); + command.arg("--launch"); + command.arg("false"); + let _ = command.spawn(); } - false } #[cfg(test)] @@ -1002,7 +1032,6 @@ mod tests { let app_commit_sha = Ok(Some("a".to_string())); let installed_version = SemanticVersion::new(1, 0, 0); let status = AutoUpdateStatus::Updated { - binary_path: PathBuf::new(), version: VersionCheckType::Semantic(SemanticVersion::new(1, 0, 1)), }; let fetched_version = SemanticVersion::new(1, 0, 1); @@ -1024,7 +1053,6 @@ mod tests { let app_commit_sha = Ok(Some("a".to_string())); let installed_version = SemanticVersion::new(1, 0, 0); let status = AutoUpdateStatus::Updated { - binary_path: PathBuf::new(), version: VersionCheckType::Semantic(SemanticVersion::new(1, 0, 1)), }; let fetched_version = SemanticVersion::new(1, 0, 2); @@ -1090,7 +1118,6 @@ mod tests { let app_commit_sha = Ok(Some("a".to_string())); let installed_version = SemanticVersion::new(1, 0, 0); let status = AutoUpdateStatus::Updated { - binary_path: PathBuf::new(), version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())), }; let fetched_sha = "b".to_string(); @@ -1112,7 +1139,6 @@ mod tests { let app_commit_sha = Ok(Some("a".to_string())); let installed_version = SemanticVersion::new(1, 0, 0); let status = AutoUpdateStatus::Updated { - binary_path: PathBuf::new(), version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())), }; let fetched_sha = "c".to_string(); @@ -1160,7 +1186,6 @@ mod tests { let app_commit_sha = Ok(None); let installed_version = SemanticVersion::new(1, 0, 0); let status = AutoUpdateStatus::Updated { - binary_path: PathBuf::new(), version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())), }; let fetched_sha = "b".to_string(); @@ -1183,7 +1208,6 @@ mod tests { let app_commit_sha = Ok(None); let installed_version = SemanticVersion::new(1, 0, 0); let status = AutoUpdateStatus::Updated { - binary_path: PathBuf::new(), version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())), }; let fetched_sha = "c".to_string(); diff --git a/crates/auto_update_helper/src/auto_update_helper.rs b/crates/auto_update_helper/src/auto_update_helper.rs index 7c810d8724..2781176028 100644 --- a/crates/auto_update_helper/src/auto_update_helper.rs +++ b/crates/auto_update_helper/src/auto_update_helper.rs @@ -37,6 +37,11 @@ mod windows_impl { pub(crate) const WM_JOB_UPDATED: u32 = WM_USER + 1; pub(crate) const WM_TERMINATE: u32 = WM_USER + 2; + #[derive(Debug)] + struct Args { + launch: Option, + } + pub(crate) fn run() -> Result<()> { let helper_dir = std::env::current_exe()? .parent() @@ -51,8 +56,9 @@ mod windows_impl { log::info!("======= Starting Zed update ======="); let (tx, rx) = std::sync::mpsc::channel(); let hwnd = create_dialog_window(rx)?.0 as isize; + let args = parse_args(); std::thread::spawn(move || { - let result = perform_update(app_dir.as_path(), Some(hwnd)); + let result = perform_update(app_dir.as_path(), Some(hwnd), args.launch.unwrap_or(true)); tx.send(result).ok(); unsafe { PostMessageW(Some(HWND(hwnd as _)), WM_TERMINATE, WPARAM(0), LPARAM(0)) }.ok(); }); @@ -77,6 +83,41 @@ mod windows_impl { Ok(()) } + fn parse_args() -> Args { + let mut result = Args { launch: None }; + if let Some(candidate) = std::env::args().nth(1) { + parse_single_arg(&candidate, &mut result); + } + + result + } + + fn parse_single_arg(arg: &str, result: &mut Args) { + let Some((key, value)) = arg.strip_prefix("--").and_then(|arg| arg.split_once('=')) else { + log::error!( + "Invalid argument format: '{}'. Expected format: --key=value", + arg + ); + return; + }; + + match key { + "launch" => parse_launch_arg(value, &mut result.launch), + _ => log::error!("Unknown argument: --{}", key), + } + } + + fn parse_launch_arg(value: &str, arg: &mut Option) { + match value { + "true" => *arg = Some(true), + "false" => *arg = Some(false), + _ => log::error!( + "Invalid value for --launch: '{}'. Expected 'true' or 'false'", + value + ), + } + } + pub(crate) fn show_error(mut content: String) { if content.len() > 600 { content.truncate(600); @@ -91,4 +132,47 @@ mod windows_impl { ) }; } + + #[cfg(test)] + mod tests { + use crate::windows_impl::{Args, parse_launch_arg, parse_single_arg}; + + #[test] + fn test_parse_launch_arg() { + let mut arg = None; + parse_launch_arg("true", &mut arg); + assert_eq!(arg, Some(true)); + + let mut arg = None; + parse_launch_arg("false", &mut arg); + assert_eq!(arg, Some(false)); + + let mut arg = None; + parse_launch_arg("invalid", &mut arg); + assert_eq!(arg, None); + } + + #[test] + fn test_parse_single_arg() { + let mut args = Args { launch: None }; + parse_single_arg("--launch=true", &mut args); + assert_eq!(args.launch, Some(true)); + + let mut args = Args { launch: None }; + parse_single_arg("--launch=false", &mut args); + assert_eq!(args.launch, Some(false)); + + let mut args = Args { launch: None }; + parse_single_arg("--launch=invalid", &mut args); + assert_eq!(args.launch, None); + + let mut args = Args { launch: None }; + parse_single_arg("--launch", &mut args); + assert_eq!(args.launch, None); + + let mut args = Args { launch: None }; + parse_single_arg("--unknown", &mut args); + assert_eq!(args.launch, None); + } + } } diff --git a/crates/auto_update_helper/src/dialog.rs b/crates/auto_update_helper/src/dialog.rs index 010ebb4875..757819df51 100644 --- a/crates/auto_update_helper/src/dialog.rs +++ b/crates/auto_update_helper/src/dialog.rs @@ -72,7 +72,7 @@ pub(crate) fn create_dialog_window(receiver: Receiver>) -> Result) -> Result<()> { +pub(crate) fn perform_update(app_dir: &Path, hwnd: Option, launch: bool) -> Result<()> { let hwnd = hwnd.map(|ptr| HWND(ptr as _)); for job in JOBS.iter() { @@ -145,9 +145,11 @@ pub(crate) fn perform_update(app_dir: &Path, hwnd: Option) -> Result<()> } } } - let _ = std::process::Command::new(app_dir.join("Zed.exe")) - .creation_flags(CREATE_NEW_PROCESS_GROUP.0) - .spawn(); + if launch { + let _ = std::process::Command::new(app_dir.join("Zed.exe")) + .creation_flags(CREATE_NEW_PROCESS_GROUP.0) + .spawn(); + } log::info!("Update completed successfully"); Ok(()) } @@ -159,11 +161,11 @@ mod test { #[test] fn test_perform_update() { let app_dir = std::path::Path::new("C:/"); - assert!(perform_update(app_dir, None).is_ok()); + assert!(perform_update(app_dir, None, false).is_ok()); // Simulate a timeout unsafe { std::env::set_var("ZED_AUTO_UPDATE", "err") }; - let ret = perform_update(app_dir, None); + let ret = perform_update(app_dir, None, false); assert!(ret.is_err_and(|e| e.to_string().as_str() == "Timed out")); } } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index b31963c9c8..0d2ecec8f2 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -22456,7 +22456,7 @@ async fn test_invisible_worktree_servers(cx: &mut TestAppContext) { ); cx.update(|_, cx| { - workspace::reload(&workspace::Reload::default(), cx); + workspace::reload(cx); }); assert_language_servers_count( 1, diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index ded7bae316..5f6d252503 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -277,6 +277,8 @@ pub struct App { pub(crate) release_listeners: SubscriberSet, pub(crate) global_observers: SubscriberSet, pub(crate) quit_observers: SubscriberSet<(), QuitHandler>, + pub(crate) restart_observers: SubscriberSet<(), Handler>, + pub(crate) restart_path: Option, pub(crate) window_closed_observers: SubscriberSet<(), WindowClosedHandler>, pub(crate) layout_id_buffer: Vec, // We recycle this memory across layout requests. pub(crate) propagate_event: bool, @@ -349,6 +351,8 @@ impl App { keyboard_layout_observers: SubscriberSet::new(), global_observers: SubscriberSet::new(), quit_observers: SubscriberSet::new(), + restart_observers: SubscriberSet::new(), + restart_path: None, window_closed_observers: SubscriberSet::new(), layout_id_buffer: Default::default(), propagate_event: true, @@ -832,8 +836,16 @@ impl App { } /// Restarts the application. - pub fn restart(&self, binary_path: Option) { - self.platform.restart(binary_path) + pub fn restart(&mut self) { + self.restart_observers + .clone() + .retain(&(), |observer| observer(self)); + self.platform.restart(self.restart_path.take()) + } + + /// Sets the path to use when restarting the application. + pub fn set_restart_path(&mut self, path: PathBuf) { + self.restart_path = Some(path); } /// Returns the HTTP client for the application. @@ -1466,6 +1478,21 @@ impl App { subscription } + /// Register a callback to be invoked when the application is about to restart. + /// + /// These callbacks are called before any `on_app_quit` callbacks. + pub fn on_app_restart(&self, mut on_restart: impl 'static + FnMut(&mut App)) -> Subscription { + let (subscription, activate) = self.restart_observers.insert( + (), + Box::new(move |cx| { + on_restart(cx); + true + }), + ); + activate(); + subscription + } + /// Register a callback to be invoked when a window is closed /// The window is no longer accessible at the point this callback is invoked. pub fn on_window_closed(&self, mut on_closed: impl FnMut(&mut App) + 'static) -> Subscription { diff --git a/crates/gpui/src/app/context.rs b/crates/gpui/src/app/context.rs index 392be2ffe9..68c41592b3 100644 --- a/crates/gpui/src/app/context.rs +++ b/crates/gpui/src/app/context.rs @@ -164,6 +164,20 @@ impl<'a, T: 'static> Context<'a, T> { subscription } + /// Register a callback to be invoked when the application is about to restart. + pub fn on_app_restart( + &self, + mut on_restart: impl FnMut(&mut T, &mut App) + 'static, + ) -> Subscription + where + T: 'static, + { + let handle = self.weak_entity(); + self.app.on_app_restart(move |cx| { + handle.update(cx, |entity, cx| on_restart(entity, cx)).ok(); + }) + } + /// Arrange for the given function to be invoked whenever the application is quit. /// The future returned from this callback will be polled for up to [crate::SHUTDOWN_TIMEOUT] until the app fully quits. pub fn on_app_quit( @@ -175,20 +189,15 @@ impl<'a, T: 'static> Context<'a, T> { T: 'static, { let handle = self.weak_entity(); - let (subscription, activate) = self.app.quit_observers.insert( - (), - Box::new(move |cx| { - let future = handle.update(cx, |entity, cx| on_quit(entity, cx)).ok(); - async move { - if let Some(future) = future { - future.await; - } + self.app.on_app_quit(move |cx| { + let future = handle.update(cx, |entity, cx| on_quit(entity, cx)).ok(); + async move { + if let Some(future) = future { + future.await; } - .boxed_local() - }), - ); - activate(); - subscription + } + .boxed_local() + }) } /// Tell GPUI that this entity has changed and observers of it should be notified. diff --git a/crates/gpui/src/platform/windows/platform.rs b/crates/gpui/src/platform/windows/platform.rs index 9e5d359e43..bbde655b80 100644 --- a/crates/gpui/src/platform/windows/platform.rs +++ b/crates/gpui/src/platform/windows/platform.rs @@ -370,9 +370,9 @@ impl Platform for WindowsPlatform { .detach(); } - fn restart(&self, _: Option) { + fn restart(&self, binary_path: Option) { let pid = std::process::id(); - let Some(app_path) = self.app_path().log_err() else { + let Some(app_path) = binary_path.or(self.app_path().log_err()) else { return; }; let script = format!( diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index d11d3b7081..eb317a5616 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -595,7 +595,7 @@ impl TitleBar { .on_click(|_, window, cx| { if let Some(auto_updater) = auto_update::AutoUpdater::get(cx) { if auto_updater.read(cx).status().is_updated() { - workspace::reload(&Default::default(), cx); + workspace::reload(cx); return; } } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index aab8a36f45..98794e54cd 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -224,6 +224,8 @@ actions!( ResetActiveDockSize, /// Resets all open docks to their default sizes. ResetOpenDocksSize, + /// Reloads the application + Reload, /// Saves the current file with a new name. SaveAs, /// Saves without formatting. @@ -340,14 +342,6 @@ pub struct CloseInactiveTabsAndPanes { #[action(namespace = workspace)] pub struct SendKeystrokes(pub String); -/// Reloads the active item or workspace. -#[derive(Clone, Deserialize, PartialEq, Default, JsonSchema, Action)] -#[action(namespace = workspace)] -#[serde(deny_unknown_fields)] -pub struct Reload { - pub binary_path: Option, -} - actions!( project_symbols, [ @@ -555,8 +549,8 @@ pub fn init(app_state: Arc, cx: &mut App) { toast_layer::init(cx); history_manager::init(cx); - cx.on_action(Workspace::close_global); - cx.on_action(reload); + cx.on_action(|_: &CloseWindow, cx| Workspace::close_global(cx)); + cx.on_action(|_: &Reload, cx| reload(cx)); cx.on_action({ let app_state = Arc::downgrade(&app_state); @@ -2184,7 +2178,7 @@ impl Workspace { } } - pub fn close_global(_: &CloseWindow, cx: &mut App) { + pub fn close_global(cx: &mut App) { cx.defer(|cx| { cx.windows().iter().find(|window| { window @@ -7642,7 +7636,7 @@ pub fn join_in_room_project( }) } -pub fn reload(reload: &Reload, cx: &mut App) { +pub fn reload(cx: &mut App) { let should_confirm = WorkspaceSettings::get_global(cx).confirm_quit; let mut workspace_windows = cx .windows() @@ -7669,7 +7663,6 @@ pub fn reload(reload: &Reload, cx: &mut App) { .ok(); } - let binary_path = reload.binary_path.clone(); cx.spawn(async move |cx| { if let Some(prompt) = prompt { let answer = prompt.await?; @@ -7688,8 +7681,7 @@ pub fn reload(reload: &Reload, cx: &mut App) { } } } - - cx.update(|cx| cx.restart(binary_path)) + cx.update(|cx| cx.restart()) }) .detach_and_log_err(cx); } diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index e4a14b5d32..457372b4af 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -201,16 +201,6 @@ pub fn main() { return; } - // Check if there is a pending installer - // If there is, run the installer and exit - // And we don't want to run the installer if we are not the first instance - #[cfg(target_os = "windows")] - let is_first_instance = crate::zed::windows_only_instance::is_first_instance(); - #[cfg(target_os = "windows")] - if is_first_instance && auto_update::check_pending_installation() { - return; - } - if args.dump_all_actions { dump_all_gpui_actions(); return; @@ -283,30 +273,27 @@ pub fn main() { let (open_listener, mut open_rx) = OpenListener::new(); - let failed_single_instance_check = - if *db::ZED_STATELESS || *release_channel::RELEASE_CHANNEL == ReleaseChannel::Dev { - false - } else { - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - { - crate::zed::listen_for_cli_connections(open_listener.clone()).is_err() - } + let failed_single_instance_check = if *db::ZED_STATELESS + || *release_channel::RELEASE_CHANNEL == ReleaseChannel::Dev + { + false + } else { + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + { + crate::zed::listen_for_cli_connections(open_listener.clone()).is_err() + } - #[cfg(target_os = "windows")] - { - !crate::zed::windows_only_instance::handle_single_instance( - open_listener.clone(), - &args, - is_first_instance, - ) - } + #[cfg(target_os = "windows")] + { + !crate::zed::windows_only_instance::handle_single_instance(open_listener.clone(), &args) + } - #[cfg(target_os = "macos")] - { - use zed::mac_only_instance::*; - ensure_only_instance() != IsOnlyInstance::Yes - } - }; + #[cfg(target_os = "macos")] + { + use zed::mac_only_instance::*; + ensure_only_instance() != IsOnlyInstance::Yes + } + }; if failed_single_instance_check { println!("zed is already running"); return; diff --git a/crates/zed/src/zed/windows_only_instance.rs b/crates/zed/src/zed/windows_only_instance.rs index 277e8ee724..bd62dea75a 100644 --- a/crates/zed/src/zed/windows_only_instance.rs +++ b/crates/zed/src/zed/windows_only_instance.rs @@ -25,7 +25,8 @@ use windows::{ use crate::{Args, OpenListener, RawOpenRequest}; -pub fn is_first_instance() -> bool { +#[inline] +fn is_first_instance() -> bool { unsafe { CreateMutexW( None, @@ -37,7 +38,8 @@ pub fn is_first_instance() -> bool { unsafe { GetLastError() != ERROR_ALREADY_EXISTS } } -pub fn handle_single_instance(opener: OpenListener, args: &Args, is_first_instance: bool) -> bool { +pub fn handle_single_instance(opener: OpenListener, args: &Args) -> bool { + let is_first_instance = is_first_instance(); if is_first_instance { // We are the first instance, listen for messages sent from other instances std::thread::spawn(move || { From d78bd8f1d738b3d9da23b707467237500ca4e961 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Tue, 12 Aug 2025 21:41:00 -0400 Subject: [PATCH 104/109] Fix external agent still being marked as generating after error response (#35992) Release Notes: - N/A --- crates/acp_thread/src/acp_thread.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index cadab3d62c..d09c80fe9d 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -1072,8 +1072,11 @@ impl AcpThread { cx.spawn(async move |this, cx| match rx.await { Ok(Err(e)) => { - this.update(cx, |_, cx| cx.emit(AcpThreadEvent::Error)) - .log_err(); + this.update(cx, |this, cx| { + this.send_task.take(); + cx.emit(AcpThreadEvent::Error) + }) + .log_err(); Err(e)? } result => { From 1957e1f642456e26efff735436ea955d52f920cd Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Tue, 12 Aug 2025 21:48:28 -0400 Subject: [PATCH 105/109] Add locations to native agent tool calls, and wire them up to UI (#36058) Release Notes: - N/A --------- Co-authored-by: Conrad --- Cargo.lock | 1 + crates/acp_thread/src/acp_thread.rs | 155 ++++++++++++++----- crates/agent2/Cargo.toml | 1 + crates/agent2/src/tools/edit_file_tool.rs | 42 ++++- crates/agent2/src/tools/read_file_tool.rs | 63 ++++---- crates/agent_ui/src/acp/thread_view.rs | 38 +++-- crates/assistant_tools/src/edit_agent.rs | 69 ++++++--- crates/assistant_tools/src/edit_file_tool.rs | 2 +- 8 files changed, 257 insertions(+), 114 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d24a399c1c..9ac0809d25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -231,6 +231,7 @@ dependencies = [ "task", "tempfile", "terminal", + "text", "theme", "tree-sitter-rust", "ui", diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index d09c80fe9d..80e0a31f97 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -13,9 +13,9 @@ use agent_client_protocol::{self as acp}; use anyhow::{Context as _, Result}; use editor::Bias; use futures::{FutureExt, channel::oneshot, future::BoxFuture}; -use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Task}; +use gpui::{AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity}; use itertools::Itertools; -use language::{Anchor, Buffer, BufferSnapshot, LanguageRegistry, Point, text_diff}; +use language::{Anchor, Buffer, BufferSnapshot, LanguageRegistry, Point, ToPoint, text_diff}; use markdown::Markdown; use project::{AgentLocation, Project}; use std::collections::HashMap; @@ -122,9 +122,17 @@ impl AgentThreadEntry { } } - pub fn locations(&self) -> Option<&[acp::ToolCallLocation]> { - if let AgentThreadEntry::ToolCall(ToolCall { locations, .. }) = self { - Some(locations) + pub fn location(&self, ix: usize) -> Option<(acp::ToolCallLocation, AgentLocation)> { + if let AgentThreadEntry::ToolCall(ToolCall { + locations, + resolved_locations, + .. + }) = self + { + Some(( + locations.get(ix)?.clone(), + resolved_locations.get(ix)?.clone()?, + )) } else { None } @@ -139,6 +147,7 @@ pub struct ToolCall { pub content: Vec, pub status: ToolCallStatus, pub locations: Vec, + pub resolved_locations: Vec>, pub raw_input: Option, pub raw_output: Option, } @@ -167,6 +176,7 @@ impl ToolCall { .map(|content| ToolCallContent::from_acp(content, language_registry.clone(), cx)) .collect(), locations: tool_call.locations, + resolved_locations: Vec::default(), status, raw_input: tool_call.raw_input, raw_output: tool_call.raw_output, @@ -260,6 +270,57 @@ impl ToolCall { } markdown } + + async fn resolve_location( + location: acp::ToolCallLocation, + project: WeakEntity, + cx: &mut AsyncApp, + ) -> Option { + let buffer = project + .update(cx, |project, cx| { + if let Some(path) = project.project_path_for_absolute_path(&location.path, cx) { + Some(project.open_buffer(path, cx)) + } else { + None + } + }) + .ok()??; + let buffer = buffer.await.log_err()?; + let position = buffer + .update(cx, |buffer, _| { + if let Some(row) = location.line { + let snapshot = buffer.snapshot(); + let column = snapshot.indent_size_for_line(row).len; + let point = snapshot.clip_point(Point::new(row, column), Bias::Left); + snapshot.anchor_before(point) + } else { + Anchor::MIN + } + }) + .ok()?; + + Some(AgentLocation { + buffer: buffer.downgrade(), + position, + }) + } + + fn resolve_locations( + &self, + project: Entity, + cx: &mut App, + ) -> Task>> { + let locations = self.locations.clone(); + project.update(cx, |_, cx| { + cx.spawn(async move |project, cx| { + let mut new_locations = Vec::new(); + for location in locations { + new_locations.push(Self::resolve_location(location, project.clone(), cx).await); + } + new_locations + }) + }) + } } #[derive(Debug)] @@ -804,7 +865,11 @@ impl AcpThread { .context("Tool call not found")?; match update { ToolCallUpdate::UpdateFields(update) => { + let location_updated = update.fields.locations.is_some(); current_call.update_fields(update.fields, languages, cx); + if location_updated { + self.resolve_locations(update.id.clone(), cx); + } } ToolCallUpdate::UpdateDiff(update) => { current_call.content.clear(); @@ -841,8 +906,7 @@ impl AcpThread { ) { let language_registry = self.project.read(cx).languages().clone(); let call = ToolCall::from_acp(tool_call, status, language_registry, cx); - - let location = call.locations.last().cloned(); + let id = call.id.clone(); if let Some((ix, current_call)) = self.tool_call_mut(&call.id) { *current_call = call; @@ -850,11 +914,9 @@ impl AcpThread { cx.emit(AcpThreadEvent::EntryUpdated(ix)); } else { self.push_entry(AgentThreadEntry::ToolCall(call), cx); - } + }; - if let Some(location) = location { - self.set_project_location(location, cx) - } + self.resolve_locations(id, cx); } fn tool_call_mut(&mut self, id: &acp::ToolCallId) -> Option<(usize, &mut ToolCall)> { @@ -875,35 +937,50 @@ impl AcpThread { }) } - pub fn set_project_location(&self, location: acp::ToolCallLocation, cx: &mut Context) { - self.project.update(cx, |project, cx| { - let Some(path) = project.project_path_for_absolute_path(&location.path, cx) else { - return; - }; - let buffer = project.open_buffer(path, cx); - cx.spawn(async move |project, cx| { - let buffer = buffer.await?; - - project.update(cx, |project, cx| { - let position = if let Some(line) = location.line { - let snapshot = buffer.read(cx).snapshot(); - let point = snapshot.clip_point(Point::new(line, 0), Bias::Left); - snapshot.anchor_before(point) - } else { - Anchor::MIN - }; - - project.set_agent_location( - Some(AgentLocation { - buffer: buffer.downgrade(), - position, - }), - cx, - ); - }) + pub fn resolve_locations(&mut self, id: acp::ToolCallId, cx: &mut Context) { + let project = self.project.clone(); + let Some((_, tool_call)) = self.tool_call_mut(&id) else { + return; + }; + let task = tool_call.resolve_locations(project, cx); + cx.spawn(async move |this, cx| { + let resolved_locations = task.await; + this.update(cx, |this, cx| { + let project = this.project.clone(); + let Some((ix, tool_call)) = this.tool_call_mut(&id) else { + return; + }; + if let Some(Some(location)) = resolved_locations.last() { + project.update(cx, |project, cx| { + if let Some(agent_location) = project.agent_location() { + let should_ignore = agent_location.buffer == location.buffer + && location + .buffer + .update(cx, |buffer, _| { + let snapshot = buffer.snapshot(); + let old_position = + agent_location.position.to_point(&snapshot); + let new_position = location.position.to_point(&snapshot); + // ignore this so that when we get updates from the edit tool + // the position doesn't reset to the startof line + old_position.row == new_position.row + && old_position.column > new_position.column + }) + .ok() + .unwrap_or_default(); + if !should_ignore { + project.set_agent_location(Some(location.clone()), cx); + } + } + }); + } + if tool_call.resolved_locations != resolved_locations { + tool_call.resolved_locations = resolved_locations; + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + } }) - .detach_and_log_err(cx); - }); + }) + .detach(); } pub fn request_tool_call_authorization( diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml index 1030380dc0..ac1840e5e5 100644 --- a/crates/agent2/Cargo.toml +++ b/crates/agent2/Cargo.toml @@ -49,6 +49,7 @@ settings.workspace = true smol.workspace = true task.workspace = true terminal.workspace = true +text.workspace = true ui.workspace = true util.workspace = true uuid.workspace = true diff --git a/crates/agent2/src/tools/edit_file_tool.rs b/crates/agent2/src/tools/edit_file_tool.rs index 134bc5e5e4..405afb585f 100644 --- a/crates/agent2/src/tools/edit_file_tool.rs +++ b/crates/agent2/src/tools/edit_file_tool.rs @@ -1,12 +1,13 @@ use crate::{AgentTool, Thread, ToolCallEventStream}; use acp_thread::Diff; -use agent_client_protocol as acp; +use agent_client_protocol::{self as acp, ToolCallLocation, ToolCallUpdateFields}; use anyhow::{Context as _, Result, anyhow}; use assistant_tools::edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat}; use cloud_llm_client::CompletionIntent; use collections::HashSet; use gpui::{App, AppContext, AsyncApp, Entity, Task}; use indoc::formatdoc; +use language::ToPoint; use language::language_settings::{self, FormatOnSave}; use language_model::LanguageModelToolResultContent; use paths; @@ -225,6 +226,16 @@ impl AgentTool for EditFileTool { Ok(path) => path, Err(err) => return Task::ready(Err(anyhow!(err))), }; + let abs_path = project.read(cx).absolute_path(&project_path, cx); + if let Some(abs_path) = abs_path.clone() { + event_stream.update_fields(ToolCallUpdateFields { + locations: Some(vec![acp::ToolCallLocation { + path: abs_path, + line: None, + }]), + ..Default::default() + }); + } let request = self.thread.update(cx, |thread, cx| { thread.build_completion_request(CompletionIntent::ToolResults, cx) @@ -283,13 +294,38 @@ impl AgentTool for EditFileTool { let mut hallucinated_old_text = false; let mut ambiguous_ranges = Vec::new(); + let mut emitted_location = false; while let Some(event) = events.next().await { match event { - EditAgentOutputEvent::Edited => {}, + EditAgentOutputEvent::Edited(range) => { + if !emitted_location { + let line = buffer.update(cx, |buffer, _cx| { + range.start.to_point(&buffer.snapshot()).row + }).ok(); + if let Some(abs_path) = abs_path.clone() { + event_stream.update_fields(ToolCallUpdateFields { + locations: Some(vec![ToolCallLocation { path: abs_path, line }]), + ..Default::default() + }); + } + emitted_location = true; + } + }, EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true, EditAgentOutputEvent::AmbiguousEditRange(ranges) => ambiguous_ranges = ranges, EditAgentOutputEvent::ResolvingEditRange(range) => { - diff.update(cx, |card, cx| card.reveal_range(range, cx))?; + diff.update(cx, |card, cx| card.reveal_range(range.clone(), cx))?; + // if !emitted_location { + // let line = buffer.update(cx, |buffer, _cx| { + // range.start.to_point(&buffer.snapshot()).row + // }).ok(); + // if let Some(abs_path) = abs_path.clone() { + // event_stream.update_fields(ToolCallUpdateFields { + // locations: Some(vec![ToolCallLocation { path: abs_path, line }]), + // ..Default::default() + // }); + // } + // } } } } diff --git a/crates/agent2/src/tools/read_file_tool.rs b/crates/agent2/src/tools/read_file_tool.rs index fac637d838..f21643cbbb 100644 --- a/crates/agent2/src/tools/read_file_tool.rs +++ b/crates/agent2/src/tools/read_file_tool.rs @@ -1,10 +1,10 @@ use action_log::ActionLog; -use agent_client_protocol::{self as acp}; +use agent_client_protocol::{self as acp, ToolCallUpdateFields}; use anyhow::{Context as _, Result, anyhow}; use assistant_tool::outline; use gpui::{App, Entity, SharedString, Task}; use indoc::formatdoc; -use language::{Anchor, Point}; +use language::Point; use language_model::{LanguageModelImage, LanguageModelToolResultContent}; use project::{AgentLocation, ImageItem, Project, WorktreeSettings, image_store}; use schemars::JsonSchema; @@ -97,7 +97,7 @@ impl AgentTool for ReadFileTool { fn run( self: Arc, input: Self::Input, - _event_stream: ToolCallEventStream, + event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx) else { @@ -166,7 +166,9 @@ impl AgentTool for ReadFileTool { cx.spawn(async move |cx| { let buffer = cx .update(|cx| { - project.update(cx, |project, cx| project.open_buffer(project_path, cx)) + project.update(cx, |project, cx| { + project.open_buffer(project_path.clone(), cx) + }) })? .await?; if buffer.read_with(cx, |buffer, _| { @@ -178,19 +180,10 @@ impl AgentTool for ReadFileTool { anyhow::bail!("{file_path} not found"); } - project.update(cx, |project, cx| { - project.set_agent_location( - Some(AgentLocation { - buffer: buffer.downgrade(), - position: Anchor::MIN, - }), - cx, - ); - })?; + let mut anchor = None; // Check if specific line ranges are provided - if input.start_line.is_some() || input.end_line.is_some() { - let mut anchor = None; + let result = if input.start_line.is_some() || input.end_line.is_some() { let result = buffer.read_with(cx, |buffer, _cx| { let text = buffer.text(); // .max(1) because despite instructions to be 1-indexed, sometimes the model passes 0. @@ -214,18 +207,6 @@ impl AgentTool for ReadFileTool { log.buffer_read(buffer.clone(), cx); })?; - if let Some(anchor) = anchor { - project.update(cx, |project, cx| { - project.set_agent_location( - Some(AgentLocation { - buffer: buffer.downgrade(), - position: anchor, - }), - cx, - ); - })?; - } - Ok(result.into()) } else { // No line ranges specified, so check file size to see if it's too big. @@ -236,7 +217,7 @@ impl AgentTool for ReadFileTool { let result = buffer.read_with(cx, |buffer, _cx| buffer.text())?; action_log.update(cx, |log, cx| { - log.buffer_read(buffer, cx); + log.buffer_read(buffer.clone(), cx); })?; Ok(result.into()) @@ -244,7 +225,8 @@ impl AgentTool for ReadFileTool { // File is too big, so return the outline // and a suggestion to read again with line numbers. let outline = - outline::file_outline(project, file_path, action_log, None, cx).await?; + outline::file_outline(project.clone(), file_path, action_log, None, cx) + .await?; Ok(formatdoc! {" This file was too big to read all at once. @@ -261,7 +243,28 @@ impl AgentTool for ReadFileTool { } .into()) } - } + }; + + project.update(cx, |project, cx| { + if let Some(abs_path) = project.absolute_path(&project_path, cx) { + project.set_agent_location( + Some(AgentLocation { + buffer: buffer.downgrade(), + position: anchor.unwrap_or(text::Anchor::MIN), + }), + cx, + ); + event_stream.update_fields(ToolCallUpdateFields { + locations: Some(vec![acp::ToolCallLocation { + path: abs_path, + line: input.start_line.map(|line| line.saturating_sub(1)), + }]), + ..Default::default() + }); + } + })?; + + result }) } } diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index f47c7a0bc5..da7915222e 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -27,6 +27,7 @@ use language::{Buffer, Language}; use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle}; use parking_lot::Mutex; use project::{CompletionIntent, Project}; +use rope::Point; use settings::{Settings as _, SettingsStore}; use std::path::PathBuf; use std::{ @@ -2679,26 +2680,24 @@ impl AcpThreadView { window: &mut Window, cx: &mut Context, ) -> Option<()> { - let location = self + let (tool_call_location, agent_location) = self .thread()? .read(cx) .entries() .get(entry_ix)? - .locations()? - .get(location_ix)?; + .location(location_ix)?; let project_path = self .project .read(cx) - .find_project_path(&location.path, cx)?; + .find_project_path(&tool_call_location.path, cx)?; let open_task = self .workspace - .update(cx, |worskpace, cx| { - worskpace.open_path(project_path, None, true, window, cx) + .update(cx, |workspace, cx| { + workspace.open_path(project_path, None, true, window, cx) }) .log_err()?; - window .spawn(cx, async move |cx| { let item = open_task.await?; @@ -2708,17 +2707,22 @@ impl AcpThreadView { }; active_editor.update_in(cx, |editor, window, cx| { - let snapshot = editor.buffer().read(cx).snapshot(cx); - let first_hunk = editor - .diff_hunks_in_ranges( - &[editor::Anchor::min()..editor::Anchor::max()], - &snapshot, - ) - .next(); - if let Some(first_hunk) = first_hunk { - let first_hunk_start = first_hunk.multi_buffer_range().start; + let multibuffer = editor.buffer().read(cx); + let buffer = multibuffer.as_singleton(); + if agent_location.buffer.upgrade() == buffer { + let excerpt_id = multibuffer.excerpt_ids().first().cloned(); + let anchor = editor::Anchor::in_buffer( + excerpt_id.unwrap(), + buffer.unwrap().read(cx).remote_id(), + agent_location.position, + ); editor.change_selections(Default::default(), window, cx, |selections| { - selections.select_anchor_ranges([first_hunk_start..first_hunk_start]); + selections.select_anchor_ranges([anchor..anchor]); + }) + } else { + let row = tool_call_location.line.unwrap_or_default(); + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([Point::new(row, 0)..Point::new(row, 0)]); }) } })?; diff --git a/crates/assistant_tools/src/edit_agent.rs b/crates/assistant_tools/src/edit_agent.rs index 9305f584cb..aa321aa8f3 100644 --- a/crates/assistant_tools/src/edit_agent.rs +++ b/crates/assistant_tools/src/edit_agent.rs @@ -65,7 +65,7 @@ pub enum EditAgentOutputEvent { ResolvingEditRange(Range), UnresolvedEditRange, AmbiguousEditRange(Vec>), - Edited, + Edited(Range), } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] @@ -178,7 +178,9 @@ impl EditAgent { ) }); output_events_tx - .unbounded_send(EditAgentOutputEvent::Edited) + .unbounded_send(EditAgentOutputEvent::Edited( + language::Anchor::MIN..language::Anchor::MAX, + )) .ok(); })?; @@ -200,7 +202,9 @@ impl EditAgent { }); })?; output_events_tx - .unbounded_send(EditAgentOutputEvent::Edited) + .unbounded_send(EditAgentOutputEvent::Edited( + language::Anchor::MIN..language::Anchor::MAX, + )) .ok(); } } @@ -336,8 +340,8 @@ impl EditAgent { // Edit the buffer and report edits to the action log as part of the // same effect cycle, otherwise the edit will be reported as if the // user made it. - cx.update(|cx| { - let max_edit_end = buffer.update(cx, |buffer, cx| { + let (min_edit_start, max_edit_end) = cx.update(|cx| { + let (min_edit_start, max_edit_end) = buffer.update(cx, |buffer, cx| { buffer.edit(edits.iter().cloned(), None, cx); let max_edit_end = buffer .summaries_for_anchors::( @@ -345,7 +349,16 @@ impl EditAgent { ) .max() .unwrap(); - buffer.anchor_before(max_edit_end) + let min_edit_start = buffer + .summaries_for_anchors::( + edits.iter().map(|(range, _)| &range.start), + ) + .min() + .unwrap(); + ( + buffer.anchor_after(min_edit_start), + buffer.anchor_before(max_edit_end), + ) }); self.action_log .update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); @@ -358,9 +371,10 @@ impl EditAgent { cx, ); }); + (min_edit_start, max_edit_end) })?; output_events - .unbounded_send(EditAgentOutputEvent::Edited) + .unbounded_send(EditAgentOutputEvent::Edited(min_edit_start..max_edit_end)) .ok(); } @@ -755,6 +769,7 @@ mod tests { use gpui::{AppContext, TestAppContext}; use indoc::indoc; use language_model::fake_provider::FakeLanguageModel; + use pretty_assertions::assert_matches; use project::{AgentLocation, Project}; use rand::prelude::*; use rand::rngs::StdRng; @@ -992,7 +1007,10 @@ mod tests { model.send_last_completion_stream_text_chunk("abX"); cx.run_until_parked(); - assert_eq!(drain_events(&mut events), [EditAgentOutputEvent::Edited]); + assert_matches!( + drain_events(&mut events).as_slice(), + [EditAgentOutputEvent::Edited(_)] + ); assert_eq!( buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), "abXc\ndef\nghi\njkl" @@ -1007,7 +1025,10 @@ mod tests { model.send_last_completion_stream_text_chunk("cY"); cx.run_until_parked(); - assert_eq!(drain_events(&mut events), [EditAgentOutputEvent::Edited]); + assert_matches!( + drain_events(&mut events).as_slice(), + [EditAgentOutputEvent::Edited { .. }] + ); assert_eq!( buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), "abXcY\ndef\nghi\njkl" @@ -1118,9 +1139,9 @@ mod tests { model.send_last_completion_stream_text_chunk("GHI"); cx.run_until_parked(); - assert_eq!( - drain_events(&mut events), - vec![EditAgentOutputEvent::Edited] + assert_matches!( + drain_events(&mut events).as_slice(), + [EditAgentOutputEvent::Edited { .. }] ); assert_eq!( buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), @@ -1165,9 +1186,9 @@ mod tests { ); cx.run_until_parked(); - assert_eq!( - drain_events(&mut events), - vec![EditAgentOutputEvent::Edited] + assert_matches!( + drain_events(&mut events).as_slice(), + [EditAgentOutputEvent::Edited(_)] ); assert_eq!( buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), @@ -1183,9 +1204,9 @@ mod tests { chunks_tx.unbounded_send("```\njkl\n").unwrap(); cx.run_until_parked(); - assert_eq!( - drain_events(&mut events), - vec![EditAgentOutputEvent::Edited] + assert_matches!( + drain_events(&mut events).as_slice(), + [EditAgentOutputEvent::Edited { .. }] ); assert_eq!( buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), @@ -1201,9 +1222,9 @@ mod tests { chunks_tx.unbounded_send("mno\n").unwrap(); cx.run_until_parked(); - assert_eq!( - drain_events(&mut events), - vec![EditAgentOutputEvent::Edited] + assert_matches!( + drain_events(&mut events).as_slice(), + [EditAgentOutputEvent::Edited { .. }] ); assert_eq!( buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), @@ -1219,9 +1240,9 @@ mod tests { chunks_tx.unbounded_send("pqr\n```").unwrap(); cx.run_until_parked(); - assert_eq!( - drain_events(&mut events), - vec![EditAgentOutputEvent::Edited] + assert_matches!( + drain_events(&mut events).as_slice(), + [EditAgentOutputEvent::Edited(_)], ); assert_eq!( buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), diff --git a/crates/assistant_tools/src/edit_file_tool.rs b/crates/assistant_tools/src/edit_file_tool.rs index b5712415ec..e819c51e1e 100644 --- a/crates/assistant_tools/src/edit_file_tool.rs +++ b/crates/assistant_tools/src/edit_file_tool.rs @@ -307,7 +307,7 @@ impl Tool for EditFileTool { let mut ambiguous_ranges = Vec::new(); while let Some(event) = events.next().await { match event { - EditAgentOutputEvent::Edited => { + EditAgentOutputEvent::Edited { .. } => { if let Some(card) = card_clone.as_ref() { card.update(cx, |card, cx| card.update_diff(cx))?; } From dc87f4b32e4c370d623a0cefbb32ea368fbc2c05 Mon Sep 17 00:00:00 2001 From: morgankrey Date: Tue, 12 Aug 2025 21:15:48 -0600 Subject: [PATCH 106/109] Add 4.1 to models page (#36086) Adds opus 4.1 to models page in docs Release Notes: - N/A --- docs/src/ai/models.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/src/ai/models.md b/docs/src/ai/models.md index b40f17b77f..8d46d0b8d1 100644 --- a/docs/src/ai/models.md +++ b/docs/src/ai/models.md @@ -12,8 +12,10 @@ We’re working hard to expand the models supported by Zed’s subscription offe | Claude Sonnet 4 | Anthropic | ✅ | 200k | N/A | $0.05 | | Claude Opus 4 | Anthropic | ❌ | 120k | $0.20 | N/A | | Claude Opus 4 | Anthropic | ✅ | 200k | N/A | $0.25 | +| Claude Opus 4.1 | Anthropic | ❌ | 120k | $0.20 | N/A | +| Claude Opus 4.1 | Anthropic | ✅ | 200k | N/A | $0.25 | -> Note: Because of the 5x token cost for [Opus relative to Sonnet](https://www.anthropic.com/pricing#api), each Opus prompt consumes 5 prompts against your billing meter +> Note: Because of the 5x token cost for [Opus relative to Sonnet](https://www.anthropic.com/pricing#api), each Opus 4 and 4.1 prompt consumes 5 prompts against your billing meter ## Usage {#usage} From 96093aa465f14eeb01fa6e6c457f57a0283c1e69 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Wed, 13 Aug 2025 01:18:11 -0400 Subject: [PATCH 107/109] onboarding: Link git clone button with action (#35999) Release Notes: - N/A --- Cargo.lock | 1 + crates/git_ui/src/git_panel.rs | 2 +- crates/git_ui/src/git_ui.rs | 4 ---- crates/onboarding/Cargo.toml | 1 + crates/onboarding/src/welcome.rs | 5 ++--- 5 files changed, 5 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9ac0809d25..ffcaf64859 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11157,6 +11157,7 @@ dependencies = [ "feature_flags", "fs", "fuzzy", + "git", "gpui", "itertools 0.14.0", "language", diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 75fac114d2..de308b9dde 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -2105,7 +2105,7 @@ impl GitPanel { Ok(_) => cx.update(|window, cx| { window.prompt( PromptLevel::Info, - "Git Clone", + &format!("Git Clone: {}", repo_name), None, &["Add repo to project", "Open repo in new project"], cx, diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 7d5207dfb6..79aa4a6bd0 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -181,10 +181,6 @@ pub fn init(cx: &mut App) { workspace.toggle_modal(window, cx, |window, cx| { GitCloneModal::show(panel, window, cx) }); - - // panel.update(cx, |panel, cx| { - // panel.git_clone(window, cx); - // }); }); workspace.register_action(|workspace, _: &git::OpenModifiedFiles, window, cx| { open_modified_files(workspace, window, cx); diff --git a/crates/onboarding/Cargo.toml b/crates/onboarding/Cargo.toml index 436c714cf3..cb07bb5dab 100644 --- a/crates/onboarding/Cargo.toml +++ b/crates/onboarding/Cargo.toml @@ -26,6 +26,7 @@ editor.workspace = true feature_flags.workspace = true fs.workspace = true fuzzy.workspace = true +git.workspace = true gpui.workspace = true itertools.workspace = true language.workspace = true diff --git a/crates/onboarding/src/welcome.rs b/crates/onboarding/src/welcome.rs index d4d6c3f701..65baad03a0 100644 --- a/crates/onboarding/src/welcome.rs +++ b/crates/onboarding/src/welcome.rs @@ -1,6 +1,6 @@ use gpui::{ Action, App, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, - NoAction, ParentElement, Render, Styled, Window, actions, + ParentElement, Render, Styled, Window, actions, }; use menu::{SelectNext, SelectPrevious}; use ui::{ButtonLike, Divider, DividerColor, KeyBinding, Vector, VectorName, prelude::*}; @@ -38,8 +38,7 @@ const CONTENT: (Section<4>, Section<3>) = ( SectionEntry { icon: IconName::CloudDownload, title: "Clone a Repo", - // TODO: use proper action - action: &NoAction, + action: &git::Clone, }, SectionEntry { icon: IconName::ListCollapse, From 8ff2e3e1956543a0bf1f801aaec05f8993030c91 Mon Sep 17 00:00:00 2001 From: Cretezy Date: Wed, 13 Aug 2025 02:09:16 -0400 Subject: [PATCH 108/109] language_models: Add reasoning_effort for custom models (#35929) Release Notes: - Added `reasoning_effort` support to custom models Tested using the following config: ```json5 "language_models": { "openai": { "available_models": [ { "name": "gpt-5-mini", "display_name": "GPT 5 Mini (custom reasoning)", "max_output_tokens": 128000, "max_tokens": 272000, "reasoning_effort": "high" // Can be minimal, low, medium (default), and high } ], "version": "1" } } ``` Docs: https://platform.openai.com/docs/api-reference/chat/create#chat_create-reasoning_effort This work could be used to split the GPT 5/5-mini/5-nano into each of it's reasoning effort variant. E.g. `gpt-5`, `gpt-5 low`, `gpt-5 minimal`, `gpt-5 high`, and same for mini/nano. Release Notes: * Added a setting to control `reasoning_effort` in OpenAI models --- crates/language_models/src/provider/cloud.rs | 1 + .../language_models/src/provider/open_ai.rs | 7 +++++- .../src/provider/open_ai_compatible.rs | 8 ++++++- crates/language_models/src/provider/vercel.rs | 1 + crates/language_models/src/provider/x_ai.rs | 1 + crates/open_ai/src/open_ai.rs | 22 +++++++++++++++++++ 6 files changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index ba110be9c5..ff8048040e 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -942,6 +942,7 @@ impl LanguageModel for CloudLanguageModel { model.id(), model.supports_parallel_tool_calls(), None, + None, ); let llm_api_token = self.llm_api_token.clone(); let future = self.request_limiter.stream(async move { diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index 9eac58c880..725027b2a7 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -14,7 +14,7 @@ use language_model::{ RateLimiter, Role, StopReason, TokenUsage, }; use menu; -use open_ai::{ImageUrl, Model, ResponseStreamEvent, stream_completion}; +use open_ai::{ImageUrl, Model, ReasoningEffort, ResponseStreamEvent, stream_completion}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; @@ -45,6 +45,7 @@ pub struct AvailableModel { pub max_tokens: u64, pub max_output_tokens: Option, pub max_completion_tokens: Option, + pub reasoning_effort: Option, } pub struct OpenAiLanguageModelProvider { @@ -213,6 +214,7 @@ impl LanguageModelProvider for OpenAiLanguageModelProvider { max_tokens: model.max_tokens, max_output_tokens: model.max_output_tokens, max_completion_tokens: model.max_completion_tokens, + reasoning_effort: model.reasoning_effort.clone(), }, ); } @@ -369,6 +371,7 @@ impl LanguageModel for OpenAiLanguageModel { self.model.id(), self.model.supports_parallel_tool_calls(), self.max_output_tokens(), + self.model.reasoning_effort(), ); let completions = self.stream_completion(request, cx); async move { @@ -384,6 +387,7 @@ pub fn into_open_ai( model_id: &str, supports_parallel_tool_calls: bool, max_output_tokens: Option, + reasoning_effort: Option, ) -> open_ai::Request { let stream = !model_id.starts_with("o1-"); @@ -490,6 +494,7 @@ pub fn into_open_ai( LanguageModelToolChoice::Any => open_ai::ToolChoice::Required, LanguageModelToolChoice::None => open_ai::ToolChoice::None, }), + reasoning_effort, } } diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index 38bd7cee06..6e912765cd 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -355,7 +355,13 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { LanguageModelCompletionError, >, > { - let request = into_open_ai(request, &self.model.name, true, self.max_output_tokens()); + let request = into_open_ai( + request, + &self.model.name, + true, + self.max_output_tokens(), + None, + ); let completions = self.stream_completion(request, cx); async move { let mapper = OpenAiEventMapper::new(); diff --git a/crates/language_models/src/provider/vercel.rs b/crates/language_models/src/provider/vercel.rs index 037ce467d0..57a89ba4aa 100644 --- a/crates/language_models/src/provider/vercel.rs +++ b/crates/language_models/src/provider/vercel.rs @@ -356,6 +356,7 @@ impl LanguageModel for VercelLanguageModel { self.model.id(), self.model.supports_parallel_tool_calls(), self.max_output_tokens(), + None, ); let completions = self.stream_completion(request, cx); async move { diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs index 5f6034571b..5e7190ea96 100644 --- a/crates/language_models/src/provider/x_ai.rs +++ b/crates/language_models/src/provider/x_ai.rs @@ -360,6 +360,7 @@ impl LanguageModel for XAiLanguageModel { self.model.id(), self.model.supports_parallel_tool_calls(), self.max_output_tokens(), + None, ); let completions = self.stream_completion(request, cx); async move { diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index 919b1d9ebf..5801f29623 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -89,11 +89,13 @@ pub enum Model { max_tokens: u64, max_output_tokens: Option, max_completion_tokens: Option, + reasoning_effort: Option, }, } impl Model { pub fn default_fast() -> Self { + // TODO: Replace with FiveMini since all other models are deprecated Self::FourPointOneMini } @@ -206,6 +208,15 @@ impl Model { } } + pub fn reasoning_effort(&self) -> Option { + match self { + Self::Custom { + reasoning_effort, .. + } => reasoning_effort.to_owned(), + _ => None, + } + } + /// Returns whether the given model supports the `parallel_tool_calls` parameter. /// /// If the model does not support the parameter, do not pass it up, or the API will return an error. @@ -246,6 +257,7 @@ pub struct Request { pub tools: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub prompt_cache_key: Option, + pub reasoning_effort: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -257,6 +269,16 @@ pub enum ToolChoice { Other(ToolDefinition), } +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +#[serde(rename_all = "lowercase")] +pub enum ReasoningEffort { + Minimal, + Low, + Medium, + High, +} + #[derive(Clone, Deserialize, Serialize, Debug)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ToolDefinition { From db497ac867ce8c9a2bad0aef6261ac2acb2896fa Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 13 Aug 2025 11:01:02 +0200 Subject: [PATCH 109/109] Agent2 Model Selector (#36028) Release Notes: - N/A --------- Co-authored-by: Bennet Bo Fenner --- Cargo.lock | 3 +- crates/acp_thread/Cargo.toml | 3 +- crates/acp_thread/src/acp_thread.rs | 4 + crates/acp_thread/src/connection.rs | 151 ++++-- crates/agent2/src/agent.rs | 395 ++++++++++++--- crates/agent2/src/native_agent_server.rs | 17 +- crates/agent2/src/tests/mod.rs | 42 +- crates/agent_ui/src/acp.rs | 4 + crates/agent_ui/src/acp/model_selector.rs | 472 ++++++++++++++++++ .../src/acp/model_selector_popover.rs | 85 ++++ crates/agent_ui/src/acp/thread_view.rs | 41 +- crates/agent_ui/src/agent_panel.rs | 5 +- crates/agent_ui/src/agent_ui.rs | 4 +- 13 files changed, 1078 insertions(+), 148 deletions(-) create mode 100644 crates/agent_ui/src/acp/model_selector.rs create mode 100644 crates/agent_ui/src/acp/model_selector_popover.rs diff --git a/Cargo.lock b/Cargo.lock index ffcaf64859..d31189fa06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,6 +10,7 @@ dependencies = [ "agent-client-protocol", "anyhow", "buffer_diff", + "collections", "editor", "env_logger 0.11.8", "futures 0.3.31", @@ -17,7 +18,6 @@ dependencies = [ "indoc", "itertools 0.14.0", "language", - "language_model", "markdown", "parking_lot", "project", @@ -31,6 +31,7 @@ dependencies = [ "ui", "url", "util", + "watch", "workspace-hack", ] diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index 1fef342c01..fd01b31786 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -20,12 +20,12 @@ action_log.workspace = true agent-client-protocol.workspace = true anyhow.workspace = true buffer_diff.workspace = true +collections.workspace = true editor.workspace = true futures.workspace = true gpui.workspace = true itertools.workspace = true language.workspace = true -language_model.workspace = true markdown.workspace = true project.workspace = true serde.workspace = true @@ -36,6 +36,7 @@ terminal.workspace = true ui.workspace = true url.workspace = true util.workspace = true +watch.workspace = true workspace-hack.workspace = true [dev-dependencies] diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 80e0a31f97..d1957e1c2a 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -694,6 +694,10 @@ impl AcpThread { } } + pub fn connection(&self) -> &Rc { + &self.connection + } + pub fn action_log(&self) -> &Entity { &self.action_log } diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index cf06563bee..8e6294b3ce 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -1,61 +1,14 @@ -use std::{error::Error, fmt, path::Path, rc::Rc, sync::Arc}; +use std::{error::Error, fmt, path::Path, rc::Rc}; use agent_client_protocol::{self as acp}; use anyhow::Result; -use gpui::{AsyncApp, Entity, Task}; -use language_model::LanguageModel; +use collections::IndexMap; +use gpui::{AsyncApp, Entity, SharedString, Task}; use project::Project; -use ui::App; +use ui::{App, IconName}; use crate::AcpThread; -/// Trait for agents that support listing, selecting, and querying language models. -/// -/// This is an optional capability; agents indicate support via [AgentConnection::model_selector]. -pub trait ModelSelector: 'static { - /// Lists all available language models for this agent. - /// - /// # Parameters - /// - `cx`: The GPUI app context for async operations and global access. - /// - /// # Returns - /// A task resolving to the list of models or an error (e.g., if no models are configured). - fn list_models(&self, cx: &mut AsyncApp) -> Task>>>; - - /// Selects a model for a specific session (thread). - /// - /// This sets the default model for future interactions in the session. - /// If the session doesn't exist or the model is invalid, it returns an error. - /// - /// # Parameters - /// - `session_id`: The ID of the session (thread) to apply the model to. - /// - `model`: The model to select (should be one from [list_models]). - /// - `cx`: The GPUI app context. - /// - /// # Returns - /// A task resolving to `Ok(())` on success or an error. - fn select_model( - &self, - session_id: acp::SessionId, - model: Arc, - cx: &mut AsyncApp, - ) -> Task>; - - /// Retrieves the currently selected model for a specific session (thread). - /// - /// # Parameters - /// - `session_id`: The ID of the session (thread) to query. - /// - `cx`: The GPUI app context. - /// - /// # Returns - /// A task resolving to the selected model (always set) or an error (e.g., session not found). - fn selected_model( - &self, - session_id: &acp::SessionId, - cx: &mut AsyncApp, - ) -> Task>>; -} - pub trait AgentConnection { fn new_thread( self: Rc, @@ -77,8 +30,8 @@ pub trait AgentConnection { /// /// If the agent does not support model selection, returns [None]. /// This allows sharing the selector in UI components. - fn model_selector(&self) -> Option> { - None // Default impl for agents that don't support it + fn model_selector(&self) -> Option> { + None } } @@ -91,3 +44,95 @@ impl fmt::Display for AuthRequired { write!(f, "AuthRequired") } } + +/// Trait for agents that support listing, selecting, and querying language models. +/// +/// This is an optional capability; agents indicate support via [AgentConnection::model_selector]. +pub trait AgentModelSelector: 'static { + /// Lists all available language models for this agent. + /// + /// # Parameters + /// - `cx`: The GPUI app context for async operations and global access. + /// + /// # Returns + /// A task resolving to the list of models or an error (e.g., if no models are configured). + fn list_models(&self, cx: &mut App) -> Task>; + + /// Selects a model for a specific session (thread). + /// + /// This sets the default model for future interactions in the session. + /// If the session doesn't exist or the model is invalid, it returns an error. + /// + /// # Parameters + /// - `session_id`: The ID of the session (thread) to apply the model to. + /// - `model`: The model to select (should be one from [list_models]). + /// - `cx`: The GPUI app context. + /// + /// # Returns + /// A task resolving to `Ok(())` on success or an error. + fn select_model( + &self, + session_id: acp::SessionId, + model_id: AgentModelId, + cx: &mut App, + ) -> Task>; + + /// Retrieves the currently selected model for a specific session (thread). + /// + /// # Parameters + /// - `session_id`: The ID of the session (thread) to query. + /// - `cx`: The GPUI app context. + /// + /// # Returns + /// A task resolving to the selected model (always set) or an error (e.g., session not found). + fn selected_model( + &self, + session_id: &acp::SessionId, + cx: &mut App, + ) -> Task>; + + /// Whenever the model list is updated the receiver will be notified. + fn watch(&self, cx: &mut App) -> watch::Receiver<()>; +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct AgentModelId(pub SharedString); + +impl std::ops::Deref for AgentModelId { + type Target = SharedString; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl fmt::Display for AgentModelId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentModelInfo { + pub id: AgentModelId, + pub name: SharedString, + pub icon: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct AgentModelGroupName(pub SharedString); + +#[derive(Debug, Clone)] +pub enum AgentModelList { + Flat(Vec), + Grouped(IndexMap>), +} + +impl AgentModelList { + pub fn is_empty(&self) -> bool { + match self { + AgentModelList::Flat(models) => models.is_empty(), + AgentModelList::Grouped(groups) => groups.is_empty(), + } + } +} diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index 7439b2a088..3ddd7be793 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -4,18 +4,22 @@ use crate::{ FetchTool, FindPathTool, GrepTool, ListDirectoryTool, MessageContent, MovePathTool, NowTool, OpenTool, ReadFileTool, TerminalTool, ThinkingTool, ToolCallAuthorization, WebSearchTool, }; -use acp_thread::ModelSelector; +use acp_thread::AgentModelSelector; use agent_client_protocol as acp; +use agent_settings::AgentSettings; use anyhow::{Context as _, Result, anyhow}; +use collections::{HashSet, IndexMap}; +use fs::Fs; use futures::{StreamExt, future}; use gpui::{ App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity, }; -use language_model::{LanguageModel, LanguageModelRegistry}; +use language_model::{LanguageModel, LanguageModelProvider, LanguageModelRegistry}; use project::{Project, ProjectItem, ProjectPath, Worktree}; use prompt_store::{ ProjectContext, PromptId, PromptStore, RulesFileContext, UserRulesContext, WorktreeContext, }; +use settings::update_settings_file; use std::cell::RefCell; use std::collections::HashMap; use std::path::Path; @@ -48,6 +52,104 @@ struct Session { _subscription: Subscription, } +pub struct LanguageModels { + /// Access language model by ID + models: HashMap>, + /// Cached list for returning language model information + model_list: acp_thread::AgentModelList, + refresh_models_rx: watch::Receiver<()>, + refresh_models_tx: watch::Sender<()>, +} + +impl LanguageModels { + fn new(cx: &App) -> Self { + let (refresh_models_tx, refresh_models_rx) = watch::channel(()); + let mut this = Self { + models: HashMap::default(), + model_list: acp_thread::AgentModelList::Grouped(IndexMap::default()), + refresh_models_rx, + refresh_models_tx, + }; + this.refresh_list(cx); + this + } + + fn refresh_list(&mut self, cx: &App) { + let providers = LanguageModelRegistry::global(cx) + .read(cx) + .providers() + .into_iter() + .filter(|provider| provider.is_authenticated(cx)) + .collect::>(); + + let mut language_model_list = IndexMap::default(); + let mut recommended_models = HashSet::default(); + + let mut recommended = Vec::new(); + for provider in &providers { + for model in provider.recommended_models(cx) { + recommended_models.insert(model.id()); + recommended.push(Self::map_language_model_to_info(&model, &provider)); + } + } + if !recommended.is_empty() { + language_model_list.insert( + acp_thread::AgentModelGroupName("Recommended".into()), + recommended, + ); + } + + let mut models = HashMap::default(); + for provider in providers { + let mut provider_models = Vec::new(); + for model in provider.provided_models(cx) { + let model_info = Self::map_language_model_to_info(&model, &provider); + let model_id = model_info.id.clone(); + if !recommended_models.contains(&model.id()) { + provider_models.push(model_info); + } + models.insert(model_id, model); + } + if !provider_models.is_empty() { + language_model_list.insert( + acp_thread::AgentModelGroupName(provider.name().0.clone()), + provider_models, + ); + } + } + + self.models = models; + self.model_list = acp_thread::AgentModelList::Grouped(language_model_list); + self.refresh_models_tx.send(()).ok(); + } + + fn watch(&self) -> watch::Receiver<()> { + self.refresh_models_rx.clone() + } + + pub fn model_from_id( + &self, + model_id: &acp_thread::AgentModelId, + ) -> Option> { + self.models.get(model_id).cloned() + } + + fn map_language_model_to_info( + model: &Arc, + provider: &Arc, + ) -> acp_thread::AgentModelInfo { + acp_thread::AgentModelInfo { + id: Self::model_id(model), + name: model.name().0, + icon: Some(provider.icon()), + } + } + + fn model_id(model: &Arc) -> acp_thread::AgentModelId { + acp_thread::AgentModelId(format!("{}/{}", model.provider_id().0, model.id().0).into()) + } +} + pub struct NativeAgent { /// Session ID -> Session mapping sessions: HashMap, @@ -58,8 +160,11 @@ pub struct NativeAgent { context_server_registry: Entity, /// Shared templates for all threads templates: Arc, + /// Cached model information + models: LanguageModels, project: Entity, prompt_store: Option>, + fs: Arc, _subscriptions: Vec, } @@ -68,6 +173,7 @@ impl NativeAgent { project: Entity, templates: Arc, prompt_store: Option>, + fs: Arc, cx: &mut AsyncApp, ) -> Result> { log::info!("Creating new NativeAgent"); @@ -77,7 +183,13 @@ impl NativeAgent { .await; cx.new(|cx| { - let mut subscriptions = vec![cx.subscribe(&project, Self::handle_project_event)]; + let mut subscriptions = vec![ + cx.subscribe(&project, Self::handle_project_event), + cx.subscribe( + &LanguageModelRegistry::global(cx), + Self::handle_models_updated_event, + ), + ]; if let Some(prompt_store) = prompt_store.as_ref() { subscriptions.push(cx.subscribe(prompt_store, Self::handle_prompts_updated_event)) } @@ -95,13 +207,19 @@ impl NativeAgent { ContextServerRegistry::new(project.read(cx).context_server_store(), cx) }), templates, + models: LanguageModels::new(cx), project, prompt_store, + fs, _subscriptions: subscriptions, } }) } + pub fn models(&self) -> &LanguageModels { + &self.models + } + async fn maintain_project_context( this: WeakEntity, mut needs_refresh: watch::Receiver<()>, @@ -297,75 +415,104 @@ impl NativeAgent { ) { self.project_context_needs_refresh.send(()).ok(); } + + fn handle_models_updated_event( + &mut self, + _registry: Entity, + _event: &language_model::Event, + cx: &mut Context, + ) { + self.models.refresh_list(cx); + for session in self.sessions.values_mut() { + session.thread.update(cx, |thread, _| { + let model_id = LanguageModels::model_id(&thread.selected_model); + if let Some(model) = self.models.model_from_id(&model_id) { + thread.selected_model = model.clone(); + } + }); + } + } } /// Wrapper struct that implements the AgentConnection trait #[derive(Clone)] pub struct NativeAgentConnection(pub Entity); -impl ModelSelector for NativeAgentConnection { - fn list_models(&self, cx: &mut AsyncApp) -> Task>>> { +impl AgentModelSelector for NativeAgentConnection { + fn list_models(&self, cx: &mut App) -> Task> { log::debug!("NativeAgentConnection::list_models called"); - cx.spawn(async move |cx| { - cx.update(|cx| { - let registry = LanguageModelRegistry::read_global(cx); - let models = registry.available_models(cx).collect::>(); - log::info!("Found {} available models", models.len()); - if models.is_empty() { - Err(anyhow::anyhow!("No models available")) - } else { - Ok(models) - } - })? + let list = self.0.read(cx).models.model_list.clone(); + Task::ready(if list.is_empty() { + Err(anyhow::anyhow!("No models available")) + } else { + Ok(list) }) } fn select_model( &self, session_id: acp::SessionId, - model: Arc, - cx: &mut AsyncApp, + model_id: acp_thread::AgentModelId, + cx: &mut App, ) -> Task> { - log::info!( - "Setting model for session {}: {:?}", - session_id, - model.name() - ); - let agent = self.0.clone(); + log::info!("Setting model for session {}: {}", session_id, model_id); + let Some(thread) = self + .0 + .read(cx) + .sessions + .get(&session_id) + .map(|session| session.thread.clone()) + else { + return Task::ready(Err(anyhow!("Session not found"))); + }; - cx.spawn(async move |cx| { - agent.update(cx, |agent, cx| { - if let Some(session) = agent.sessions.get(&session_id) { - session.thread.update(cx, |thread, _cx| { - thread.selected_model = model; - }); - Ok(()) - } else { - Err(anyhow!("Session not found")) - } - })? - }) + let Some(model) = self.0.read(cx).models.model_from_id(&model_id) else { + return Task::ready(Err(anyhow!("Invalid model ID {}", model_id))); + }; + + thread.update(cx, |thread, _cx| { + thread.selected_model = model.clone(); + }); + + update_settings_file::( + self.0.read(cx).fs.clone(), + cx, + move |settings, _cx| { + settings.set_model(model); + }, + ); + + Task::ready(Ok(())) } fn selected_model( &self, session_id: &acp::SessionId, - cx: &mut AsyncApp, - ) -> Task>> { - let agent = self.0.clone(); + cx: &mut App, + ) -> Task> { let session_id = session_id.clone(); - cx.spawn(async move |cx| { - let thread = agent - .read_with(cx, |agent, _| { - agent - .sessions - .get(&session_id) - .map(|session| session.thread.clone()) - })? - .ok_or_else(|| anyhow::anyhow!("Session not found"))?; - let selected = thread.read_with(cx, |thread, _| thread.selected_model.clone())?; - Ok(selected) - }) + + let Some(thread) = self + .0 + .read(cx) + .sessions + .get(&session_id) + .map(|session| session.thread.clone()) + else { + return Task::ready(Err(anyhow!("Session not found"))); + }; + let model = thread.read(cx).selected_model.clone(); + let Some(provider) = LanguageModelRegistry::read_global(cx).provider(&model.provider_id()) + else { + return Task::ready(Err(anyhow!("Provider not found"))); + }; + Task::ready(Ok(LanguageModels::map_language_model_to_info( + &model, &provider, + ))) + } + + fn watch(&self, cx: &mut App) -> watch::Receiver<()> { + self.0.read(cx).models.watch() } } @@ -413,13 +560,10 @@ impl acp_thread::AgentConnection for NativeAgentConnection { let default_model = registry .default_model() - .map(|configured| { - log::info!( - "Using configured default model: {:?} from provider: {:?}", - configured.model.name(), - configured.provider.name() - ); - configured.model + .and_then(|default_model| { + agent + .models + .model_from_id(&LanguageModels::model_id(&default_model.model)) }) .ok_or_else(|| { log::warn!("No default model configured in settings"); @@ -487,8 +631,8 @@ impl acp_thread::AgentConnection for NativeAgentConnection { Task::ready(Ok(())) } - fn model_selector(&self) -> Option> { - Some(Rc::new(self.clone()) as Rc) + fn model_selector(&self) -> Option> { + Some(Rc::new(self.clone()) as Rc) } fn prompt( @@ -629,6 +773,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection { #[cfg(test)] mod tests { use super::*; + use acp_thread::{AgentConnection, AgentModelGroupName, AgentModelId, AgentModelInfo}; use fs::FakeFs; use gpui::TestAppContext; use serde_json::json; @@ -646,9 +791,15 @@ mod tests { ) .await; let project = Project::test(fs.clone(), [], cx).await; - let agent = NativeAgent::new(project.clone(), Templates::new(), None, &mut cx.to_async()) - .await - .unwrap(); + let agent = NativeAgent::new( + project.clone(), + Templates::new(), + None, + fs.clone(), + &mut cx.to_async(), + ) + .await + .unwrap(); agent.read_with(cx, |agent, _| { assert_eq!(agent.project_context.borrow().worktrees, vec![]) }); @@ -689,13 +840,131 @@ mod tests { }); } + #[gpui::test] + async fn test_listing_models(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/", json!({ "a": {} })).await; + let project = Project::test(fs.clone(), [], cx).await; + let connection = NativeAgentConnection( + NativeAgent::new( + project.clone(), + Templates::new(), + None, + fs.clone(), + &mut cx.to_async(), + ) + .await + .unwrap(), + ); + + let models = cx.update(|cx| connection.list_models(cx)).await.unwrap(); + + let acp_thread::AgentModelList::Grouped(models) = models else { + panic!("Unexpected model group"); + }; + assert_eq!( + models, + IndexMap::from_iter([( + AgentModelGroupName("Fake".into()), + vec![AgentModelInfo { + id: AgentModelId("fake/fake".into()), + name: "Fake".into(), + icon: Some(ui::IconName::ZedAssistant), + }] + )]) + ); + } + + #[gpui::test] + async fn test_model_selection_persists_to_settings(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.create_dir(paths::settings_file().parent().unwrap()) + .await + .unwrap(); + fs.insert_file( + paths::settings_file(), + json!({ + "agent": { + "default_model": { + "provider": "foo", + "model": "bar" + } + } + }) + .to_string() + .into_bytes(), + ) + .await; + let project = Project::test(fs.clone(), [], cx).await; + + // Create the agent and connection + let agent = NativeAgent::new( + project.clone(), + Templates::new(), + None, + fs.clone(), + &mut cx.to_async(), + ) + .await + .unwrap(); + let connection = NativeAgentConnection(agent.clone()); + + // Create a thread/session + let acp_thread = cx + .update(|cx| { + Rc::new(connection.clone()).new_thread( + project.clone(), + Path::new("/a"), + &mut cx.to_async(), + ) + }) + .await + .unwrap(); + + let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone()); + + // Select a model + let model_id = AgentModelId("fake/fake".into()); + cx.update(|cx| connection.select_model(session_id.clone(), model_id.clone(), cx)) + .await + .unwrap(); + + // Verify the thread has the selected model + agent.read_with(cx, |agent, _| { + let session = agent.sessions.get(&session_id).unwrap(); + session.thread.read_with(cx, |thread, _| { + assert_eq!(thread.selected_model.id().0, "fake"); + }); + }); + + cx.run_until_parked(); + + // Verify settings file was updated + let settings_content = fs.load(paths::settings_file()).await.unwrap(); + let settings_json: serde_json::Value = serde_json::from_str(&settings_content).unwrap(); + + // Check that the agent settings contain the selected model + assert_eq!( + settings_json["agent"]["default_model"]["model"], + json!("fake") + ); + assert_eq!( + settings_json["agent"]["default_model"]["provider"], + json!("fake") + ); + } + fn init_test(cx: &mut TestAppContext) { env_logger::try_init().ok(); cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); Project::init_settings(cx); + agent_settings::init(cx); language::init(cx); + LanguageModelRegistry::test(cx); }); } } diff --git a/crates/agent2/src/native_agent_server.rs b/crates/agent2/src/native_agent_server.rs index 58f6d37c54..cadd88a846 100644 --- a/crates/agent2/src/native_agent_server.rs +++ b/crates/agent2/src/native_agent_server.rs @@ -1,8 +1,8 @@ -use std::path::Path; -use std::rc::Rc; +use std::{path::Path, rc::Rc, sync::Arc}; use agent_servers::AgentServer; use anyhow::Result; +use fs::Fs; use gpui::{App, Entity, Task}; use project::Project; use prompt_store::PromptStore; @@ -10,7 +10,15 @@ use prompt_store::PromptStore; use crate::{NativeAgent, NativeAgentConnection, templates::Templates}; #[derive(Clone)] -pub struct NativeAgentServer; +pub struct NativeAgentServer { + fs: Arc, +} + +impl NativeAgentServer { + pub fn new(fs: Arc) -> Self { + Self { fs } + } +} impl AgentServer for NativeAgentServer { fn name(&self) -> &'static str { @@ -41,6 +49,7 @@ impl AgentServer for NativeAgentServer { _root_dir ); let project = project.clone(); + let fs = self.fs.clone(); let prompt_store = PromptStore::global(cx); cx.spawn(async move |cx| { log::debug!("Creating templates for native agent"); @@ -48,7 +57,7 @@ impl AgentServer for NativeAgentServer { let prompt_store = prompt_store.await?; log::debug!("Creating native agent entity"); - let agent = NativeAgent::new(project, templates, Some(prompt_store), cx).await?; + let agent = NativeAgent::new(project, templates, Some(prompt_store), fs, cx).await?; // Create the connection wrapper let connection = NativeAgentConnection(agent); diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index 88cf92836b..b70fa56747 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -1,6 +1,6 @@ use super::*; use crate::MessageContent; -use acp_thread::AgentConnection; +use acp_thread::{AgentConnection, AgentModelGroupName, AgentModelList}; use action_log::ActionLog; use agent_client_protocol::{self as acp}; use agent_settings::AgentProfileId; @@ -686,13 +686,19 @@ async fn test_agent_connection(cx: &mut TestAppContext) { // Create a project for new_thread let fake_fs = cx.update(|cx| fs::FakeFs::new(cx.background_executor().clone())); fake_fs.insert_tree(path!("/test"), json!({})).await; - let project = Project::test(fake_fs, [Path::new("/test")], cx).await; + let project = Project::test(fake_fs.clone(), [Path::new("/test")], cx).await; let cwd = Path::new("/test"); // Create agent and connection - let agent = NativeAgent::new(project.clone(), templates.clone(), None, &mut cx.to_async()) - .await - .unwrap(); + let agent = NativeAgent::new( + project.clone(), + templates.clone(), + None, + fake_fs.clone(), + &mut cx.to_async(), + ) + .await + .unwrap(); let connection = NativeAgentConnection(agent.clone()); // Test model_selector returns Some @@ -705,22 +711,22 @@ async fn test_agent_connection(cx: &mut TestAppContext) { // Test list_models let listed_models = cx - .update(|cx| { - let mut async_cx = cx.to_async(); - selector.list_models(&mut async_cx) - }) + .update(|cx| selector.list_models(cx)) .await .expect("list_models should succeed"); + let AgentModelList::Grouped(listed_models) = listed_models else { + panic!("Unexpected model list type"); + }; assert!(!listed_models.is_empty(), "should have at least one model"); - assert_eq!(listed_models[0].id().0, "fake"); + assert_eq!( + listed_models[&AgentModelGroupName("Fake".into())][0].id.0, + "fake/fake" + ); // Create a thread using new_thread let connection_rc = Rc::new(connection.clone()); let acp_thread = cx - .update(|cx| { - let mut async_cx = cx.to_async(); - connection_rc.new_thread(project, cwd, &mut async_cx) - }) + .update(|cx| connection_rc.new_thread(project, cwd, &mut cx.to_async())) .await .expect("new_thread should succeed"); @@ -729,12 +735,12 @@ async fn test_agent_connection(cx: &mut TestAppContext) { // Test selected_model returns the default let model = cx - .update(|cx| { - let mut async_cx = cx.to_async(); - selector.selected_model(&session_id, &mut async_cx) - }) + .update(|cx| selector.selected_model(&session_id, cx)) .await .expect("selected_model should succeed"); + let model = cx + .update(|cx| agent.read(cx).models().model_from_id(&model.id)) + .unwrap(); let model = model.as_fake(); assert_eq!(model.id().0, "fake", "should return default model"); diff --git a/crates/agent_ui/src/acp.rs b/crates/agent_ui/src/acp.rs index cc476b1a86..b9814adb2d 100644 --- a/crates/agent_ui/src/acp.rs +++ b/crates/agent_ui/src/acp.rs @@ -1,6 +1,10 @@ mod completion_provider; mod message_history; +mod model_selector; +mod model_selector_popover; mod thread_view; pub use message_history::MessageHistory; +pub use model_selector::AcpModelSelector; +pub use model_selector_popover::AcpModelSelectorPopover; pub use thread_view::AcpThreadView; diff --git a/crates/agent_ui/src/acp/model_selector.rs b/crates/agent_ui/src/acp/model_selector.rs new file mode 100644 index 0000000000..563afee65f --- /dev/null +++ b/crates/agent_ui/src/acp/model_selector.rs @@ -0,0 +1,472 @@ +use std::{cmp::Reverse, rc::Rc, sync::Arc}; + +use acp_thread::{AgentModelInfo, AgentModelList, AgentModelSelector}; +use agent_client_protocol as acp; +use anyhow::Result; +use collections::IndexMap; +use futures::FutureExt; +use fuzzy::{StringMatchCandidate, match_strings}; +use gpui::{Action, AsyncWindowContext, BackgroundExecutor, DismissEvent, Task, WeakEntity}; +use ordered_float::OrderedFloat; +use picker::{Picker, PickerDelegate}; +use ui::{ + AnyElement, App, Context, IntoElement, ListItem, ListItemSpacing, SharedString, Window, + prelude::*, rems, +}; +use util::ResultExt; + +pub type AcpModelSelector = Picker; + +pub fn acp_model_selector( + session_id: acp::SessionId, + selector: Rc, + window: &mut Window, + cx: &mut Context, +) -> AcpModelSelector { + let delegate = AcpModelPickerDelegate::new(session_id, selector, window, cx); + Picker::list(delegate, window, cx) + .show_scrollbar(true) + .width(rems(20.)) + .max_height(Some(rems(20.).into())) +} + +enum AcpModelPickerEntry { + Separator(SharedString), + Model(AgentModelInfo), +} + +pub struct AcpModelPickerDelegate { + session_id: acp::SessionId, + selector: Rc, + filtered_entries: Vec, + models: Option, + selected_index: usize, + selected_model: Option, + _refresh_models_task: Task<()>, +} + +impl AcpModelPickerDelegate { + fn new( + session_id: acp::SessionId, + selector: Rc, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let mut rx = selector.watch(cx); + let refresh_models_task = cx.spawn_in(window, { + let session_id = session_id.clone(); + async move |this, cx| { + async fn refresh( + this: &WeakEntity>, + session_id: &acp::SessionId, + cx: &mut AsyncWindowContext, + ) -> Result<()> { + let (models_task, selected_model_task) = this.update(cx, |this, cx| { + ( + this.delegate.selector.list_models(cx), + this.delegate.selector.selected_model(session_id, cx), + ) + })?; + + let (models, selected_model) = futures::join!(models_task, selected_model_task); + + this.update_in(cx, |this, window, cx| { + this.delegate.models = models.ok(); + this.delegate.selected_model = selected_model.ok(); + this.delegate.update_matches(this.query(cx), window, cx) + })? + .await; + + Ok(()) + } + + refresh(&this, &session_id, cx).await.log_err(); + while let Ok(()) = rx.recv().await { + refresh(&this, &session_id, cx).await.log_err(); + } + } + }); + + Self { + session_id, + selector, + filtered_entries: Vec::new(), + models: None, + selected_model: None, + selected_index: 0, + _refresh_models_task: refresh_models_task, + } + } + + pub fn active_model(&self) -> Option<&AgentModelInfo> { + self.selected_model.as_ref() + } +} + +impl PickerDelegate for AcpModelPickerDelegate { + type ListItem = AnyElement; + + fn match_count(&self) -> usize { + self.filtered_entries.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context>) { + self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1)); + cx.notify(); + } + + fn can_select( + &mut self, + ix: usize, + _window: &mut Window, + _cx: &mut Context>, + ) -> bool { + match self.filtered_entries.get(ix) { + Some(AcpModelPickerEntry::Model(_)) => true, + Some(AcpModelPickerEntry::Separator(_)) | None => false, + } + } + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { + "Select a model…".into() + } + + fn update_matches( + &mut self, + query: String, + window: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + cx.spawn_in(window, async move |this, cx| { + let filtered_models = match this + .read_with(cx, |this, cx| { + this.delegate.models.clone().map(move |models| { + fuzzy_search(models, query, cx.background_executor().clone()) + }) + }) + .ok() + .flatten() + { + Some(task) => task.await, + None => AgentModelList::Flat(vec![]), + }; + + this.update_in(cx, |this, window, cx| { + this.delegate.filtered_entries = + info_list_to_picker_entries(filtered_models).collect(); + // Finds the currently selected model in the list + let new_index = this + .delegate + .selected_model + .as_ref() + .and_then(|selected| { + this.delegate.filtered_entries.iter().position(|entry| { + if let AcpModelPickerEntry::Model(model_info) = entry { + model_info.id == selected.id + } else { + false + } + }) + }) + .unwrap_or(0); + this.set_selected_index(new_index, Some(picker::Direction::Down), true, window, cx); + cx.notify(); + }) + .ok(); + }) + } + + fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { + if let Some(AcpModelPickerEntry::Model(model_info)) = + self.filtered_entries.get(self.selected_index) + { + self.selector + .select_model(self.session_id.clone(), model_info.id.clone(), cx) + .detach_and_log_err(cx); + self.selected_model = Some(model_info.clone()); + let current_index = self.selected_index; + self.set_selected_index(current_index, window, cx); + + cx.emit(DismissEvent); + } + } + + fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { + cx.emit(DismissEvent); + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _: &mut Window, + cx: &mut Context>, + ) -> Option { + match self.filtered_entries.get(ix)? { + AcpModelPickerEntry::Separator(title) => Some( + div() + .px_2() + .pb_1() + .when(ix > 1, |this| { + this.mt_1() + .pt_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + }) + .child( + Label::new(title) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .into_any_element(), + ), + AcpModelPickerEntry::Model(model_info) => { + let is_selected = Some(model_info) == self.selected_model.as_ref(); + + let model_icon_color = if is_selected { + Color::Accent + } else { + Color::Muted + }; + + Some( + ListItem::new(ix) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) + .start_slot::(model_info.icon.map(|icon| { + Icon::new(icon) + .color(model_icon_color) + .size(IconSize::Small) + })) + .child( + h_flex() + .w_full() + .pl_0p5() + .gap_1p5() + .w(px(240.)) + .child(Label::new(model_info.name.clone()).truncate()), + ) + .end_slot(div().pr_3().when(is_selected, |this| { + this.child( + Icon::new(IconName::Check) + .color(Color::Accent) + .size(IconSize::Small), + ) + })) + .into_any_element(), + ) + } + } + } + + fn render_footer( + &self, + _: &mut Window, + cx: &mut Context>, + ) -> Option { + Some( + h_flex() + .w_full() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .p_1() + .gap_4() + .justify_between() + .child( + Button::new("configure", "Configure") + .icon(IconName::Settings) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .on_click(|_, window, cx| { + window.dispatch_action( + zed_actions::agent::OpenSettings.boxed_clone(), + cx, + ); + }), + ) + .into_any(), + ) + } +} + +fn info_list_to_picker_entries( + model_list: AgentModelList, +) -> impl Iterator { + match model_list { + AgentModelList::Flat(list) => { + itertools::Either::Left(list.into_iter().map(AcpModelPickerEntry::Model)) + } + AgentModelList::Grouped(index_map) => { + itertools::Either::Right(index_map.into_iter().flat_map(|(group_name, models)| { + std::iter::once(AcpModelPickerEntry::Separator(group_name.0)) + .chain(models.into_iter().map(AcpModelPickerEntry::Model)) + })) + } + } +} + +async fn fuzzy_search( + model_list: AgentModelList, + query: String, + executor: BackgroundExecutor, +) -> AgentModelList { + async fn fuzzy_search_list( + model_list: Vec, + query: &str, + executor: BackgroundExecutor, + ) -> Vec { + let candidates = model_list + .iter() + .enumerate() + .map(|(ix, model)| { + StringMatchCandidate::new(ix, &format!("{}/{}", model.id, model.name)) + }) + .collect::>(); + let mut matches = match_strings( + &candidates, + &query, + false, + true, + 100, + &Default::default(), + executor, + ) + .await; + + matches.sort_unstable_by_key(|mat| { + let candidate = &candidates[mat.candidate_id]; + (Reverse(OrderedFloat(mat.score)), candidate.id) + }); + + matches + .into_iter() + .map(|mat| model_list[mat.candidate_id].clone()) + .collect() + } + + match model_list { + AgentModelList::Flat(model_list) => { + AgentModelList::Flat(fuzzy_search_list(model_list, &query, executor).await) + } + AgentModelList::Grouped(index_map) => { + let groups = + futures::future::join_all(index_map.into_iter().map(|(group_name, models)| { + fuzzy_search_list(models, &query, executor.clone()) + .map(|results| (group_name, results)) + })) + .await; + AgentModelList::Grouped(IndexMap::from_iter( + groups + .into_iter() + .filter(|(_, results)| !results.is_empty()), + )) + } + } +} + +#[cfg(test)] +mod tests { + use gpui::TestAppContext; + + use super::*; + + fn create_model_list(grouped_models: Vec<(&str, Vec<&str>)>) -> AgentModelList { + AgentModelList::Grouped(IndexMap::from_iter(grouped_models.into_iter().map( + |(group, models)| { + ( + acp_thread::AgentModelGroupName(group.to_string().into()), + models + .into_iter() + .map(|model| acp_thread::AgentModelInfo { + id: acp_thread::AgentModelId(model.to_string().into()), + name: model.to_string().into(), + icon: None, + }) + .collect::>(), + ) + }, + ))) + } + + fn assert_models_eq(result: AgentModelList, expected: Vec<(&str, Vec<&str>)>) { + let AgentModelList::Grouped(groups) = result else { + panic!("Expected LanguageModelInfoList::Grouped, got {:?}", result); + }; + + assert_eq!( + groups.len(), + expected.len(), + "Number of groups doesn't match" + ); + + for (i, (expected_group, expected_models)) in expected.iter().enumerate() { + let (actual_group, actual_models) = groups.get_index(i).unwrap(); + assert_eq!( + actual_group.0.as_ref(), + *expected_group, + "Group at position {} doesn't match expected group", + i + ); + assert_eq!( + actual_models.len(), + expected_models.len(), + "Number of models in group {} doesn't match", + expected_group + ); + + for (j, expected_model_name) in expected_models.iter().enumerate() { + assert_eq!( + actual_models[j].name, *expected_model_name, + "Model at position {} in group {} doesn't match expected model", + j, expected_group + ); + } + } + } + + #[gpui::test] + async fn test_fuzzy_match(cx: &mut TestAppContext) { + let models = create_model_list(vec![ + ( + "zed", + vec![ + "Claude 3.7 Sonnet", + "Claude 3.7 Sonnet Thinking", + "gpt-4.1", + "gpt-4.1-nano", + ], + ), + ("openai", vec!["gpt-3.5-turbo", "gpt-4.1", "gpt-4.1-nano"]), + ("ollama", vec!["mistral", "deepseek"]), + ]); + + // Results should preserve models order whenever possible. + // In the case below, `zed/gpt-4.1` and `openai/gpt-4.1` have identical + // similarity scores, but `zed/gpt-4.1` was higher in the models list, + // so it should appear first in the results. + let results = fuzzy_search(models.clone(), "41".into(), cx.executor()).await; + assert_models_eq( + results, + vec![ + ("zed", vec!["gpt-4.1", "gpt-4.1-nano"]), + ("openai", vec!["gpt-4.1", "gpt-4.1-nano"]), + ], + ); + + // Fuzzy search + let results = fuzzy_search(models.clone(), "4n".into(), cx.executor()).await; + assert_models_eq( + results, + vec![ + ("zed", vec!["gpt-4.1-nano"]), + ("openai", vec!["gpt-4.1-nano"]), + ], + ); + } +} diff --git a/crates/agent_ui/src/acp/model_selector_popover.rs b/crates/agent_ui/src/acp/model_selector_popover.rs new file mode 100644 index 0000000000..e52101113a --- /dev/null +++ b/crates/agent_ui/src/acp/model_selector_popover.rs @@ -0,0 +1,85 @@ +use std::rc::Rc; + +use acp_thread::AgentModelSelector; +use agent_client_protocol as acp; +use gpui::{Entity, FocusHandle}; +use picker::popover_menu::PickerPopoverMenu; +use ui::{ + ButtonLike, Context, IntoElement, PopoverMenuHandle, SharedString, Tooltip, Window, prelude::*, +}; +use zed_actions::agent::ToggleModelSelector; + +use crate::acp::{AcpModelSelector, model_selector::acp_model_selector}; + +pub struct AcpModelSelectorPopover { + selector: Entity, + menu_handle: PopoverMenuHandle, + focus_handle: FocusHandle, +} + +impl AcpModelSelectorPopover { + pub(crate) fn new( + session_id: acp::SessionId, + selector: Rc, + menu_handle: PopoverMenuHandle, + focus_handle: FocusHandle, + window: &mut Window, + cx: &mut Context, + ) -> Self { + Self { + selector: cx.new(move |cx| acp_model_selector(session_id, selector, window, cx)), + menu_handle, + focus_handle, + } + } + + pub fn toggle(&self, window: &mut Window, cx: &mut Context) { + self.menu_handle.toggle(window, cx); + } +} + +impl Render for AcpModelSelectorPopover { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let model = self.selector.read(cx).delegate.active_model(); + let model_name = model + .as_ref() + .map(|model| model.name.clone()) + .unwrap_or_else(|| SharedString::from("Select a Model")); + + let model_icon = model.as_ref().and_then(|model| model.icon); + + let focus_handle = self.focus_handle.clone(); + + PickerPopoverMenu::new( + self.selector.clone(), + ButtonLike::new("active-model") + .when_some(model_icon, |this, icon| { + this.child(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall)) + }) + .child( + Label::new(model_name) + .color(Color::Muted) + .size(LabelSize::Small) + .ml_0p5(), + ) + .child( + Icon::new(IconName::ChevronDown) + .color(Color::Muted) + .size(IconSize::XSmall), + ), + move |window, cx| { + Tooltip::for_action_in( + "Change Model", + &ToggleModelSelector, + &focus_handle, + window, + cx, + ) + }, + gpui::Corner::BottomRight, + cx, + ) + .with_handle(self.menu_handle.clone()) + .render(window, cx) + } +} diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index da7915222e..12fc29b08f 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -38,12 +38,14 @@ use terminal_view::TerminalView; use text::{Anchor, BufferSnapshot}; use theme::ThemeSettings; use ui::{ - Disclosure, Divider, DividerColor, KeyBinding, Scrollbar, ScrollbarState, Tooltip, prelude::*, + Disclosure, Divider, DividerColor, KeyBinding, PopoverMenuHandle, Scrollbar, ScrollbarState, + Tooltip, prelude::*, }; use util::{ResultExt, size::format_file_size, time::duration_alt_display}; use workspace::{CollaboratorId, Workspace}; -use zed_actions::agent::{Chat, NextHistoryMessage, PreviousHistoryMessage}; +use zed_actions::agent::{Chat, NextHistoryMessage, PreviousHistoryMessage, ToggleModelSelector}; +use crate::acp::AcpModelSelectorPopover; use crate::acp::completion_provider::{ContextPickerCompletionProvider, MentionSet}; use crate::acp::message_history::MessageHistory; use crate::agent_diff::AgentDiff; @@ -63,6 +65,7 @@ pub struct AcpThreadView { diff_editors: HashMap>, terminal_views: HashMap>, message_editor: Entity, + model_selector: Option>, message_set_from_history: Option, _message_editor_subscription: Subscription, mention_set: Arc>, @@ -187,6 +190,7 @@ impl AcpThreadView { project: project.clone(), thread_state: Self::initial_state(agent, workspace, project, window, cx), message_editor, + model_selector: None, message_set_from_history: None, _message_editor_subscription: message_editor_subscription, mention_set, @@ -270,7 +274,7 @@ impl AcpThreadView { Err(e) } } - Ok(session_id) => Ok(session_id), + Ok(thread) => Ok(thread), }; this.update_in(cx, |this, window, cx| { @@ -288,6 +292,24 @@ impl AcpThreadView { AgentDiff::set_active_thread(&workspace, thread.clone(), window, cx); + this.model_selector = + thread + .read(cx) + .connection() + .model_selector() + .map(|selector| { + cx.new(|cx| { + AcpModelSelectorPopover::new( + thread.read(cx).session_id().clone(), + selector, + PopoverMenuHandle::default(), + this.focus_handle(cx), + window, + cx, + ) + }) + }); + this.thread_state = ThreadState::Ready { thread, _subscription: [thread_subscription, action_log_subscription], @@ -2472,6 +2494,12 @@ impl AcpThreadView { v_flex() .on_action(cx.listener(Self::expand_message_editor)) + .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| { + if let Some(model_selector) = this.model_selector.as_ref() { + model_selector + .update(cx, |model_selector, cx| model_selector.toggle(window, cx)); + } + })) .p_2() .gap_2() .border_t_1() @@ -2548,7 +2576,12 @@ impl AcpThreadView { .flex_none() .justify_between() .child(self.render_follow_toggle(cx)) - .child(self.render_send_button(cx)), + .child( + h_flex() + .gap_1() + .children(self.model_selector.clone()) + .child(self.render_send_button(cx)), + ), ) .into_any() } diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 87e4dd822c..d07581da93 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -916,6 +916,7 @@ impl AgentPanel { let workspace = self.workspace.clone(); let project = self.project.clone(); let message_history = self.acp_message_history.clone(); + let fs = self.fs.clone(); const LAST_USED_EXTERNAL_AGENT_KEY: &str = "agent_panel__last_used_external_agent"; @@ -939,7 +940,7 @@ impl AgentPanel { }) .detach(); - agent.server() + agent.server(fs) } None => cx .background_spawn(async move { @@ -953,7 +954,7 @@ impl AgentPanel { }) .unwrap_or_default() .agent - .server(), + .server(fs), }; this.update_in(cx, |this, window, cx| { diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index fceb8f4c45..b776c0830b 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -155,11 +155,11 @@ enum ExternalAgent { } impl ExternalAgent { - pub fn server(&self) -> Rc { + pub fn server(&self, fs: Arc) -> Rc { match self { ExternalAgent::Gemini => Rc::new(agent_servers::Gemini), ExternalAgent::ClaudeCode => Rc::new(agent_servers::ClaudeCode), - ExternalAgent::NativeAgent => Rc::new(agent2::NativeAgentServer), + ExternalAgent::NativeAgent => Rc::new(agent2::NativeAgentServer::new(fs)), } } }