From c241eadbc3fd0d4036db266210b344203a3886bf Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Fri, 7 Nov 2025 22:06:12 -0300 Subject: [PATCH 01/19] zeta2: Targeted retrieval search (#42240) Since we removed the filtering step during context gathering, we want the model to perform more targeted searches. This PR tweaks search tool schema allowing the model to search within syntax nodes such as `impl` blocks or methods. This is what the query schema looks like now: ```rust /// Search for relevant code by path, syntax hierarchy, and content. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct SearchToolQuery { /// 1. A glob pattern to match file paths in the codebase to search in. pub glob: String, /// 2. Regular expressions to match syntax nodes **by their first line** and hierarchy. /// /// Subsequent regexes match nodes within the full content of the nodes matched by the previous regexes. /// /// Example: Searching for a `User` class /// ["class\s+User"] /// /// Example: Searching for a `get_full_name` method under a `User` class /// ["class\s+User", "def\sget_full_name"] /// /// Skip this field to match on content alone. #[schemars(length(max = 3))] #[serde(default)] pub syntax_node: Vec, /// 3. An optional regular expression to match the final content that should appear in the results. /// /// - Content will be matched within all lines of the matched syntax nodes. /// - If syntax node regexes are provided, this field can be skipped to include as much of the node itself as possible. /// - If no syntax node regexes are provided, the content will be matched within the entire file. pub content: Option, } ``` We'll need to keep refining this, but the core implementation is ready. Release Notes: - N/A --------- Co-authored-by: Ben Co-authored-by: Max Co-authored-by: Max Brunsfeld --- Cargo.lock | 5 +- crates/agent/src/agent.rs | 1 - crates/agent/src/outline.rs | 9 +- crates/agent/src/thread.rs | 4 +- .../src/tools/context_server_registry.rs | 2 +- crates/cloud_zeta2_prompt/Cargo.toml | 1 - .../src/retrieval_prompt.rs | 64 +-- crates/language/src/buffer.rs | 12 + crates/language_model/Cargo.toml | 3 +- crates/language_model/src/language_model.rs | 11 +- .../src/tool_schema.rs | 12 +- crates/project/src/project.rs | 2 +- crates/zeta2/Cargo.toml | 1 + crates/zeta2/src/retrieval_search.rs | 483 +++++++++++++++--- crates/zeta2/src/udiff.rs | 28 +- crates/zeta2/src/zeta2.rs | 53 +- crates/zeta2_tools/Cargo.toml | 2 +- crates/zeta2_tools/src/zeta2_context_view.rs | 45 +- crates/zeta_cli/src/predict.rs | 191 +++---- 19 files changed, 657 insertions(+), 272 deletions(-) rename crates/{agent => language_model}/src/tool_schema.rs (95%) diff --git a/Cargo.lock b/Cargo.lock index 08ead914af69281a21c763b874bc57e8c84ac90d..faae1259d9d5c08559ec6ba02463367e84b3aa4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3202,7 +3202,6 @@ dependencies = [ "rustc-hash 2.1.1", "schemars 1.0.4", "serde", - "serde_json", "strum 0.27.2", ] @@ -8867,6 +8866,7 @@ dependencies = [ "open_router", "parking_lot", "proto", + "schemars 1.0.4", "serde", "serde_json", "settings", @@ -21685,6 +21685,7 @@ dependencies = [ "serde", "serde_json", "settings", + "smol", "thiserror 2.0.17", "util", "uuid", @@ -21702,6 +21703,7 @@ dependencies = [ "clap", "client", "cloud_llm_client", + "cloud_zeta2_prompt", "collections", "edit_prediction_context", "editor", @@ -21715,7 +21717,6 @@ dependencies = [ "ordered-float 2.10.1", "pretty_assertions", "project", - "regex-syntax", "serde", "serde_json", "settings", diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 0e9372373a65ac5fee9870cf58e2b0d9c11427d2..fc0b66f4073ea137f53b29286b0c17b53d11bf83 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -6,7 +6,6 @@ mod native_agent_server; pub mod outline; mod templates; mod thread; -mod tool_schema; mod tools; #[cfg(test)] diff --git a/crates/agent/src/outline.rs b/crates/agent/src/outline.rs index bc78290fb52ae208742b9dea0e6dbbe560022419..262fa8d3d139a5c8f5900d0dd55348f9dc716167 100644 --- a/crates/agent/src/outline.rs +++ b/crates/agent/src/outline.rs @@ -1,6 +1,6 @@ use anyhow::Result; use gpui::{AsyncApp, Entity}; -use language::{Buffer, OutlineItem, ParseStatus}; +use language::{Buffer, OutlineItem}; use regex::Regex; use std::fmt::Write; use text::Point; @@ -30,10 +30,9 @@ pub async fn get_buffer_content_or_outline( if file_size > AUTO_OUTLINE_SIZE { // For large files, use outline instead of full content // Wait until the buffer has been fully parsed, so we can read its outline - let mut parse_status = buffer.read_with(cx, |buffer, _| buffer.parse_status())?; - while *parse_status.borrow() != ParseStatus::Idle { - parse_status.changed().await?; - } + buffer + .read_with(cx, |buffer, _| buffer.parsing_idle())? + .await; let outline_items = buffer.read_with(cx, |buffer, _| { let snapshot = buffer.snapshot(); diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 4c0fb00163744e66b5644a0fe76b1aa853fb8237..78f20152b4daf461de40cfa7746216092f82cf41 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -2139,7 +2139,7 @@ where /// Returns the JSON schema that describes the tool's input. fn input_schema(format: LanguageModelToolSchemaFormat) -> Schema { - crate::tool_schema::root_schema_for::(format) + language_model::tool_schema::root_schema_for::(format) } /// Some tools rely on a provider for the underlying billing or other reasons. @@ -2226,7 +2226,7 @@ where fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { let mut json = serde_json::to_value(T::input_schema(format))?; - crate::tool_schema::adapt_schema_to_format(&mut json, format)?; + language_model::tool_schema::adapt_schema_to_format(&mut json, format)?; Ok(json) } diff --git a/crates/agent/src/tools/context_server_registry.rs b/crates/agent/src/tools/context_server_registry.rs index 382d2ba9be74b4518de853037c858fd054366d5d..03a0ef84e73d4cbca83d61077d568ec58cd7ae2b 100644 --- a/crates/agent/src/tools/context_server_registry.rs +++ b/crates/agent/src/tools/context_server_registry.rs @@ -165,7 +165,7 @@ impl AnyAgentTool for ContextServerTool { format: language_model::LanguageModelToolSchemaFormat, ) -> Result { let mut schema = self.tool.input_schema.clone(); - crate::tool_schema::adapt_schema_to_format(&mut schema, format)?; + language_model::tool_schema::adapt_schema_to_format(&mut schema, format)?; Ok(match schema { serde_json::Value::Null => { serde_json::json!({ "type": "object", "properties": [] }) diff --git a/crates/cloud_zeta2_prompt/Cargo.toml b/crates/cloud_zeta2_prompt/Cargo.toml index fa8246950f8d03029388e0276954de946efc2346..8be10265cb23e7dd0983c52e7c2d6984b62c4be4 100644 --- a/crates/cloud_zeta2_prompt/Cargo.toml +++ b/crates/cloud_zeta2_prompt/Cargo.toml @@ -19,5 +19,4 @@ ordered-float.workspace = true rustc-hash.workspace = true schemars.workspace = true serde.workspace = true -serde_json.workspace = true strum.workspace = true diff --git a/crates/cloud_zeta2_prompt/src/retrieval_prompt.rs b/crates/cloud_zeta2_prompt/src/retrieval_prompt.rs index 54ef1999729f6976bd77d280508f8c370d54488e..7fbc3834dfd0f4bbfc4085d696b7fbf755e6dd3d 100644 --- a/crates/cloud_zeta2_prompt/src/retrieval_prompt.rs +++ b/crates/cloud_zeta2_prompt/src/retrieval_prompt.rs @@ -3,7 +3,7 @@ use cloud_llm_client::predict_edits_v3::{self, Excerpt}; use indoc::indoc; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use std::{fmt::Write, sync::LazyLock}; +use std::fmt::Write; use crate::{push_events, write_codeblock}; @@ -15,7 +15,7 @@ pub fn build_prompt(request: predict_edits_v3::PlanContextRetrievalRequest) -> R push_events(&mut prompt, &request.events); } - writeln!(&mut prompt, "## Excerpt around the cursor\n")?; + writeln!(&mut prompt, "## Cursor context")?; write_codeblock( &request.excerpt_path, &[Excerpt { @@ -39,54 +39,56 @@ pub fn build_prompt(request: predict_edits_v3::PlanContextRetrievalRequest) -> R #[derive(Clone, Deserialize, Serialize, JsonSchema)] pub struct SearchToolInput { /// An array of queries to run for gathering context relevant to the next prediction - #[schemars(length(max = 5))] + #[schemars(length(max = 3))] pub queries: Box<[SearchToolQuery]>, } +/// Search for relevant code by path, syntax hierarchy, and content. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct SearchToolQuery { - /// A glob pattern to match file paths in the codebase + /// 1. A glob pattern to match file paths in the codebase to search in. pub glob: String, - /// A regular expression to match content within the files matched by the glob pattern - pub regex: String, + /// 2. Regular expressions to match syntax nodes **by their first line** and hierarchy. + /// + /// Subsequent regexes match nodes within the full content of the nodes matched by the previous regexes. + /// + /// Example: Searching for a `User` class + /// ["class\s+User"] + /// + /// Example: Searching for a `get_full_name` method under a `User` class + /// ["class\s+User", "def\sget_full_name"] + /// + /// Skip this field to match on content alone. + #[schemars(length(max = 3))] + #[serde(default)] + pub syntax_node: Vec, + /// 3. An optional regular expression to match the final content that should appear in the results. + /// + /// - Content will be matched within all lines of the matched syntax nodes. + /// - If syntax node regexes are provided, this field can be skipped to include as much of the node itself as possible. + /// - If no syntax node regexes are provided, the content will be matched within the entire file. + pub content: Option, } -pub static TOOL_SCHEMA: LazyLock<(serde_json::Value, String)> = LazyLock::new(|| { - let schema = schemars::schema_for!(SearchToolInput); - - let description = schema - .get("description") - .and_then(|description| description.as_str()) - .unwrap() - .to_string(); - - (schema.into(), description) -}); - pub const TOOL_NAME: &str = "search"; const SEARCH_INSTRUCTIONS: &str = indoc! {r#" - ## Task + You are part of an edit prediction system in a code editor. + Your role is to search for code that will serve as context for predicting the next edit. - You are part of an edit prediction system in a code editor. Your role is to identify relevant code locations - that will serve as context for predicting the next required edit. - - **Your task:** - Analyze the user's recent edits and current cursor context - - Use the `search` tool to find code that may be relevant for predicting the next edit + - Use the `search` tool to find code that is relevant for predicting the next edit - Focus on finding: - Code patterns that might need similar changes based on the recent edits - Functions, variables, types, and constants referenced in the current cursor context - Related implementations, usages, or dependencies that may require consistent updates - - **Important constraints:** - - This conversation has exactly 2 turns - - You must make ALL search queries in your first response via the `search` tool - - All queries will be executed in parallel and results returned together - - In the second turn, you will select the most relevant results via the `select` tool. + - How items defined in the cursor excerpt are used or altered + - You will not be able to filter results or perform subsequent queries, so keep searches as targeted as possible + - Use `syntax_node` parameter whenever you're looking for a particular type, class, or function + - Avoid using wildcard globs if you already know the file path of the content you're looking for "#}; const TOOL_USE_REMINDER: &str = indoc! {" -- - Use the `search` tool now + Analyze the user's intent in one to two sentences, then call the `search` tool. "}; diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 4dd90c15d9387327a75ece2d82385e406e5840d6..ea2405d04c32cba45963bc32747ee0b94292ffd9 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -1618,6 +1618,18 @@ impl Buffer { self.parse_status.1.clone() } + /// Wait until the buffer is no longer parsing + pub fn parsing_idle(&self) -> impl Future + use<> { + let mut parse_status = self.parse_status(); + async move { + while *parse_status.borrow() != ParseStatus::Idle { + if parse_status.changed().await.is_err() { + break; + } + } + } + } + /// Assign to the buffer a set of diagnostics created by a given language server. pub fn update_diagnostics( &mut self, diff --git a/crates/language_model/Cargo.toml b/crates/language_model/Cargo.toml index f572561f6a78b3cf2d9bfc2f7272895836f11614..4d40a063b604b405f7bcb29a3457956e1dd5541d 100644 --- a/crates/language_model/Cargo.toml +++ b/crates/language_model/Cargo.toml @@ -17,7 +17,6 @@ test-support = [] [dependencies] anthropic = { workspace = true, features = ["schemars"] } -open_router.workspace = true anyhow.workspace = true base64.workspace = true client.workspace = true @@ -30,8 +29,10 @@ http_client.workspace = true icons.workspace = true image.workspace = true log.workspace = true +open_router.workspace = true parking_lot.workspace = true proto.workspace = true +schemars.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index 24f9b84afcfa7b9a40b4a1b7684e9a9b036a5a85..94f6ec33f15062dd53b4122ca9d9dcac3fbff83d 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -4,6 +4,7 @@ mod registry; mod request; mod role; mod telemetry; +pub mod tool_schema; #[cfg(any(test, feature = "test-support"))] pub mod fake_provider; @@ -35,6 +36,7 @@ pub use crate::registry::*; pub use crate::request::*; pub use crate::role::*; pub use crate::telemetry::*; +pub use crate::tool_schema::LanguageModelToolSchemaFormat; pub const ANTHROPIC_PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("anthropic"); @@ -409,15 +411,6 @@ impl From for LanguageModelCompletionError { } } -/// Indicates the format used to define the input schema for a language model tool. -#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)] -pub enum LanguageModelToolSchemaFormat { - /// A JSON schema, see https://json-schema.org - JsonSchema, - /// A subset of an OpenAPI 3.0 schema object supported by Google AI, see https://ai.google.dev/api/caching#Schema - JsonSchemaSubset, -} - #[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum StopReason { diff --git a/crates/agent/src/tool_schema.rs b/crates/language_model/src/tool_schema.rs similarity index 95% rename from crates/agent/src/tool_schema.rs rename to crates/language_model/src/tool_schema.rs index 4b0de3e5c63fb0c5ccafbb89a22dad8a33072b35..f9402c28dc316f9ccdacc58afaa0eebd6699f92d 100644 --- a/crates/agent/src/tool_schema.rs +++ b/crates/language_model/src/tool_schema.rs @@ -1,5 +1,4 @@ use anyhow::Result; -use language_model::LanguageModelToolSchemaFormat; use schemars::{ JsonSchema, Schema, generate::SchemaSettings, @@ -7,7 +6,16 @@ use schemars::{ }; use serde_json::Value; -pub(crate) fn root_schema_for(format: LanguageModelToolSchemaFormat) -> Schema { +/// Indicates the format used to define the input schema for a language model tool. +#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)] +pub enum LanguageModelToolSchemaFormat { + /// A JSON schema, see https://json-schema.org + JsonSchema, + /// A subset of an OpenAPI 3.0 schema object supported by Google AI, see https://ai.google.dev/api/caching#Schema + JsonSchemaSubset, +} + +pub fn root_schema_for(format: LanguageModelToolSchemaFormat) -> Schema { let mut generator = match format { LanguageModelToolSchemaFormat::JsonSchema => SchemaSettings::draft07().into_generator(), LanguageModelToolSchemaFormat::JsonSchemaSubset => SchemaSettings::openapi3() diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 9cc95da3b4c86daffd32af89d4f26509c97269fa..13ed42847d522c371226988d8ca133a1748d5fec 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -4060,7 +4060,7 @@ impl Project { result_rx } - fn find_search_candidate_buffers( + pub fn find_search_candidate_buffers( &mut self, query: &SearchQuery, limit: usize, diff --git a/crates/zeta2/Cargo.toml b/crates/zeta2/Cargo.toml index cbde212dd104bdc909dda19de403f815ff4f6386..3f394cd5ef2ab5d5bce05430a717312c9e3c0f5c 100644 --- a/crates/zeta2/Cargo.toml +++ b/crates/zeta2/Cargo.toml @@ -33,6 +33,7 @@ project.workspace = true release_channel.workspace = true serde.workspace = true serde_json.workspace = true +smol.workspace = true thiserror.workspace = true util.workspace = true uuid.workspace = true diff --git a/crates/zeta2/src/retrieval_search.rs b/crates/zeta2/src/retrieval_search.rs index e2e78c3e3b295549ca2c294818f935f1d7d8a9f9..f735f44cad9623711e5ed9a1293a74e34e084888 100644 --- a/crates/zeta2/src/retrieval_search.rs +++ b/crates/zeta2/src/retrieval_search.rs @@ -1,18 +1,19 @@ use std::ops::Range; use anyhow::Result; +use cloud_zeta2_prompt::retrieval_prompt::SearchToolQuery; use collections::HashMap; -use edit_prediction_context::{EditPredictionExcerpt, EditPredictionExcerptOptions}; use futures::{ StreamExt, channel::mpsc::{self, UnboundedSender}, }; use gpui::{AppContext, AsyncApp, Entity}; -use language::{Anchor, Buffer, BufferSnapshot, OffsetRangeExt, ToPoint as _}; +use language::{Anchor, Buffer, BufferSnapshot, OffsetRangeExt, Point, ToOffset, ToPoint}; use project::{ Project, WorktreeSettings, search::{SearchQuery, SearchResult}, }; +use smol::channel; use util::{ ResultExt as _, paths::{PathMatcher, PathStyle}, @@ -21,7 +22,7 @@ use workspace::item::Settings as _; pub async fn run_retrieval_searches( project: Entity, - regex_by_glob: HashMap, + queries: Vec, cx: &mut AsyncApp, ) -> Result, Vec>>> { let (exclude_matcher, path_style) = project.update(cx, |project, cx| { @@ -37,14 +38,13 @@ pub async fn run_retrieval_searches( let (results_tx, mut results_rx) = mpsc::unbounded(); - for (glob, regex) in regex_by_glob { + for query in queries { let exclude_matcher = exclude_matcher.clone(); let results_tx = results_tx.clone(); let project = project.clone(); cx.spawn(async move |cx| { run_query( - &glob, - ®ex, + query, results_tx.clone(), path_style, exclude_matcher, @@ -108,87 +108,442 @@ pub async fn run_retrieval_searches( .await } -const MIN_EXCERPT_LEN: usize = 16; const MAX_EXCERPT_LEN: usize = 768; const MAX_RESULTS_LEN: usize = MAX_EXCERPT_LEN * 5; +struct SearchJob { + buffer: Entity, + snapshot: BufferSnapshot, + ranges: Vec>, + query_ix: usize, + jobs_tx: channel::Sender, +} + async fn run_query( - glob: &str, - regex: &str, + input_query: SearchToolQuery, results_tx: UnboundedSender<(Entity, BufferSnapshot, Vec<(Range, usize)>)>, path_style: PathStyle, exclude_matcher: PathMatcher, project: &Entity, cx: &mut AsyncApp, ) -> Result<()> { - let include_matcher = PathMatcher::new(vec![glob], path_style)?; - - let query = SearchQuery::regex( - regex, - false, - true, - false, - true, - include_matcher, - exclude_matcher, - true, - None, - )?; - - let results = project.update(cx, |project, cx| project.search(query, cx))?; - futures::pin_mut!(results); - - while let Some(SearchResult::Buffer { buffer, ranges }) = results.next().await { - if results_tx.is_closed() { - break; - } + let include_matcher = PathMatcher::new(vec![input_query.glob], path_style)?; - if ranges.is_empty() { - continue; - } + let make_search = |regex: &str| -> Result { + SearchQuery::regex( + regex, + false, + true, + false, + true, + include_matcher.clone(), + exclude_matcher.clone(), + true, + None, + ) + }; - let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; - let results_tx = results_tx.clone(); + if let Some(outer_syntax_regex) = input_query.syntax_node.first() { + let outer_syntax_query = make_search(outer_syntax_regex)?; + let nested_syntax_queries = input_query + .syntax_node + .into_iter() + .skip(1) + .map(|query| make_search(&query)) + .collect::>>()?; + let content_query = input_query + .content + .map(|regex| make_search(®ex)) + .transpose()?; - cx.background_spawn(async move { - let mut excerpts = Vec::with_capacity(ranges.len()); - - for range in ranges { - let offset_range = range.to_offset(&snapshot); - let query_point = (offset_range.start + offset_range.len() / 2).to_point(&snapshot); - - let excerpt = EditPredictionExcerpt::select_from_buffer( - query_point, - &snapshot, - &EditPredictionExcerptOptions { - max_bytes: MAX_EXCERPT_LEN, - min_bytes: MIN_EXCERPT_LEN, - target_before_cursor_over_total_bytes: 0.5, - }, - None, - ); + let (jobs_tx, jobs_rx) = channel::unbounded(); - if let Some(excerpt) = excerpt - && !excerpt.line_range.is_empty() - { - excerpts.push(( - snapshot.anchor_after(excerpt.range.start) - ..snapshot.anchor_before(excerpt.range.end), - excerpt.range.len(), - )); - } + let outer_search_results_rx = + project.update(cx, |project, cx| project.search(outer_syntax_query, cx))?; + + let outer_search_task = cx.spawn(async move |cx| { + futures::pin_mut!(outer_search_results_rx); + while let Some(SearchResult::Buffer { buffer, ranges }) = + outer_search_results_rx.next().await + { + buffer + .read_with(cx, |buffer, _| buffer.parsing_idle())? + .await; + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; + let expanded_ranges: Vec<_> = ranges + .into_iter() + .filter_map(|range| expand_to_parent_range(&range, &snapshot)) + .collect(); + jobs_tx + .send(SearchJob { + buffer, + snapshot, + ranges: expanded_ranges, + query_ix: 0, + jobs_tx: jobs_tx.clone(), + }) + .await?; } + anyhow::Ok(()) + }); - let send_result = results_tx.unbounded_send((buffer, snapshot, excerpts)); + let n_workers = cx.background_executor().num_cpus(); + let search_job_task = cx.background_executor().scoped(|scope| { + for _ in 0..n_workers { + scope.spawn(async { + while let Ok(job) = jobs_rx.recv().await { + process_nested_search_job( + &results_tx, + &nested_syntax_queries, + &content_query, + job, + ) + .await; + } + }); + } + }); + + search_job_task.await; + outer_search_task.await?; + } else if let Some(content_regex) = &input_query.content { + let search_query = make_search(&content_regex)?; + + let results_rx = project.update(cx, |project, cx| project.search(search_query, cx))?; + futures::pin_mut!(results_rx); + + while let Some(SearchResult::Buffer { buffer, ranges }) = results_rx.next().await { + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; + + let ranges = ranges + .into_iter() + .map(|range| { + let range = range.to_offset(&snapshot); + let range = expand_to_entire_lines(range, &snapshot); + let size = range.len(); + let range = + snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end); + (range, size) + }) + .collect(); + + let send_result = results_tx.unbounded_send((buffer.clone(), snapshot.clone(), ranges)); if let Err(err) = send_result && !err.is_disconnected() { log::error!("{err}"); } - }) - .detach(); + } + } else { + log::warn!("Context gathering model produced a glob-only search"); } anyhow::Ok(()) } + +async fn process_nested_search_job( + results_tx: &UnboundedSender<(Entity, BufferSnapshot, Vec<(Range, usize)>)>, + queries: &Vec, + content_query: &Option, + job: SearchJob, +) { + if let Some(search_query) = queries.get(job.query_ix) { + let mut subranges = Vec::new(); + for range in job.ranges { + let start = range.start; + let search_results = search_query.search(&job.snapshot, Some(range)).await; + for subrange in search_results { + let subrange = start + subrange.start..start + subrange.end; + subranges.extend(expand_to_parent_range(&subrange, &job.snapshot)); + } + } + job.jobs_tx + .send(SearchJob { + buffer: job.buffer, + snapshot: job.snapshot, + ranges: subranges, + query_ix: job.query_ix + 1, + jobs_tx: job.jobs_tx.clone(), + }) + .await + .ok(); + } else { + let ranges = if let Some(content_query) = content_query { + let mut subranges = Vec::new(); + for range in job.ranges { + let start = range.start; + let search_results = content_query.search(&job.snapshot, Some(range)).await; + for subrange in search_results { + let subrange = start + subrange.start..start + subrange.end; + subranges.push(subrange); + } + } + subranges + } else { + job.ranges + }; + + let matches = ranges + .into_iter() + .map(|range| { + let snapshot = &job.snapshot; + let range = expand_to_entire_lines(range, snapshot); + let size = range.len(); + let range = snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end); + (range, size) + }) + .collect(); + + let send_result = results_tx.unbounded_send((job.buffer, job.snapshot, matches)); + + if let Err(err) = send_result + && !err.is_disconnected() + { + log::error!("{err}"); + } + } +} + +fn expand_to_entire_lines(range: Range, snapshot: &BufferSnapshot) -> Range { + let mut point_range = range.to_point(snapshot); + point_range.start.column = 0; + if point_range.end.column > 0 { + point_range.end = snapshot.max_point().min(point_range.end + Point::new(1, 0)); + } + point_range.to_offset(snapshot) +} + +fn expand_to_parent_range( + range: &Range, + snapshot: &BufferSnapshot, +) -> Option> { + let mut line_range = range.to_point(&snapshot); + line_range.start.column = snapshot.indent_size_for_line(line_range.start.row).len; + line_range.end.column = snapshot.line_len(line_range.end.row); + // TODO skip result if matched line isn't the first node line? + + let node = snapshot.syntax_ancestor(line_range)?; + Some(node.byte_range()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::merge_excerpts::merge_excerpts; + use cloud_zeta2_prompt::write_codeblock; + use edit_prediction_context::Line; + use gpui::TestAppContext; + use indoc::indoc; + use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_rust}; + use pretty_assertions::assert_eq; + use project::FakeFs; + use serde_json::json; + use settings::SettingsStore; + use std::path::Path; + use util::path; + + #[gpui::test] + async fn test_retrieval(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "user.rs": indoc!{" + pub struct Organization { + owner: Arc, + } + + pub struct User { + first_name: String, + last_name: String, + } + + impl Organization { + pub fn owner(&self) -> Arc { + self.owner.clone() + } + } + + impl User { + pub fn new(first_name: String, last_name: String) -> Self { + Self { + first_name, + last_name + } + } + + pub fn first_name(&self) -> String { + self.first_name.clone() + } + + pub fn last_name(&self) -> String { + self.last_name.clone() + } + } + "}, + "main.rs": indoc!{r#" + fn main() { + let user = User::new(FIRST_NAME.clone(), "doe".into()); + println!("user {:?}", user); + } + "#}, + }), + ) + .await; + + let project = Project::test(fs, vec![Path::new(path!("/root"))], cx).await; + project.update(cx, |project, _cx| { + project.languages().add(rust_lang().into()) + }); + + assert_results( + &project, + SearchToolQuery { + glob: "user.rs".into(), + syntax_node: vec!["impl\\s+User".into(), "pub\\s+fn\\s+first_name".into()], + content: None, + }, + indoc! {r#" + `````root/user.rs + … + impl User { + … + pub fn first_name(&self) -> String { + self.first_name.clone() + } + … + ````` + "#}, + cx, + ) + .await; + + assert_results( + &project, + SearchToolQuery { + glob: "user.rs".into(), + syntax_node: vec!["impl\\s+User".into()], + content: Some("\\.clone".into()), + }, + indoc! {r#" + `````root/user.rs + … + impl User { + … + pub fn first_name(&self) -> String { + self.first_name.clone() + … + pub fn last_name(&self) -> String { + self.last_name.clone() + … + ````` + "#}, + cx, + ) + .await; + + assert_results( + &project, + SearchToolQuery { + glob: "*.rs".into(), + syntax_node: vec![], + content: Some("\\.clone".into()), + }, + indoc! {r#" + `````root/main.rs + fn main() { + let user = User::new(FIRST_NAME.clone(), "doe".into()); + … + ````` + + `````root/user.rs + … + impl Organization { + pub fn owner(&self) -> Arc { + self.owner.clone() + … + impl User { + … + pub fn first_name(&self) -> String { + self.first_name.clone() + … + pub fn last_name(&self) -> String { + self.last_name.clone() + … + ````` + "#}, + cx, + ) + .await; + } + + async fn assert_results( + project: &Entity, + query: SearchToolQuery, + expected_output: &str, + cx: &mut TestAppContext, + ) { + let results = run_retrieval_searches(project.clone(), vec![query], &mut cx.to_async()) + .await + .unwrap(); + + let mut results = results.into_iter().collect::>(); + results.sort_by_key(|results| { + results + .0 + .read_with(cx, |buffer, _| buffer.file().unwrap().path().clone()) + }); + + let mut output = String::new(); + for (buffer, ranges) in results { + buffer.read_with(cx, |buffer, cx| { + let excerpts = ranges.into_iter().map(|range| { + let point_range = range.to_point(buffer); + if point_range.end.column > 0 { + Line(point_range.start.row)..Line(point_range.end.row + 1) + } else { + Line(point_range.start.row)..Line(point_range.end.row) + } + }); + + write_codeblock( + &buffer.file().unwrap().full_path(cx), + merge_excerpts(&buffer.snapshot(), excerpts).iter(), + &[], + Line(buffer.max_point().row), + false, + &mut output, + ); + }); + } + output.pop(); + + assert_eq!(output, expected_output); + } + + 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() + } + + fn init_test(cx: &mut TestAppContext) { + cx.update(move |cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + zlog::init_test(); + }); + } +} diff --git a/crates/zeta2/src/udiff.rs b/crates/zeta2/src/udiff.rs index b30eb22741a1e701e2e744445f2a01c1f0ed0d03..d765a64345f839b9314632444d209fa79e9ca5ce 100644 --- a/crates/zeta2/src/udiff.rs +++ b/crates/zeta2/src/udiff.rs @@ -18,10 +18,10 @@ use language::{Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, TextBufferSn use project::Project; pub async fn parse_diff<'a>( - diff: &'a str, + diff_str: &'a str, get_buffer: impl Fn(&Path) -> Option<(&'a BufferSnapshot, &'a [Range])> + Send, ) -> Result<(&'a BufferSnapshot, Vec<(Range, Arc)>)> { - let mut diff = DiffParser::new(diff); + let mut diff = DiffParser::new(diff_str); let mut edited_buffer = None; let mut edits = Vec::new(); @@ -41,7 +41,10 @@ pub async fn parse_diff<'a>( Some(ref current) => current, }; - edits.extend(resolve_hunk_edits_in_buffer(hunk, &buffer.text, ranges)?); + edits.extend( + resolve_hunk_edits_in_buffer(hunk, &buffer.text, ranges) + .with_context(|| format!("Diff:\n{diff_str}"))?, + ); } DiffEvent::FileEnd { renamed_to } => { let (buffer, _) = edited_buffer @@ -69,13 +72,13 @@ pub struct OpenedBuffers<'a>(#[allow(unused)] HashMap, Entity( - diff: &'a str, + diff_str: &'a str, project: &Entity, cx: &mut AsyncApp, ) -> Result> { let mut included_files = HashMap::default(); - for line in diff.lines() { + for line in diff_str.lines() { let diff_line = DiffLine::parse(line); if let DiffLine::OldPath { path } = diff_line { @@ -97,7 +100,7 @@ pub async fn apply_diff<'a>( let ranges = [Anchor::MIN..Anchor::MAX]; - let mut diff = DiffParser::new(diff); + let mut diff = DiffParser::new(diff_str); let mut current_file = None; let mut edits = vec![]; @@ -120,7 +123,10 @@ pub async fn apply_diff<'a>( }; buffer.read_with(cx, |buffer, _| { - edits.extend(resolve_hunk_edits_in_buffer(hunk, buffer, ranges)?); + edits.extend( + resolve_hunk_edits_in_buffer(hunk, buffer, ranges) + .with_context(|| format!("Diff:\n{diff_str}"))?, + ); anyhow::Ok(()) })??; } @@ -328,13 +334,7 @@ fn resolve_hunk_edits_in_buffer( offset = Some(range.start + ix); } } - offset.ok_or_else(|| { - anyhow!( - "Failed to match context:\n{}\n\nBuffer:\n{}", - hunk.context, - buffer.text(), - ) - }) + offset.ok_or_else(|| anyhow!("Failed to match context:\n{}", hunk.context)) }?; let iter = hunk.edits.into_iter().flat_map(move |edit| { let old_text = buffer diff --git a/crates/zeta2/src/zeta2.rs b/crates/zeta2/src/zeta2.rs index 503964c88f18562dbf10197bfc330ffe49add8d5..ff0ff4f1ba2af59f32cddee96e4b9c0dd25af22d 100644 --- a/crates/zeta2/src/zeta2.rs +++ b/crates/zeta2/src/zeta2.rs @@ -7,7 +7,7 @@ use cloud_llm_client::{ ZED_VERSION_HEADER_NAME, }; use cloud_zeta2_prompt::DEFAULT_MAX_PROMPT_BYTES; -use cloud_zeta2_prompt::retrieval_prompt::SearchToolInput; +use cloud_zeta2_prompt::retrieval_prompt::{SearchToolInput, SearchToolQuery}; use collections::HashMap; use edit_prediction_context::{ DeclarationId, DeclarationStyle, EditPredictionContext, EditPredictionContextOptions, @@ -35,7 +35,7 @@ use uuid::Uuid; use std::ops::Range; use std::path::Path; use std::str::FromStr as _; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant}; use thiserror::Error; use util::rel_path::RelPathBuf; @@ -88,6 +88,9 @@ pub const DEFAULT_OPTIONS: ZetaOptions = ZetaOptions { buffer_change_grouping_interval: Duration::from_secs(1), }; +static MODEL_ID: LazyLock = + LazyLock::new(|| std::env::var("ZED_ZETA2_MODEL").unwrap_or("yqvev8r3".to_string())); + pub struct Zeta2FeatureFlag; impl FeatureFlag for Zeta2FeatureFlag { @@ -180,7 +183,7 @@ pub struct ZetaEditPredictionDebugInfo { pub struct ZetaSearchQueryDebugInfo { pub project: Entity, pub timestamp: Instant, - pub regex_by_glob: HashMap, + pub search_queries: Vec, } pub type RequestDebugInfo = predict_edits_v3::DebugInfo; @@ -883,7 +886,7 @@ impl Zeta { let (prompt, _) = prompt_result?; let request = open_ai::Request { - model: std::env::var("ZED_ZETA2_MODEL").unwrap_or("yqvev8r3".to_string()), + model: MODEL_ID.clone(), messages: vec![open_ai::RequestMessage::User { content: open_ai::MessageContent::Plain(prompt), }], @@ -1226,10 +1229,24 @@ impl Zeta { .ok(); } - let (tool_schema, tool_description) = &*cloud_zeta2_prompt::retrieval_prompt::TOOL_SCHEMA; + pub static TOOL_SCHEMA: LazyLock<(serde_json::Value, String)> = LazyLock::new(|| { + let schema = language_model::tool_schema::root_schema_for::( + language_model::LanguageModelToolSchemaFormat::JsonSchemaSubset, + ); + + let description = schema + .get("description") + .and_then(|description| description.as_str()) + .unwrap() + .to_string(); + + (schema.into(), description) + }); + + let (tool_schema, tool_description) = TOOL_SCHEMA.clone(); let request = open_ai::Request { - model: std::env::var("ZED_ZETA2_MODEL").unwrap_or("2327jz9q".to_string()), + model: MODEL_ID.clone(), messages: vec![open_ai::RequestMessage::User { content: open_ai::MessageContent::Plain(prompt), }], @@ -1242,8 +1259,8 @@ impl Zeta { tools: vec![open_ai::ToolDefinition::Function { function: FunctionDefinition { name: cloud_zeta2_prompt::retrieval_prompt::TOOL_NAME.to_string(), - description: Some(tool_description.clone()), - parameters: Some(tool_schema.clone()), + description: Some(tool_description), + parameters: Some(tool_schema), }, }], prompt_cache_key: None, @@ -1255,7 +1272,6 @@ impl Zeta { let response = Self::send_raw_llm_request(client, llm_token, app_version, request).await; let mut response = Self::handle_api_response(&this, response, cx)?; - log::trace!("Got search planning response"); let choice = response @@ -1270,7 +1286,7 @@ impl Zeta { anyhow::bail!("Retrieval response didn't include an assistant message"); }; - let mut regex_by_glob: HashMap = HashMap::default(); + let mut queries: Vec = Vec::new(); for tool_call in tool_calls { let open_ai::ToolCallContent::Function { function } = tool_call.content; if function.name != cloud_zeta2_prompt::retrieval_prompt::TOOL_NAME { @@ -1283,13 +1299,7 @@ impl Zeta { } let input: SearchToolInput = serde_json::from_str(&function.arguments)?; - for query in input.queries { - let regex = regex_by_glob.entry(query.glob).or_default(); - if !regex.is_empty() { - regex.push('|'); - } - regex.push_str(&query.regex); - } + queries.extend(input.queries); } if let Some(debug_tx) = &debug_tx { @@ -1298,16 +1308,16 @@ impl Zeta { ZetaSearchQueryDebugInfo { project: project.clone(), timestamp: Instant::now(), - regex_by_glob: regex_by_glob.clone(), + search_queries: queries.clone(), }, )) .ok(); } - log::trace!("Running retrieval search: {regex_by_glob:#?}"); + log::trace!("Running retrieval search: {queries:#?}"); let related_excerpts_result = - retrieval_search::run_retrieval_searches(project.clone(), regex_by_glob, cx).await; + retrieval_search::run_retrieval_searches(project.clone(), queries, cx).await; log::trace!("Search queries executed"); @@ -1754,7 +1764,8 @@ mod tests { arguments: serde_json::to_string(&SearchToolInput { queries: Box::new([SearchToolQuery { glob: "root/2.txt".to_string(), - regex: ".".to_string(), + syntax_node: vec![], + content: Some(".".into()), }]), }) .unwrap(), diff --git a/crates/zeta2_tools/Cargo.toml b/crates/zeta2_tools/Cargo.toml index 89d0ce8338624906d2262a7d8314700f6720cff1..3a9b1ccbf9340dfdaa06030e59c2112b9cda6307 100644 --- a/crates/zeta2_tools/Cargo.toml +++ b/crates/zeta2_tools/Cargo.toml @@ -16,6 +16,7 @@ anyhow.workspace = true chrono.workspace = true client.workspace = true cloud_llm_client.workspace = true +cloud_zeta2_prompt.workspace = true collections.workspace = true edit_prediction_context.workspace = true editor.workspace = true @@ -27,7 +28,6 @@ log.workspace = true multi_buffer.workspace = true ordered-float.workspace = true project.workspace = true -regex-syntax = "0.8.8" serde.workspace = true serde_json.workspace = true telemetry.workspace = true diff --git a/crates/zeta2_tools/src/zeta2_context_view.rs b/crates/zeta2_tools/src/zeta2_context_view.rs index 685029cc4a2993227725c17e283c660da5c1d5e1..1826bd22df6d08ce717ef9bdf0070f88ad63c433 100644 --- a/crates/zeta2_tools/src/zeta2_context_view.rs +++ b/crates/zeta2_tools/src/zeta2_context_view.rs @@ -8,6 +8,7 @@ use std::{ use anyhow::Result; use client::{Client, UserStore}; +use cloud_zeta2_prompt::retrieval_prompt::SearchToolQuery; use editor::{Editor, PathKey}; use futures::StreamExt as _; use gpui::{ @@ -41,19 +42,13 @@ pub struct Zeta2ContextView { #[derive(Debug)] struct RetrievalRun { editor: Entity, - search_queries: Vec, + search_queries: Vec, started_at: Instant, search_results_generated_at: Option, search_results_executed_at: Option, finished_at: Option, } -#[derive(Debug)] -struct GlobQueries { - glob: String, - alternations: Vec, -} - actions!( dev, [ @@ -210,23 +205,7 @@ impl Zeta2ContextView { }; run.search_results_generated_at = Some(info.timestamp); - run.search_queries = info - .regex_by_glob - .into_iter() - .map(|(glob, regex)| { - let mut regex_parser = regex_syntax::ast::parse::Parser::new(); - - GlobQueries { - glob, - alternations: match regex_parser.parse(®ex) { - Ok(regex_syntax::ast::Ast::Alternation(ref alt)) => { - alt.asts.iter().map(|ast| ast.to_string()).collect() - } - _ => vec![regex], - }, - } - }) - .collect(); + run.search_queries = info.search_queries; cx.notify(); } @@ -292,18 +271,28 @@ impl Zeta2ContextView { .enumerate() .flat_map(|(ix, query)| { std::iter::once(ListHeader::new(query.glob.clone()).into_any_element()) - .chain(query.alternations.iter().enumerate().map( - move |(alt_ix, alt)| { - ListItem::new(ix * 100 + alt_ix) + .chain(query.syntax_node.iter().enumerate().map( + move |(regex_ix, regex)| { + ListItem::new(ix * 100 + regex_ix) .start_slot( Icon::new(IconName::MagnifyingGlass) .color(Color::Muted) .size(IconSize::Small), ) - .child(alt.clone()) + .child(regex.clone()) .into_any_element() }, )) + .chain(query.content.as_ref().map(move |regex| { + ListItem::new(ix * 100 + query.syntax_node.len()) + .start_slot( + Icon::new(IconName::MagnifyingGlass) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child(regex.clone()) + .into_any_element() + })) }), ), ) diff --git a/crates/zeta_cli/src/predict.rs b/crates/zeta_cli/src/predict.rs index 1bc2411c825a2fa7147ff0c657804908b687d9ff..a593a1b12ceb2b72a316463076657f35ac2c4e9d 100644 --- a/crates/zeta_cli/src/predict.rs +++ b/crates/zeta_cli/src/predict.rs @@ -2,11 +2,11 @@ use crate::example::{ActualExcerpt, NamedExample}; use crate::headless::ZetaCliAppState; use crate::paths::LOGS_DIR; use ::serde::Serialize; -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Result, anyhow}; use clap::Args; use cloud_zeta2_prompt::{CURSOR_MARKER, write_codeblock}; use futures::StreamExt as _; -use gpui::AsyncApp; +use gpui::{AppContext, AsyncApp}; use project::Project; use serde::Deserialize; use std::cell::Cell; @@ -14,6 +14,7 @@ use std::fs; use std::io::Write; use std::path::PathBuf; use std::sync::Arc; +use std::sync::Mutex; use std::time::{Duration, Instant}; #[derive(Debug, Args)] @@ -103,112 +104,126 @@ pub async fn zeta2_predict( let _edited_buffers = example.apply_edit_history(&project, cx).await?; let (cursor_buffer, cursor_anchor) = example.cursor_position(&project, cx).await?; + let result = Arc::new(Mutex::new(PredictionDetails::default())); let mut debug_rx = zeta.update(cx, |zeta, _| zeta.debug_info())?; - let refresh_task = zeta.update(cx, |zeta, cx| { - zeta.refresh_context(project.clone(), cursor_buffer.clone(), cursor_anchor, cx) - })?; + let debug_task = cx.background_spawn({ + let result = result.clone(); + async move { + let mut context_retrieval_started_at = None; + let mut context_retrieval_finished_at = None; + let mut search_queries_generated_at = None; + let mut search_queries_executed_at = None; + while let Some(event) = debug_rx.next().await { + match event { + zeta2::ZetaDebugInfo::ContextRetrievalStarted(info) => { + context_retrieval_started_at = Some(info.timestamp); + fs::write(LOGS_DIR.join("search_prompt.md"), &info.search_prompt)?; + } + zeta2::ZetaDebugInfo::SearchQueriesGenerated(info) => { + search_queries_generated_at = Some(info.timestamp); + fs::write( + LOGS_DIR.join("search_queries.json"), + serde_json::to_string_pretty(&info.search_queries).unwrap(), + )?; + } + zeta2::ZetaDebugInfo::SearchQueriesExecuted(info) => { + search_queries_executed_at = Some(info.timestamp); + } + zeta2::ZetaDebugInfo::ContextRetrievalFinished(info) => { + context_retrieval_finished_at = Some(info.timestamp); + } + zeta2::ZetaDebugInfo::EditPredictionRequested(request) => { + let prediction_started_at = Instant::now(); + fs::write( + LOGS_DIR.join("prediction_prompt.md"), + &request.local_prompt.unwrap_or_default(), + )?; - let mut context_retrieval_started_at = None; - let mut context_retrieval_finished_at = None; - let mut search_queries_generated_at = None; - let mut search_queries_executed_at = None; - let mut prediction_started_at = None; - let mut prediction_finished_at = None; - let mut excerpts_text = String::new(); - let mut prediction_task = None; - let mut result = PredictionDetails::default(); - while let Some(event) = debug_rx.next().await { - match event { - zeta2::ZetaDebugInfo::ContextRetrievalStarted(info) => { - context_retrieval_started_at = Some(info.timestamp); - fs::write(LOGS_DIR.join("search_prompt.md"), &info.search_prompt)?; - } - zeta2::ZetaDebugInfo::SearchQueriesGenerated(info) => { - search_queries_generated_at = Some(info.timestamp); - fs::write( - LOGS_DIR.join("search_queries.json"), - serde_json::to_string_pretty(&info.regex_by_glob).unwrap(), - )?; - } - zeta2::ZetaDebugInfo::SearchQueriesExecuted(info) => { - search_queries_executed_at = Some(info.timestamp); - } - zeta2::ZetaDebugInfo::ContextRetrievalFinished(info) => { - context_retrieval_finished_at = Some(info.timestamp); + { + let mut result = result.lock().unwrap(); - prediction_task = Some(zeta.update(cx, |zeta, cx| { - zeta.request_prediction(&project, &cursor_buffer, cursor_anchor, cx) - })?); - } - zeta2::ZetaDebugInfo::EditPredictionRequested(request) => { - prediction_started_at = Some(Instant::now()); - fs::write( - LOGS_DIR.join("prediction_prompt.md"), - &request.local_prompt.unwrap_or_default(), - )?; + for included_file in request.request.included_files { + let insertions = + vec![(request.request.cursor_point, CURSOR_MARKER)]; + result.excerpts.extend(included_file.excerpts.iter().map( + |excerpt| ActualExcerpt { + path: included_file.path.components().skip(1).collect(), + text: String::from(excerpt.text.as_ref()), + }, + )); + write_codeblock( + &included_file.path, + included_file.excerpts.iter(), + if included_file.path == request.request.excerpt_path { + &insertions + } else { + &[] + }, + included_file.max_row, + false, + &mut result.excerpts_text, + ); + } + } - for included_file in request.request.included_files { - let insertions = vec![(request.request.cursor_point, CURSOR_MARKER)]; - result - .excerpts - .extend(included_file.excerpts.iter().map(|excerpt| ActualExcerpt { - path: included_file.path.components().skip(1).collect(), - text: String::from(excerpt.text.as_ref()), - })); - write_codeblock( - &included_file.path, - included_file.excerpts.iter(), - if included_file.path == request.request.excerpt_path { - &insertions - } else { - &[] - }, - included_file.max_row, - false, - &mut excerpts_text, - ); - } + let response = request.response_rx.await?.0.map_err(|err| anyhow!(err))?; + let response = zeta2::text_from_response(response).unwrap_or_default(); + let prediction_finished_at = Instant::now(); + fs::write(LOGS_DIR.join("prediction_response.md"), &response)?; - let response = request.response_rx.await?.0.map_err(|err| anyhow!(err))?; - let response = zeta2::text_from_response(response).unwrap_or_default(); - prediction_finished_at = Some(Instant::now()); - fs::write(LOGS_DIR.join("prediction_response.md"), &response)?; + let mut result = result.lock().unwrap(); - break; + result.planning_search_time = search_queries_generated_at.unwrap() + - context_retrieval_started_at.unwrap(); + result.running_search_time = search_queries_executed_at.unwrap() + - search_queries_generated_at.unwrap(); + result.filtering_search_time = context_retrieval_finished_at.unwrap() + - search_queries_executed_at.unwrap(); + result.prediction_time = prediction_finished_at - prediction_started_at; + result.total_time = + prediction_finished_at - context_retrieval_started_at.unwrap(); + + break; + } + } } + anyhow::Ok(()) } - } + }); + + zeta.update(cx, |zeta, cx| { + zeta.refresh_context(project.clone(), cursor_buffer.clone(), cursor_anchor, cx) + })? + .await?; - refresh_task.await.context("context retrieval failed")?; - let prediction = prediction_task.unwrap().await?; + let prediction = zeta + .update(cx, |zeta, cx| { + zeta.request_prediction(&project, &cursor_buffer, cursor_anchor, cx) + })? + .await?; + debug_task.await?; + + let mut result = Arc::into_inner(result).unwrap().into_inner().unwrap(); result.diff = prediction .map(|prediction| { let old_text = prediction.snapshot.text(); - let new_text = prediction.buffer.update(cx, |buffer, cx| { - buffer.edit(prediction.edits.iter().cloned(), None, cx); - buffer.text() - })?; - anyhow::Ok(language::unified_diff(&old_text, &new_text)) + let new_text = prediction + .buffer + .update(cx, |buffer, cx| { + buffer.edit(prediction.edits.iter().cloned(), None, cx); + buffer.text() + }) + .unwrap(); + language::unified_diff(&old_text, &new_text) }) - .transpose()? .unwrap_or_default(); - result.excerpts_text = excerpts_text; - - result.planning_search_time = - search_queries_generated_at.unwrap() - context_retrieval_started_at.unwrap(); - result.running_search_time = - search_queries_executed_at.unwrap() - search_queries_generated_at.unwrap(); - result.filtering_search_time = - context_retrieval_finished_at.unwrap() - search_queries_executed_at.unwrap(); - result.prediction_time = prediction_finished_at.unwrap() - prediction_started_at.unwrap(); - result.total_time = prediction_finished_at.unwrap() - context_retrieval_started_at.unwrap(); anyhow::Ok(result) } -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct PredictionDetails { pub diff: String, pub excerpts: Vec, From d187cbb1881abf1b23a9eb8c46807578406fd5ab Mon Sep 17 00:00:00 2001 From: Donnie Adams Date: Sat, 8 Nov 2025 03:34:53 -0500 Subject: [PATCH 02/19] Add comment injection support to remaining languages (#41710) Release Notes: - Added support for comment language injections for remaining built-in languages and multi-line support for Rust --- crates/languages/src/css/injections.scm | 3 +++ crates/languages/src/diff/injections.scm | 2 ++ crates/languages/src/jsonc/injections.scm | 2 ++ crates/languages/src/rust/injections.scm | 5 ++++- 4 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 crates/languages/src/css/injections.scm create mode 100644 crates/languages/src/diff/injections.scm create mode 100644 crates/languages/src/jsonc/injections.scm diff --git a/crates/languages/src/css/injections.scm b/crates/languages/src/css/injections.scm new file mode 100644 index 0000000000000000000000000000000000000000..9117c713b98fdd2896b13e4949a77c6489b9ee36 --- /dev/null +++ b/crates/languages/src/css/injections.scm @@ -0,0 +1,3 @@ +((comment) @injection.content + (#set! injection.language "comment") +) diff --git a/crates/languages/src/diff/injections.scm b/crates/languages/src/diff/injections.scm new file mode 100644 index 0000000000000000000000000000000000000000..01e833d1e31d480b66a558bdfb8f07b2f0cdbc46 --- /dev/null +++ b/crates/languages/src/diff/injections.scm @@ -0,0 +1,2 @@ +((comment) @injection.content + (#set! injection.language "comment")) diff --git a/crates/languages/src/jsonc/injections.scm b/crates/languages/src/jsonc/injections.scm new file mode 100644 index 0000000000000000000000000000000000000000..01e833d1e31d480b66a558bdfb8f07b2f0cdbc46 --- /dev/null +++ b/crates/languages/src/jsonc/injections.scm @@ -0,0 +1,2 @@ +((comment) @injection.content + (#set! injection.language "comment")) diff --git a/crates/languages/src/rust/injections.scm b/crates/languages/src/rust/injections.scm index 20d4cf83541f9241b2e296f8dbc4a5cb7a3a5fe7..a69e55edc929851f7ced1441e9bd9062baf79bb3 100644 --- a/crates/languages/src/rust/injections.scm +++ b/crates/languages/src/rust/injections.scm @@ -1,4 +1,7 @@ -((line_comment) @injection.content +([ + (line_comment) + (block_comment) +] @injection.content (#set! injection.language "comment")) (macro_invocation From 44d91c1709d0acc70e69dc0978333e69d28cb17c Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Sat, 8 Nov 2025 02:40:18 -0600 Subject: [PATCH 03/19] docs: Explain what scrollbar marks represent (#42130) ## Summary Adds explanations for what each type of scrollbar indicator visually represents in the editor. ## Description This PR addresses the issue where users didn't understand what the colored marks on the scrollbar mean. The existing documentation explained how to toggle each type of mark on/off, but didn't explain what they actually represent. This adds a brief, clear explanation after each scrollbar indicator setting describing what that indicator shows (e.g., "Git diff indicators appear as colored marks showing lines that have been added, modified, or deleted compared to the git HEAD"). ## Fixes Closes #31794 ## Test Plan - Documentation follows the existing style and format of `docs/src/configuring-zed.md` - Each explanation is concise and immediately follows the setting description - Language is clear and user-friendly Release Notes: - N/A --- docs/src/configuring-zed.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index 43b2a971e6c0cd5dec1854b642aee39e9673bb61..ac72abd7c2635f1873ea2ee23770ba58babbaf6d 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -929,6 +929,8 @@ List of `string` values - Setting: `cursors` - Default: `true` +Cursor indicators appear as small marks on the scrollbar showing where other collaborators' cursors are positioned in the file. + **Options** `boolean` values @@ -939,6 +941,8 @@ List of `string` values - Setting: `git_diff` - Default: `true` +Git diff indicators appear as colored marks showing lines that have been added, modified, or deleted compared to the git HEAD. + **Options** `boolean` values @@ -949,6 +953,8 @@ List of `string` values - Setting: `search_results` - Default: `true` +Search result indicators appear as marks showing all locations in the file where your current search query matches. + **Options** `boolean` values @@ -959,6 +965,8 @@ List of `string` values - Setting: `selected_text` - Default: `true` +Selected text indicators appear as marks showing all occurrences of the currently selected text throughout the file. + **Options** `boolean` values @@ -969,6 +977,8 @@ List of `string` values - Setting: `selected_symbol` - Default: `true` +Selected symbol indicators appear as marks showing all occurrences of the currently selected symbol (like a function or variable name) throughout the file. + **Options** `boolean` values @@ -979,6 +989,8 @@ List of `string` values - Setting: `diagnostics` - Default: `all` +Diagnostic indicators appear as colored marks showing errors, warnings, and other language server diagnostics at their corresponding line positions in the file. + **Options** 1. Show all diagnostics: From b01a6fbdeaeb44698aa03ad04a48cab307b5ccf8 Mon Sep 17 00:00:00 2001 From: Hyeondong Lee Date: Sat, 8 Nov 2025 17:42:33 +0900 Subject: [PATCH 04/19] Fix missing highlight for macro_invocation bang (#41572) (Not sure if this was left out on purpose, but this makes things feel a bit more consistent since [VS Code parses bang mark as part of the macro name](https://github.com/microsoft/vscode/blob/main/extensions/rust/syntaxes/rust.tmLanguage.json#L889-L905)) Release Notes: - Added the missing highlight for the bang mark in macro invocations. | **Before** | **After** | | :---: | :---: | | before | fixed | --- crates/languages/src/rust/highlights.scm | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/languages/src/rust/highlights.scm b/crates/languages/src/rust/highlights.scm index 36f638e825b117673bd88b3abaf75d0fc433f4e7..c541b5121784e3edb86f6d2e97b0666204d9f475 100644 --- a/crates/languages/src/rust/highlights.scm +++ b/crates/languages/src/rust/highlights.scm @@ -41,6 +41,9 @@ name: (identifier) @function.special) ]) +(macro_invocation + "!" @function.special) + (macro_definition name: (identifier) @function.special.definition) From 77667f4844e3913f20f6af4cba99f7c3b2d73310 Mon Sep 17 00:00:00 2001 From: Xipeng Jin <56369076+xipeng-jin@users.noreply.github.com> Date: Sat, 8 Nov 2025 08:00:53 -0500 Subject: [PATCH 05/19] Remove Markdown CodeBlock metadata and Custom rendering (#42211) Follow up #40736 Clean up `CodeBlockRenderer::Custom` related rendering per the previous PR [comment](https://github.com/zed-industries/zed/pull/40736#issuecomment-3503074893). Additional note here: 1. The `Custom` variant in the enum `CodeBlockRenderer` will become not useful since cleaning all code related to the custom rendering logic. 2. Need to further review the usage of code block `metadata` field in `MarkdownTag::CodeBlock` enum. I would like to have the team further review my note above so that we can make sure it will be safe to clean it up and will not affect any potential future features will be built on top of it. Thank you! Release Notes: - N/A --- crates/markdown/src/markdown.rs | 82 +-------------------------------- 1 file changed, 2 insertions(+), 80 deletions(-) diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 2efae05285f77b6c7ec62a6aabbab979558b0ab3..b74416d8483c6b3fbdcc4f89e7bff348b81be272 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -787,7 +787,6 @@ impl Element for MarkdownElement { }; let mut code_block_ids = HashSet::default(); - let mut current_code_block_metadata = None; let mut current_img_block_range: Option> = None; for (range, event) in parsed_markdown.events.iter() { // Skip alt text for images that rendered @@ -849,7 +848,7 @@ impl Element for MarkdownElement { markdown_end, ); } - MarkdownTag::CodeBlock { kind, metadata } => { + MarkdownTag::CodeBlock { kind, .. } => { let language = match kind { CodeBlockKind::Fenced => None, CodeBlockKind::FencedLang(language) => { @@ -862,8 +861,6 @@ impl Element for MarkdownElement { _ => None, }; - current_code_block_metadata = Some(metadata.clone()); - let is_indented = matches!(kind, CodeBlockKind::Indented); let scroll_handle = if self.style.code_block_overflow_x_scroll { code_block_ids.insert(range.start); @@ -935,64 +932,7 @@ impl Element for MarkdownElement { builder.push_code_block(language); builder.push_div(code_block, range, markdown_end); } - (CodeBlockRenderer::Custom { render, .. }, _) => { - let parent_container = render( - kind, - &parsed_markdown, - range.clone(), - metadata.clone(), - window, - cx, - ); - - let mut parent_container: AnyDiv = if let Some(scroll_handle) = - scroll_handle.as_ref() - { - let scrollbars = Scrollbars::new(ScrollAxes::Horizontal) - .id(("markdown-code-block-scrollbar", range.start)) - .tracked_scroll_handle(scroll_handle.clone()) - .with_track_along( - ScrollAxes::Horizontal, - cx.theme().colors().editor_background, - ) - .notify_content(); - - parent_container - .rounded_b_lg() - .custom_scrollbars(scrollbars, window, cx) - .into() - } else { - parent_container.into() - }; - - parent_container.style().refine(&self.style.code_block); - builder.push_div(parent_container, range, markdown_end); - - let code_block = div() - .id(("code-block", range.start)) - .rounded_b_lg() - .map(|mut code_block| { - if let Some(scroll_handle) = scroll_handle.as_ref() { - code_block.style().restrict_scroll_to_axis = - Some(true); - code_block - .flex() - .overflow_x_scroll() - .overflow_y_hidden() - .track_scroll(scroll_handle) - } else { - code_block.w_full().overflow_hidden() - } - }); - - if let Some(code_block_text_style) = &self.style.code_block.text - { - builder.push_text_style(code_block_text_style.to_owned()); - } - - builder.push_code_block(language); - builder.push_div(code_block, range, markdown_end); - } + (CodeBlockRenderer::Custom { .. }, _) => {} } } MarkdownTag::HtmlBlock => builder.push_div(div(), range, markdown_end), @@ -1131,24 +1071,6 @@ impl Element for MarkdownElement { builder.pop_text_style(); } - let metadata = current_code_block_metadata.take(); - - if let CodeBlockRenderer::Custom { - transform: Some(transform), - .. - } = &self.code_block_renderer - { - builder.modify_current_div(|el| { - transform( - el, - range.clone(), - metadata.clone().unwrap_or_default(), - window, - cx, - ) - }); - } - if let CodeBlockRenderer::Default { copy_button: true, .. } = &self.code_block_renderer From a2c2c617b596fc5d92b7010240d12df457303ac3 Mon Sep 17 00:00:00 2001 From: "boris.zalman" Date: Sat, 8 Nov 2025 15:53:34 +0100 Subject: [PATCH 06/19] Add helix keymap to delete without yanking (#41988) Added previously missing keymap for helix deleting without yank. Action "editor::Delete" is mapped to "Ald-d" as in https://docs.helix-editor.com/master/keymap.html#changes Release Notes: - N/A --- assets/keymaps/vim.json | 1 + 1 file changed, 1 insertion(+) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 9bde6ca7575b958d456d46a002a14e4289fe10fd..c7b83daab67689d10a6b7c1e28312ceff4551e08 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -455,6 +455,7 @@ "<": "vim::Outdent", "=": "vim::AutoIndent", "d": "vim::HelixDelete", + "alt-d": "editor::Delete", // Delete selection, without yanking "c": "vim::HelixSubstitute", "alt-c": "vim::HelixSubstituteNoYank", From 28a85158c77dc8c8506f61238743aa491b97e3c2 Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Sat, 8 Nov 2025 16:46:01 +0100 Subject: [PATCH 07/19] shell_env: Wrap error context in format! where missing (#42267) Release Notes: - N/A --- crates/util/src/shell_env.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/util/src/shell_env.rs b/crates/util/src/shell_env.rs index de9e1a0b260d38de3b8a453100d0b81738daaa2f..7b8239007980158bb7e5d5956bebb4c5bfb576dd 100644 --- a/crates/util/src/shell_env.rs +++ b/crates/util/src/shell_env.rs @@ -93,7 +93,9 @@ async fn capture_unix( // Parse the JSON output from zed --printenv let env_map: collections::HashMap = serde_json::from_str(&env_output) - .with_context(|| "Failed to deserialize environment variables from json: {env_output}")?; + .with_context(|| { + format!("Failed to deserialize environment variables from json: {env_output}") + })?; Ok(env_map) } @@ -210,6 +212,7 @@ async fn capture_windows( let env_output = String::from_utf8_lossy(&output.stdout); // Parse the JSON output from zed --printenv - serde_json::from_str(&env_output) - .with_context(|| "Failed to deserialize environment variables from json: {env_output}") + serde_json::from_str(&env_output).with_context(|| { + format!("Failed to deserialize environment variables from json: {env_output}") + }) } From 94aa643484e294b20afff67b662e4e7dd01f6c3c Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Sat, 8 Nov 2025 12:54:42 -0300 Subject: [PATCH 08/19] docs: Update agent tools page (#42271) Release Notes: - N/A --- docs/src/ai/tools.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/src/ai/tools.md b/docs/src/ai/tools.md index 06e80a863dfd7141500d48db4ad3b4ff0552305c..e40cfcec840402ec881ecd29e9e3358a433c8d6f 100644 --- a/docs/src/ai/tools.md +++ b/docs/src/ai/tools.md @@ -1,12 +1,14 @@ # Tools -Zed's Agent has access to a variety of tools that allow it to interact with your codebase and perform tasks. +Zed's built-in agent has access to a variety of tools that allow it to interact with your codebase and perform tasks. ## Read & Search Tools ### `diagnostics` Gets errors and warnings for either a specific file or the entire project, useful after making edits to determine if further changes are needed. +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. ### `fetch` @@ -54,10 +56,6 @@ Copies a file or directory recursively in the project, more efficient than manua Creates a new directory at the specified path within the project, creating all necessary parent directories (similar to `mkdir -p`). -### `create_file` - -Creates a new file at a specified path with given text content, the most efficient way to create new files or completely replace existing ones. - ### `delete_path` Deletes a file or directory (including contents recursively) at the specified path and confirms the deletion. From f6be16da3ba61c28fe312441bf580e821fb31081 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Sat, 8 Nov 2025 12:55:31 -0300 Subject: [PATCH 09/19] docs: Update text threads page (#42273) Adding a section clarifying the difference between regular threads vs. text threads. Release Notes: - N/A --- docs/src/ai/text-threads.md | 73 ++++++++++++++++++++++++++----------- 1 file changed, 52 insertions(+), 21 deletions(-) diff --git a/docs/src/ai/text-threads.md b/docs/src/ai/text-threads.md index 4e7e7904cf53e1e7e141b29c777a6f53796177cf..eb53051ee00006ab7866cf13bd942a45dc95da40 100644 --- a/docs/src/ai/text-threads.md +++ b/docs/src/ai/text-threads.md @@ -2,9 +2,12 @@ ## Overview {#overview} -Text threads in the [Agent Panel](./agent-panel.md) function similarly to any other editor. You can use custom key bindings and work with multiple cursors, allowing for seamless transitions between coding and engaging in discussions with the language models. +Text threads in the [Agent Panel](./agent-panel.md) function similarly to any other editor. +You can use custom key bindings and work with multiple cursors, allowing for seamless transitions between coding and engaging in discussions with the language models. -However, the text threads differ with the inclusion of message blocks. These blocks serve as containers for text that correspond to different roles within the context. These roles include: +However, the text threads differ in the inclusion of message blocks. +These blocks serve as containers for text that correspond to different roles within the context. +These roles include: - `You` - `Assistant` @@ -20,24 +23,29 @@ Inserting text from an editor is as simple as highlighting the text and running ![Quoting a selection](https://zed.dev/img/assistant/quoting-a-selection.png) -To submit a message, use {#kb assistant::Assist}(`assistant: assist`). Unlike normal threads, where pressing enter would submit the message, in text threads, our goal is to make it feel as close to a regular editor as possible. So, pressing {#kb editor::Newline} simply inserts a new line. +To submit a message, use {#kb assistant::Assist}(`assistant: assist`). +Unlike normal threads, where pressing enter would submit the message, in text threads, our goal is to make it feel as close to a regular editor as possible. +So, pressing {#kb editor::Newline} simply inserts a new line. After submitting a message, the response will be streamed below, in an `Assistant` message block. ![Receiving an answer](https://zed.dev/img/assistant/receiving-an-answer.png) -The stream can be canceled at any point with escape. This is useful if you realize early on that the response is not what you were looking for. +The stream can be canceled at any point with escape. +This is useful if you realize early on that the response is not what you were looking for. If you want to start a new conversation at any time, you can hit cmd-n|ctrl-n or use the `New Chat` menu option in the hamburger menu at the top left of the panel. -Simple back-and-forth conversations work well with the text threads. However, there may come a time when you want to modify the previous text in the conversation and steer it in a different direction. +Simple back-and-forth conversations work well with the text threads. +However, there may come a time when you want to modify the previous text in the conversation and steer it in a different direction. ## Editing a Text Thread {#edit-text-thread} Text threads give you the flexibility to have control over the context. You can freely edit any previous text, including the responses from the LLM. If you want to remove a message block entirely, simply place your cursor at the beginning of the block and use the `delete` key. -A typical workflow might involve making edits and adjustments throughout the context to refine your inquiry or provide additional information. Here's an example: +A typical workflow might involve making edits and adjustments throughout the context to refine your inquiry or provide additional information. +Here's an example: 1. Write text in a `You` block. 2. Submit the message with {#kb assistant::Assist}. @@ -47,7 +55,8 @@ A typical workflow might involve making edits and adjustments throughout the con 6. Add additional context to your original message. 7. Submit the message with {#kb assistant::Assist}. -Being able to edit previous messages gives you control over how tokens are used. You don't need to start up a new chat to correct a mistake or to add additional information, and you don't have to waste tokens by submitting follow-up corrections. +Being able to edit previous messages gives you control over how tokens are used. +You don't need to start up a new chat to correct a mistake or to add additional information, and you don't have to waste tokens by submitting follow-up corrections. > **Note**: The act of editing past messages is often referred to as "Rewriting History" in the context of the language models. @@ -57,7 +66,8 @@ Some additional points to keep in mind: ## Commands Overview {#commands} -Slash commands enhance the assistant's capabilities. Begin by typing a `/` at the beginning of the line to see a list of available commands: +Slash commands enhance the assistant's capabilities. +Begin by typing a `/` at the beginning of the line to see a list of available commands: - `/default`: Inserts the default rule - `/diagnostics`: Injects errors reported by the project's language server @@ -80,7 +90,8 @@ Usage: `/default` ### `/diagnostics` -The `/diagnostics` command injects errors reported by the project's language server into the context. This is useful for getting an overview of current issues in your project. +The `/diagnostics` command injects errors reported by the project's language server into the context. +This is useful for getting an overview of current issues in your project. Usage: `/diagnostics [--include-warnings] [path]` @@ -89,7 +100,8 @@ Usage: `/diagnostics [--include-warnings] [path]` ### `/file` -The `/file` command inserts the content of a single file or a directory of files into the context. This allows you to reference specific parts of your project in your conversation with the assistant. +The `/file` command inserts the content of a single file or a directory of files into the context. +This allows you to reference specific parts of your project in your conversation with the assistant. Usage: `/file ` @@ -103,13 +115,15 @@ Examples: ### `/now` -The `/now` command inserts the current date and time into the context. This can be useful letting the language model know the current time (and by extension, how old their current knowledge base is). +The `/now` command inserts the current date and time into the context. +This can be useful for letting the language model know the current time (and by extension, how old their current knowledge base is). Usage: `/now` ### `/prompt` -The `/prompt` command inserts a prompt from the prompt library into the context. It can also be used to nest prompts within prompts. +The `/prompt` command inserts a prompt from the prompt library into the context. +It can also be used to nest prompts within prompts. Usage: `/prompt ` @@ -117,13 +131,15 @@ Related: `/default` ### `/symbols` -The `/symbols` command inserts the active symbols (functions, classes, etc.) from the current tab into the context. This is useful for getting an overview of the structure of the current file. +The `/symbols` command inserts the active symbols (functions, classes, etc.) from the current tab into the context. +This is useful for getting an overview of the structure of the current file. Usage: `/symbols` ### `/tab` -The `/tab` command inserts the content of the active tab or all open tabs into the context. This allows you to reference the content you're currently working on. +The `/tab` command inserts the content of the active tab or all open tabs into the context. +This allows you to reference the content you're currently working on. Usage: `/tab [tab_name|all]` @@ -138,15 +154,17 @@ Examples: ### `/terminal` -The `/terminal` command inserts a select number of lines of output from the terminal into the context. This is useful for referencing recent command outputs or logs. +The `/terminal` command inserts a select number of lines of output from the terminal into the context. +This is useful for referencing recent command outputs or logs. Usage: `/terminal []` -- ``: Optional parameter to specify the number of lines to insert (default is a 50). +- ``: Optional parameter to specify the number of lines to insert (default is 50). ### `/selection` -The `/selection` command inserts the selected text in the editor into the context. This is useful for referencing specific parts of your code. +The `/selection` command inserts the selected text in the editor into the context. +This is useful for referencing specific parts of your code. This is equivalent to the `agent: add selection to thread` command ({#kb agent::AddSelectionToThread}). @@ -173,7 +191,7 @@ Here is some information about their project: /file Cargo.toml ``` -In the above example, the `@file` command is used to insert the contents of the `Cargo.toml` file (or all `Cargo.toml` files present in the project) into the rule. +In the above example, the `/file` command is used to insert the contents of the `Cargo.toml` file (or all `Cargo.toml` files present in the project) into the rule. ## Nesting Rules @@ -185,7 +203,7 @@ You might want to nest rules to: - Break collections like docs or references into smaller, mix-and-matchable parts - Create variants of a similar rule (e.g., `Async Rust - Tokio` vs. `Async Rust - Async-std`) -### Example: +### Example ```plaintext Title: Zed-Flavored Rust @@ -215,6 +233,17 @@ Additional slash commands can be provided by extensions. See [Extension: Slash Commands](../extensions/slash-commands.md) to learn how to create your own. +## Text Threads vs. Threads + +For a while, text threads were the only way to interact with AI in Zed. +We have since introduced, back in May 2025, a new take on the agent panel, which, as opposed to being editor-driven, optimizes for readability. +You can read more about it in [the Agent Panel page](./agent-panel.md). + +However, aside from many interaction differences, the major difference between one vs. the other is that tool calls don't work in Text Threads. +So, it's accurate to say that Text Threads aren't necessarily "agentic", as they can't perform any action on your behalf. +Think of it more like a regular and "traditional" AI chat, where what you'll get out of the model is simply just text. +Consequently, [external agents](./external-agents.md) are also not available in Text Threads. + ## Advanced Concepts ### Rule Templates {#rule-templates} @@ -240,9 +269,11 @@ The following templates can be overridden: 2. [`terminal_assistant_prompt.hbs`](https://github.com/zed-industries/zed/tree/main/assets/prompts/terminal_assistant_prompt.hbs): Used for the terminal assistant feature. -> **Note:** Be sure you want to override these, as you'll miss out on iteration on our built-in features. This should be primarily used when developing Zed. +> **Note:** Be sure you want to override these, as you'll miss out on iteration on our built-in features. +> This should be primarily used when developing Zed. -You can customize these templates to better suit your needs while maintaining the core structure and variables used by Zed. Zed will automatically reload your prompt overrides when they change on disk. +You can customize these templates to better suit your needs while maintaining the core structure and variables used by Zed. +Zed will automatically reload your prompt overrides when they change on disk. Consult Zed's [assets/prompts](https://github.com/zed-industries/zed/tree/main/assets/prompts) directory for current versions you can play with. From 12857a7207dd8d5895a6b55945fe9c4c044d171f Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Sat, 8 Nov 2025 14:41:08 -0300 Subject: [PATCH 10/19] agent: Improve `AddSelectionToThread` action display (#42280) Closes https://github.com/zed-industries/zed/issues/42276 This fixes the fact that the `AddSelectionToThread` action was visible when `disable_ai` was true, as well as it improves its display by making it either disabled or hidden when there are no selections in the editor. I also ended up removing it from the app menu simply because making it observe the `disable_ai` setting would be a bit more complex than I'd like at the moment, so figured that, given I'm also now adding it to the toolbar selection menu, we could do without it over there. Release Notes: - Fixed the `AddSelectionToThread` action showing up when `disable_ai` is true - Improved the `AddSelectionToThread` action display by only making it available when there are selections in the editor --- crates/editor/src/mouse_context_menu.rs | 7 ++++++- crates/zed/src/zed/app_menus.rs | 4 +--- crates/zed/src/zed/quick_action_bar.rs | 17 +++++++++++++++-- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/crates/editor/src/mouse_context_menu.rs b/crates/editor/src/mouse_context_menu.rs index 7c83113f7837565efc59889e74bf397b392c516b..2a63e39adda52734b301eda0d32a5bfa10a8e47e 100644 --- a/crates/editor/src/mouse_context_menu.rs +++ b/crates/editor/src/mouse_context_menu.rs @@ -8,6 +8,8 @@ use crate::{ }; use gpui::prelude::FluentBuilder; use gpui::{Context, DismissEvent, Entity, Focusable as _, Pixels, Point, Subscription, Window}; +use project::DisableAiSettings; +use settings::Settings; use std::ops::Range; use text::PointUtf16; use workspace::OpenInTerminal; @@ -202,6 +204,7 @@ pub fn deploy_context_menu( let evaluate_selection = window.is_action_available(&EvaluateSelectedText, cx); let run_to_cursor = window.is_action_available(&RunToCursor, cx); + let disable_ai = DisableAiSettings::get_global(cx).disable_ai; ui::ContextMenu::build(window, cx, |menu, _window, _cx| { let builder = menu @@ -234,7 +237,9 @@ pub fn deploy_context_menu( quick_launch: false, }), ) - .action("Add to Agent Thread", Box::new(AddSelectionToThread)) + .when(!disable_ai && has_selections, |this| { + this.action("Add to Agent Thread", Box::new(AddSelectionToThread)) + }) .separator() .action("Cut", Box::new(Cut)) .action("Copy", Box::new(Copy)) diff --git a/crates/zed/src/zed/app_menus.rs b/crates/zed/src/zed/app_menus.rs index af68cbbbe9c5178db80f1fc9adc0a922e634c82a..b86889f60acb5f738c93012335ef27b091edc0e2 100644 --- a/crates/zed/src/zed/app_menus.rs +++ b/crates/zed/src/zed/app_menus.rs @@ -2,7 +2,7 @@ use collab_ui::collab_panel; use gpui::{App, Menu, MenuItem, OsAction}; use release_channel::ReleaseChannel; use terminal_view::terminal_panel; -use zed_actions::{ToggleFocus as ToggleDebugPanel, agent::AddSelectionToThread, dev}; +use zed_actions::{ToggleFocus as ToggleDebugPanel, dev}; pub fn app_menus(cx: &mut App) -> Vec { use zed_actions::Quit; @@ -218,8 +218,6 @@ pub fn app_menus(cx: &mut App) -> Vec { MenuItem::action("Move Line Up", editor::actions::MoveLineUp), MenuItem::action("Move Line Down", editor::actions::MoveLineDown), MenuItem::action("Duplicate Selection", editor::actions::DuplicateLineDown), - MenuItem::separator(), - MenuItem::action("Add to Agent Thread", AddSelectionToThread), ], }, Menu { diff --git a/crates/zed/src/zed/quick_action_bar.rs b/crates/zed/src/zed/quick_action_bar.rs index 273e99588b90d16f6c0b7c4f2982cd995d4ca2f1..402881680232ea636f7cb105db759f417a435145 100644 --- a/crates/zed/src/zed/quick_action_bar.rs +++ b/crates/zed/src/zed/quick_action_bar.rs @@ -15,7 +15,7 @@ use gpui::{ FocusHandle, Focusable, InteractiveElement, ParentElement, Render, Styled, Subscription, WeakEntity, Window, anchored, deferred, point, }; -use project::project_settings::DiagnosticSeverity; +use project::{DisableAiSettings, project_settings::DiagnosticSeverity}; use search::{BufferSearchBar, buffer_search}; use settings::{Settings, SettingsStore}; use ui::{ @@ -27,7 +27,7 @@ use workspace::item::ItemBufferKind; use workspace::{ ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, item::ItemHandle, }; -use zed_actions::{assistant::InlineAssist, outline::ToggleOutline}; +use zed_actions::{agent::AddSelectionToThread, assistant::InlineAssist, outline::ToggleOutline}; const MAX_CODE_ACTION_MENU_LINES: u32 = 16; @@ -241,8 +241,14 @@ impl Render for QuickActionBar { .read(cx) .snapshot(cx) .has_diff_hunks(); + let has_selection = editor.update(cx, |editor, cx| { + editor.has_non_empty_selection(&editor.display_snapshot(cx)) + }); + let focus = editor.focus_handle(cx); + let disable_ai = DisableAiSettings::get_global(cx).disable_ai; + PopoverMenu::new("editor-selections-dropdown") .trigger_with_tooltip( IconButton::new("toggle_editor_selections_icon", IconName::CursorIBeam) @@ -278,6 +284,13 @@ impl Render for QuickActionBar { skip_soft_wrap: true, }), ) + .when(!disable_ai, |this| { + this.separator().action_disabled_when( + !has_selection, + "Add to Agent Thread", + Box::new(AddSelectionToThread), + ) + }) .separator() .action("Go to Symbol", Box::new(ToggleOutline)) .action("Go to Line/Column", Box::new(ToggleGoToLine)) From 21f73d9c02681152019ed5703ce8808c841fcbbe Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Sat, 8 Nov 2025 17:47:01 -0600 Subject: [PATCH 11/19] Use ButtonLike and add OpenExcerptsSplit and dispatches on click (#42283) Closes #42099 Release Notes: - N/A --- crates/editor/src/element.rs | 75 ++++++++++++++++++++++-------------- 1 file changed, 47 insertions(+), 28 deletions(-) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index d30ead9126b35e183ee1a16d9020ef5766eeedab..99a4743eb9d5e1ef8dfc99fbee7b2a74490c7356 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -6,9 +6,9 @@ use crate::{ EditDisplayMode, EditPrediction, Editor, EditorMode, EditorSettings, EditorSnapshot, EditorStyle, FILE_HEADER_HEIGHT, FocusedBlock, GutterDimensions, HalfPageDown, HalfPageUp, HandleInput, HoveredCursor, InlayHintRefreshReason, JumpData, LineDown, LineHighlight, LineUp, - MAX_LINE_LEN, MINIMAP_FONT_SIZE, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, OpenExcerpts, PageDown, - PageUp, PhantomBreakpointIndicator, Point, RowExt, RowRangeExt, SelectPhase, - SelectedTextHighlight, Selection, SelectionDragState, SizingBehavior, SoftWrap, + MAX_LINE_LEN, MINIMAP_FONT_SIZE, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, OpenExcerpts, + OpenExcerptsSplit, PageDown, PageUp, PhantomBreakpointIndicator, Point, RowExt, RowRangeExt, + SelectPhase, SelectedTextHighlight, Selection, SelectionDragState, SizingBehavior, SoftWrap, StickyHeaderExcerpt, ToPoint, ToggleFold, ToggleFoldAll, code_context_menus::{CodeActionsMenu, MENU_ASIDE_MAX_WIDTH, MENU_ASIDE_MIN_WIDTH, MENU_GAP}, display_map::{ @@ -4043,17 +4043,24 @@ impl EditorElement { ) .group_hover("", |div| div.underline()), ) - .on_click(window.listener_for(&self.editor, { - let jump_data = jump_data.clone(); - move |editor, e: &ClickEvent, window, cx| { - editor.open_excerpts_common( - Some(jump_data.clone()), - e.modifiers().secondary(), - window, - cx, - ); + .on_click({ + let focus_handle = focus_handle.clone(); + move |event, window, cx| { + if event.modifiers().secondary() { + focus_handle.dispatch_action( + &OpenExcerptsSplit, + window, + cx, + ); + } else { + focus_handle.dispatch_action( + &OpenExcerpts, + window, + cx, + ); + } } - })), + }), ) .when_some(parent_path, |then, path| { then.child(div().child(path).text_color( @@ -4069,24 +4076,36 @@ impl EditorElement { can_open_excerpts && is_selected && relative_path.is_some(), |el| { el.child( - Button::new("open-file", "Open File") + ButtonLike::new("open-file-button") .style(ButtonStyle::OutlinedGhost) - .key_binding(KeyBinding::for_action_in( - &OpenExcerpts, - &focus_handle, - cx, - )) - .on_click(window.listener_for(&self.editor, { - let jump_data = jump_data.clone(); - move |editor, e: &ClickEvent, window, cx| { - editor.open_excerpts_common( - Some(jump_data.clone()), - e.modifiers().secondary(), - window, + .child( + h_flex() + .gap_2p5() + .child(Label::new("Open file")) + .child(KeyBinding::for_action_in( + &OpenExcerpts, + &focus_handle, cx, - ); + )), + ) + .on_click({ + let focus_handle = focus_handle.clone(); + move |event, window, cx| { + if event.modifiers().secondary() { + focus_handle.dispatch_action( + &OpenExcerptsSplit, + window, + cx, + ); + } else { + focus_handle.dispatch_action( + &OpenExcerpts, + window, + cx, + ); + } } - })), + }), ) }, ) From 81d38d98727fae44e0e414494e85f78dd4a04ec9 Mon Sep 17 00:00:00 2001 From: John Tur Date: Sun, 9 Nov 2025 03:51:39 -0500 Subject: [PATCH 12/19] Additional Windows keyboard input fixes (#42294) - Enable Alt+Numpad input - For this to be effective, the default keybindings for Alt+{Number} will need to be unbound. This won't be needed once we gain the ability to differentiate numpad digit keys from alphanumeric digit keys. - Fixes https://github.com/zed-industries/zed/issues/40699 - Fix a number of edge cases with dead keys Release Notes: - N/A --- crates/gpui/src/platform/windows/events.rs | 55 ++++++++++------------ 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/crates/gpui/src/platform/windows/events.rs b/crates/gpui/src/platform/windows/events.rs index 2a962b0c235e9900ded1496352521033fa8de667..4e6df63106f4c650ad3130e39d410670ddc4687d 100644 --- a/crates/gpui/src/platform/windows/events.rs +++ b/crates/gpui/src/platform/windows/events.rs @@ -1332,21 +1332,13 @@ fn parse_normal_key( lparam: LPARAM, mut modifiers: Modifiers, ) -> Option<(Keystroke, bool)> { - let mut key_char = None; + let (key_char, prefer_character_input) = process_key(vkey, lparam.hiword()); + let key = parse_immutable(vkey).or_else(|| { let scan_code = lparam.hiword() & 0xFF; - key_char = generate_key_char( - vkey, - scan_code as u32, - modifiers.control, - modifiers.shift, - modifiers.alt, - ); get_keystroke_key(vkey, scan_code as u32, &mut modifiers) })?; - let prefer_character_input = should_prefer_character_input(vkey, lparam.hiword() & 0xFF); - Some(( Keystroke { modifiers, @@ -1357,11 +1349,11 @@ fn parse_normal_key( )) } -fn should_prefer_character_input(vkey: VIRTUAL_KEY, scan_code: u16) -> bool { +fn process_key(vkey: VIRTUAL_KEY, scan_code: u16) -> (Option, bool) { let mut keyboard_state = [0u8; 256]; unsafe { if GetKeyboardState(&mut keyboard_state).is_err() { - return false; + return (None, false); } } @@ -1372,21 +1364,25 @@ fn should_prefer_character_input(vkey: VIRTUAL_KEY, scan_code: u16) -> bool { scan_code as u32, Some(&keyboard_state), &mut buffer_c, - 0x5, + 0x4, ) }; + + if result_c == 0 { + return (None, false); + } + + let c = &buffer_c[..result_c.unsigned_abs() as usize]; + let key_char = String::from_utf16(c) + .ok() + .filter(|s| !s.is_empty() && !s.chars().next().unwrap().is_control()); + if result_c < 0 { - return false; + return (key_char, true); } - let c = &buffer_c[..result_c as usize]; - if char::decode_utf16(c.iter().copied()) - .next() - .and_then(|ch| ch.ok()) - .map(|ch| ch.is_control()) - .unwrap_or(true) - { - return false; + if key_char.is_none() { + return (None, false); } // Workaround for some bug that makes the compiler think keyboard_state is still zeroed out @@ -1395,9 +1391,10 @@ fn should_prefer_character_input(vkey: VIRTUAL_KEY, scan_code: u16) -> bool { let alt_down = (keyboard_state[VK_MENU.0 as usize] & 0x80) != 0; let win_down = (keyboard_state[VK_LWIN.0 as usize] & 0x80) != 0 || (keyboard_state[VK_RWIN.0 as usize] & 0x80) != 0; + let has_modifiers = ctrl_down || alt_down || win_down; if !has_modifiers { - return false; + return (key_char, false); } let mut state_no_modifiers = keyboard_state; @@ -1417,15 +1414,15 @@ fn should_prefer_character_input(vkey: VIRTUAL_KEY, scan_code: u16) -> bool { scan_code as u32, Some(&state_no_modifiers), &mut buffer_c_no_modifiers, - 0x5, + 0x4, ) }; - if result_c_no_modifiers <= 0 { - return false; - } - let c_no_modifiers = &buffer_c_no_modifiers[..result_c_no_modifiers as usize]; - c != c_no_modifiers + let c_no_modifiers = &buffer_c_no_modifiers[..result_c_no_modifiers.unsigned_abs() as usize]; + ( + key_char, + result_c != result_c_no_modifiers || c != c_no_modifiers, + ) } fn parse_ime_composition_string(ctx: HIMC, comp_type: IME_COMPOSITION_STRING) -> Option { From b7d4d1791a873af87340ba204ce58bfc3472986d Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Sun, 9 Nov 2025 11:55:56 +0100 Subject: [PATCH 13/19] diagnostics: Keep diagnostic excerpt ranges properly ordered (#42298) Fixes ZED-2CQ We were doing the binary search by buffer points, but due to await points within this function we could end up mixing points of differing buffer versions. Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/diagnostics/src/buffer_diagnostics.rs | 54 +++++++++----- crates/diagnostics/src/diagnostic_renderer.rs | 21 ++---- crates/diagnostics/src/diagnostics.rs | 74 ++++++++++++------- crates/multi_buffer/src/multi_buffer.rs | 4 +- crates/multi_buffer/src/path_key.rs | 18 ++--- crates/sum_tree/src/cursor.rs | 2 +- 6 files changed, 103 insertions(+), 70 deletions(-) diff --git a/crates/diagnostics/src/buffer_diagnostics.rs b/crates/diagnostics/src/buffer_diagnostics.rs index 8fe503a706027fb6ed2f0b9114450eb79c2aa027..01626ddfd2a3f1a4773b2e88a9b8ff001b46680a 100644 --- a/crates/diagnostics/src/buffer_diagnostics.rs +++ b/crates/diagnostics/src/buffer_diagnostics.rs @@ -23,7 +23,7 @@ use project::{ use settings::Settings; use std::{ any::{Any, TypeId}, - cmp::Ordering, + cmp::{self, Ordering}, sync::Arc, }; use text::{Anchor, BufferSnapshot, OffsetRangeExt}; @@ -410,7 +410,7 @@ impl BufferDiagnosticsEditor { // in the editor. // This is done by iterating over the list of diagnostic blocks and // determine what range does the diagnostic block span. - let mut excerpt_ranges: Vec> = Vec::new(); + let mut excerpt_ranges: Vec> = Vec::new(); for diagnostic_block in blocks.iter() { let excerpt_range = context_range_for_entry( @@ -420,30 +420,43 @@ impl BufferDiagnosticsEditor { &mut cx, ) .await; + let initial_range = buffer_snapshot + .anchor_after(diagnostic_block.initial_range.start) + ..buffer_snapshot.anchor_before(diagnostic_block.initial_range.end); - let index = excerpt_ranges - .binary_search_by(|probe| { + let bin_search = |probe: &ExcerptRange| { + let context_start = || { probe .context .start - .cmp(&excerpt_range.start) - .then(probe.context.end.cmp(&excerpt_range.end)) - .then( - probe - .primary - .start - .cmp(&diagnostic_block.initial_range.start), - ) - .then(probe.primary.end.cmp(&diagnostic_block.initial_range.end)) - .then(Ordering::Greater) - }) - .unwrap_or_else(|index| index); + .cmp(&excerpt_range.start, &buffer_snapshot) + }; + let context_end = + || probe.context.end.cmp(&excerpt_range.end, &buffer_snapshot); + let primary_start = || { + probe + .primary + .start + .cmp(&initial_range.start, &buffer_snapshot) + }; + let primary_end = + || probe.primary.end.cmp(&initial_range.end, &buffer_snapshot); + context_start() + .then_with(context_end) + .then_with(primary_start) + .then_with(primary_end) + .then(cmp::Ordering::Greater) + }; + + let index = excerpt_ranges + .binary_search_by(bin_search) + .unwrap_or_else(|i| i); excerpt_ranges.insert( index, ExcerptRange { context: excerpt_range, - primary: diagnostic_block.initial_range.clone(), + primary: initial_range, }, ) } @@ -466,6 +479,13 @@ impl BufferDiagnosticsEditor { buffer_diagnostics_editor .multibuffer .update(cx, |multibuffer, cx| { + let excerpt_ranges = excerpt_ranges + .into_iter() + .map(|range| ExcerptRange { + context: range.context.to_point(&buffer_snapshot), + primary: range.primary.to_point(&buffer_snapshot), + }) + .collect(); multibuffer.set_excerpt_ranges_for_path( PathKey::for_buffer(&buffer, cx), buffer.clone(), diff --git a/crates/diagnostics/src/diagnostic_renderer.rs b/crates/diagnostics/src/diagnostic_renderer.rs index 5eda81faf97878605142a8bf0832b9082dbc414c..6204bf4b52ddb903773beac28627d53c3cce7765 100644 --- a/crates/diagnostics/src/diagnostic_renderer.rs +++ b/crates/diagnostics/src/diagnostic_renderer.rs @@ -39,8 +39,8 @@ impl DiagnosticRenderer { let group_id = primary.diagnostic.group_id; let mut results = vec![]; for entry in diagnostic_group.iter() { + let mut markdown = Self::markdown(&entry.diagnostic); if entry.diagnostic.is_primary { - let mut markdown = Self::markdown(&entry.diagnostic); let diagnostic = &primary.diagnostic; if diagnostic.source.is_some() || diagnostic.code.is_some() { markdown.push_str(" ("); @@ -81,21 +81,12 @@ impl DiagnosticRenderer { diagnostics_editor: diagnostics_editor.clone(), markdown: cx.new(|cx| Markdown::new(markdown.into(), None, None, cx)), }); - } else if entry.range.start.row.abs_diff(primary.range.start.row) < 5 { - let markdown = Self::markdown(&entry.diagnostic); - - results.push(DiagnosticBlock { - initial_range: entry.range.clone(), - severity: entry.diagnostic.severity, - diagnostics_editor: diagnostics_editor.clone(), - markdown: cx.new(|cx| Markdown::new(markdown.into(), None, None, cx)), - }); } else { - let mut markdown = Self::markdown(&entry.diagnostic); - markdown.push_str(&format!( - " ([back](file://#diagnostic-{buffer_id}-{group_id}-{primary_ix}))" - )); - + if entry.range.start.row.abs_diff(primary.range.start.row) >= 5 { + markdown.push_str(&format!( + " ([back](file://#diagnostic-{buffer_id}-{group_id}-{primary_ix}))" + )); + } results.push(DiagnosticBlock { initial_range: entry.range.clone(), severity: entry.diagnostic.severity, diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index eabb5b788f25e9308d5352513a4357172959d8eb..c2caefe3f388e12fe91931060fe8980908157e48 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -498,7 +498,7 @@ impl ProjectDiagnosticsEditor { cx: &mut Context, ) -> Task> { let was_empty = self.multibuffer.read(cx).is_empty(); - let buffer_snapshot = buffer.read(cx).snapshot(); + let mut buffer_snapshot = buffer.read(cx).snapshot(); let buffer_id = buffer_snapshot.remote_id(); let max_severity = if self.include_warnings { @@ -546,6 +546,7 @@ impl ProjectDiagnosticsEditor { } let mut blocks: Vec = Vec::new(); + let diagnostics_toolbar_editor = Arc::new(this.clone()); for (_, group) in grouped { let group_severity = group.iter().map(|d| d.diagnostic.severity).min(); if group_severity.is_none_or(|s| s > max_severity) { @@ -555,7 +556,7 @@ impl ProjectDiagnosticsEditor { crate::diagnostic_renderer::DiagnosticRenderer::diagnostic_blocks_for_group( group, buffer_snapshot.remote_id(), - Some(Arc::new(this.clone())), + Some(diagnostics_toolbar_editor.clone()), cx, ) })?; @@ -563,7 +564,7 @@ impl ProjectDiagnosticsEditor { blocks.extend(more); } - let mut excerpt_ranges: Vec> = this.update(cx, |this, cx| { + let mut excerpt_ranges: Vec> = this.update(cx, |this, cx| { this.multibuffer.update(cx, |multi_buffer, cx| { let is_dirty = multi_buffer .buffer(buffer_id) @@ -573,10 +574,7 @@ impl ProjectDiagnosticsEditor { RetainExcerpts::All | RetainExcerpts::Dirty => multi_buffer .excerpts_for_buffer(buffer_id, cx) .into_iter() - .map(|(_, range)| ExcerptRange { - context: range.context.to_point(&buffer_snapshot), - primary: range.primary.to_point(&buffer_snapshot), - }) + .map(|(_, range)| range) .collect(), } }) @@ -591,24 +589,41 @@ impl ProjectDiagnosticsEditor { cx, ) .await; + buffer_snapshot = cx.update(|_, cx| buffer.read(cx).snapshot())?; + let initial_range = buffer_snapshot.anchor_after(b.initial_range.start) + ..buffer_snapshot.anchor_before(b.initial_range.end); - let i = excerpt_ranges - .binary_search_by(|probe| { + let bin_search = |probe: &ExcerptRange| { + let context_start = || { probe .context .start - .cmp(&excerpt_range.start) - .then(probe.context.end.cmp(&excerpt_range.end)) - .then(probe.primary.start.cmp(&b.initial_range.start)) - .then(probe.primary.end.cmp(&b.initial_range.end)) - .then(cmp::Ordering::Greater) - }) + .cmp(&excerpt_range.start, &buffer_snapshot) + }; + let context_end = + || probe.context.end.cmp(&excerpt_range.end, &buffer_snapshot); + let primary_start = || { + probe + .primary + .start + .cmp(&initial_range.start, &buffer_snapshot) + }; + let primary_end = + || probe.primary.end.cmp(&initial_range.end, &buffer_snapshot); + context_start() + .then_with(context_end) + .then_with(primary_start) + .then_with(primary_end) + .then(cmp::Ordering::Greater) + }; + let i = excerpt_ranges + .binary_search_by(bin_search) .unwrap_or_else(|i| i); excerpt_ranges.insert( i, ExcerptRange { context: excerpt_range, - primary: b.initial_range.clone(), + primary: initial_range, }, ); result_blocks.insert(i, Some(b)); @@ -623,6 +638,13 @@ impl ProjectDiagnosticsEditor { }) } let (anchor_ranges, _) = this.multibuffer.update(cx, |multi_buffer, cx| { + let excerpt_ranges = excerpt_ranges + .into_iter() + .map(|range| ExcerptRange { + context: range.context.to_point(&buffer_snapshot), + primary: range.primary.to_point(&buffer_snapshot), + }) + .collect(); multi_buffer.set_excerpt_ranges_for_path( PathKey::for_buffer(&buffer, cx), buffer.clone(), @@ -968,8 +990,8 @@ async fn context_range_for_entry( context: u32, snapshot: BufferSnapshot, cx: &mut AsyncApp, -) -> Range { - if let Some(rows) = heuristic_syntactic_expand( +) -> Range { + let range = if let Some(rows) = heuristic_syntactic_expand( range.clone(), DIAGNOSTIC_EXPANSION_ROW_LIMIT, snapshot.clone(), @@ -977,15 +999,17 @@ async fn context_range_for_entry( ) .await { - return Range { + Range { start: Point::new(*rows.start(), 0), end: snapshot.clip_point(Point::new(*rows.end(), u32::MAX), Bias::Left), - }; - } - Range { - start: Point::new(range.start.row.saturating_sub(context), 0), - end: snapshot.clip_point(Point::new(range.end.row + context, u32::MAX), Bias::Left), - } + } + } else { + Range { + start: Point::new(range.start.row.saturating_sub(context), 0), + end: snapshot.clip_point(Point::new(range.end.row + context, u32::MAX), Bias::Left), + } + }; + snapshot.anchor_after(range.start)..snapshot.anchor_before(range.end) } /// Expands the input range using syntax information from TreeSitter. This expansion will be limited diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 90f1bcbe39468fcfa390ce8175414451ddb3b2c7..5be61d1efe153fcd6902b33e46f2f5b84d4055e6 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -1148,9 +1148,9 @@ impl MultiBuffer { let mut counts: Vec = Vec::new(); for range in expanded_ranges { if let Some(last_range) = merged_ranges.last_mut() { - debug_assert!( + assert!( last_range.context.start <= range.context.start, - "Last range: {last_range:?} Range: {range:?}" + "ranges must be sorted: {last_range:?} <= {range:?}" ); if last_range.context.end >= range.context.start || last_range.context.end.row + 1 == range.context.start.row diff --git a/crates/multi_buffer/src/path_key.rs b/crates/multi_buffer/src/path_key.rs index c750bb912f0c2767e4c56890d0ab75046c094e71..09d098ea0727906500b37acd8f694f569fea75e2 100644 --- a/crates/multi_buffer/src/path_key.rs +++ b/crates/multi_buffer/src/path_key.rs @@ -172,7 +172,7 @@ impl MultiBuffer { .into_iter() .chunk_by(|id| self.paths_by_excerpt.get(id).cloned()) .into_iter() - .flat_map(|(k, v)| Some((k?, v.into_iter().collect::>()))) + .filter_map(|(k, v)| Some((k?, v.into_iter().collect::>()))) .collect::>(); let snapshot = self.snapshot(cx); @@ -280,7 +280,7 @@ impl MultiBuffer { .excerpts_by_path .range(..path.clone()) .next_back() - .map(|(_, value)| *value.last().unwrap()) + .and_then(|(_, value)| value.last().copied()) .unwrap_or(ExcerptId::min()); let existing = self @@ -299,6 +299,7 @@ impl MultiBuffer { let snapshot = self.snapshot(cx); let mut next_excerpt_id = + // is this right? What if we remove the last excerpt, then we might reallocate with a wrong mapping? if let Some(last_entry) = self.snapshot.borrow().excerpt_ids.last() { last_entry.id.0 + 1 } else { @@ -311,20 +312,16 @@ impl MultiBuffer { excerpts_cursor.next(); loop { - let new = new_iter.peek(); - let existing = if let Some(existing_id) = existing_iter.peek() { - let locator = snapshot.excerpt_locator_for_id(*existing_id); + let existing = if let Some(&existing_id) = existing_iter.peek() { + let locator = snapshot.excerpt_locator_for_id(existing_id); excerpts_cursor.seek_forward(&Some(locator), Bias::Left); if let Some(excerpt) = excerpts_cursor.item() { if excerpt.buffer_id != buffer_snapshot.remote_id() { - to_remove.push(*existing_id); + to_remove.push(existing_id); existing_iter.next(); continue; } - Some(( - *existing_id, - excerpt.range.context.to_point(buffer_snapshot), - )) + Some((existing_id, excerpt.range.context.to_point(buffer_snapshot))) } else { None } @@ -332,6 +329,7 @@ impl MultiBuffer { None }; + let new = new_iter.peek(); if let Some((last_id, last)) = to_insert.last_mut() { if let Some(new) = new && last.context.end >= new.context.start diff --git a/crates/sum_tree/src/cursor.rs b/crates/sum_tree/src/cursor.rs index 7418224c86f51a52a8a621da0f2a0c53dcfcf404..f37eae4f4becaf94c578b233d18e4bff8169252d 100644 --- a/crates/sum_tree/src/cursor.rs +++ b/crates/sum_tree/src/cursor.rs @@ -448,7 +448,7 @@ where aggregate: &mut dyn SeekAggregate<'a, T>, ) -> bool { assert!( - target.cmp(&self.position, self.cx) >= Ordering::Equal, + target.cmp(&self.position, self.cx).is_ge(), "cannot seek backward", ); From 5d08c1b35f937f1a3e6cb63537d8a1ebb6bac9c9 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Sun, 9 Nov 2025 12:27:16 +0100 Subject: [PATCH 14/19] Surpress more rust-analyzer error logs (#42299) Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/languages/src/c.rs | 2 +- crates/languages/src/python.rs | 4 ++-- crates/languages/src/rust.rs | 2 +- crates/project/src/lsp_store.rs | 17 ++++++++++++----- crates/vim/src/state.rs | 10 ++++++---- 5 files changed, 22 insertions(+), 13 deletions(-) diff --git a/crates/languages/src/c.rs b/crates/languages/src/c.rs index 5957dde04d4fbf43763647675733cc0ecab3c9ab..3351c9df033a5e4550e34b5c9dbf1d119b189f6d 100644 --- a/crates/languages/src/c.rs +++ b/crates/languages/src/c.rs @@ -98,7 +98,7 @@ impl LspInstaller for CLspAdapter { }) .await .inspect_err(|err| { - log::warn!("Unable to run {binary_path:?} asset, redownloading: {err}",) + log::warn!("Unable to run {binary_path:?} asset, redownloading: {err:#}",) }) }; if let (Some(actual_digest), Some(expected_digest)) = diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index 1c28a7b36a74b66efb1f66e8914b78a48db8a339..3f25a5c5ce50d0aade7b1b575443b5d681f67c63 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -263,7 +263,7 @@ impl LspInstaller for TyLspAdapter { }) .await .inspect_err(|err| { - log::warn!("Unable to run {server_path:?} asset, redownloading: {err}",) + log::warn!("Unable to run {server_path:?} asset, redownloading: {err:#}",) }) }; if let (Some(actual_digest), Some(expected_digest)) = @@ -2176,7 +2176,7 @@ impl LspInstaller for RuffLspAdapter { }) .await .inspect_err(|err| { - log::warn!("Unable to run {server_path:?} asset, redownloading: {err}",) + log::warn!("Unable to run {server_path:?} asset, redownloading: {err:#}",) }) }; if let (Some(actual_digest), Some(expected_digest)) = diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index 5efbf46c7b59e923c01cba165b29fceec2869504..f1b205f83a6bd3a9b26cb39da854817ebba11361 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -529,7 +529,7 @@ impl LspInstaller for RustLspAdapter { }) .await .inspect_err(|err| { - log::warn!("Unable to run {server_path:?} asset, redownloading: {err}",) + log::warn!("Unable to run {server_path:?} asset, redownloading: {err:#}",) }) }; if let (Some(actual_digest), Some(expected_digest)) = diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 379fafd211d69f5c925f37f0b30f2edff682cdc0..50f8c6695c188b065e89b4694e004470aa997abc 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -4519,7 +4519,6 @@ impl LspStore { ) { Ok(LspParamsOrResponse::Params(lsp_params)) => lsp_params, Ok(LspParamsOrResponse::Response(response)) => return Task::ready(Ok(response)), - Err(err) => { let message = format!( "{} via {} failed: {}", @@ -4527,7 +4526,10 @@ impl LspStore { language_server.name(), err ); - log::warn!("{message}"); + // rust-analyzer likes to error with this when its still loading up + if !message.ends_with("content modified") { + log::warn!("{message}"); + } return Task::ready(Err(anyhow!(message))); } }; @@ -4585,7 +4587,10 @@ impl LspStore { language_server.name(), err ); - log::warn!("{message}"); + // rust-analyzer likes to error with this when its still loading up + if !message.ends_with("content modified") { + log::warn!("{message}"); + } anyhow::anyhow!(message) })?; @@ -6907,6 +6912,8 @@ impl LspStore { let mut responses = Vec::new(); match server_task.await { Ok(response) => responses.push((server_id, response)), + // rust-analyzer likes to error with this when its still loading up + Err(e) if format!("{e:#}").ends_with("content modified") => (), Err(e) => log::error!( "Error handling response for inlay hints request: {e:#}" ), @@ -8433,7 +8440,7 @@ impl LspStore { match response_result { Ok(response) => responses.push((server_id, response)), // rust-analyzer likes to error with this when its still loading up - Err(e) if e.to_string().ends_with("content modified") => (), + Err(e) if format!("{e:#}").ends_with("content modified") => (), Err(e) => log::error!("Error handling response for request {request:?}: {e:#}"), } } @@ -12428,7 +12435,7 @@ impl LspStore { match server_task.await { Ok(response) => responses.push((server_id, response)), // rust-analyzer likes to error with this when its still loading up - Err(e) if e.to_string().ends_with("content modified") => (), + Err(e) if format!("{e:#}").ends_with("content modified") => (), Err(e) => log::error!( "Error handling response for request {request:?}: {e:#}" ), diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index dc9ac7104c00a5f49758dbab219ec72d46023b27..3f4fc99584f96754afc5342d299a502eb9a3dbad 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -310,8 +310,8 @@ impl MarksState { fn load(&mut self, cx: &mut Context) { cx.spawn(async move |this, cx| { - let Some(workspace_id) = this.update(cx, |this, cx| this.workspace_id(cx))? else { - return Ok(()); + let Some(workspace_id) = this.update(cx, |this, cx| this.workspace_id(cx)).ok()? else { + return None; }; let (marks, paths) = cx .background_spawn(async move { @@ -319,10 +319,12 @@ impl MarksState { let paths = DB.get_global_marks_paths(workspace_id)?; anyhow::Ok((marks, paths)) }) - .await?; + .await + .log_err()?; this.update(cx, |this, cx| this.loaded(marks, paths, cx)) + .ok() }) - .detach_and_log_err(cx); + .detach(); } fn loaded( From cc1d66b530655da654310682bcd8d34293ffda6f Mon Sep 17 00:00:00 2001 From: chenmi Date: Mon, 10 Nov 2025 00:00:31 +0800 Subject: [PATCH 15/19] agent_ui: Improve API key configuration UI display (#42306) Improve the layout and text display of API key configuration in multiple language model providers to ensure proper text wrapping and ellipsis handling when API URLs are long. Before: image After: image Changes include: - Add proper flex layout with overflow handling - Replace truncate_and_trailoff with CSS text ellipsis - Ensure consistent UI behavior across all providers Release Notes: - Improved API key configuration display in language model settings --- .../language_models/src/provider/anthropic.rs | 54 ++++++++----- .../language_models/src/provider/deepseek.rs | 47 ++++++----- crates/language_models/src/provider/google.rs | 56 ++++++++----- .../language_models/src/provider/mistral.rs | 78 +++++++++++-------- .../language_models/src/provider/open_ai.rs | 56 ++++++++----- .../src/provider/open_ai_compatible.rs | 46 +++++++---- .../src/provider/open_router.rs | 54 ++++++++----- crates/language_models/src/provider/vercel.rs | 54 ++++++++----- crates/language_models/src/provider/x_ai.rs | 54 ++++++++----- 9 files changed, 305 insertions(+), 194 deletions(-) diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index 9eb96cb79815bdbdc06c58ca4156e68e2962b0a4..86cc81cb7bc7b89967e69949ced891a7cb845cea 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -22,7 +22,7 @@ use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; use ui::{Icon, IconName, List, Tooltip, prelude::*}; use ui_input::InputField; -use util::{ResultExt, truncate_and_trailoff}; +use util::ResultExt; use zed_env_vars::{EnvVar, env_var}; use crate::api_key::ApiKeyState; @@ -953,30 +953,42 @@ impl Render for ConfigurationView { .bg(cx.theme().colors().background) .child( h_flex() + .flex_1() + .min_w_0() .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) - .child(Label::new(if env_var_set { - format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") - } else { - let api_url = AnthropicLanguageModelProvider::api_url(cx); - if api_url == ANTHROPIC_API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", truncate_and_trailoff(&api_url, 32)) - } - })), + .child( + div() + .w_full() + .overflow_x_hidden() + .text_ellipsis() + .child(Label::new(if env_var_set { + format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") + } else { + let api_url = AnthropicLanguageModelProvider::api_url(cx); + if api_url == ANTHROPIC_API_URL { + "API key configured".to_string() + } else { + format!("API key configured for {}", api_url) + } + })) + ), ) .child( - Button::new("reset-key", "Reset Key") - .label_size(LabelSize::Small) - .icon(Some(IconName::Trash)) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .disabled(env_var_set) - .when(env_var_set, |this| { - this.tooltip(Tooltip::text(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))) - }) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), + h_flex() + .flex_shrink_0() + .child( + Button::new("reset-key", "Reset Key") + .label_size(LabelSize::Small) + .icon(Some(IconName::Trash)) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .disabled(env_var_set) + .when(env_var_set, |this| { + this.tooltip(Tooltip::text(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))) + }) + .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), + ), ) .into_any() } diff --git a/crates/language_models/src/provider/deepseek.rs b/crates/language_models/src/provider/deepseek.rs index 8784d3805f22974ffa441ecd04ddea4b56be911b..103d068d671acf331fbba73072e3edf2fcc10411 100644 --- a/crates/language_models/src/provider/deepseek.rs +++ b/crates/language_models/src/provider/deepseek.rs @@ -21,7 +21,7 @@ use std::sync::{Arc, LazyLock}; use ui::{Icon, IconName, List, prelude::*}; use ui_input::InputField; -use util::{ResultExt, truncate_and_trailoff}; +use util::ResultExt; use zed_env_vars::{EnvVar, env_var}; use crate::{api_key::ApiKeyState, ui::InstructionListItem}; @@ -640,32 +640,37 @@ impl Render for ConfigurationView { .bg(cx.theme().colors().background) .child( h_flex() + .flex_1() + .min_w_0() .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) - .child(Label::new(if env_var_set { - format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") - } else { - let api_url = DeepSeekLanguageModelProvider::api_url(cx); - if api_url == DEEPSEEK_API_URL { - "API key configured".to_string() - } else { + .child(div().w_full().overflow_x_hidden().text_ellipsis().child( + Label::new(if env_var_set { format!( - "API key configured for {}", - truncate_and_trailoff(&api_url, 32) + "API key set in {API_KEY_ENV_VAR_NAME} environment variable" ) - } - })), + } else { + let api_url = DeepSeekLanguageModelProvider::api_url(cx); + if api_url == DEEPSEEK_API_URL { + "API key configured".to_string() + } else { + format!("API key configured for {}", api_url) + } + }), + )), ) .child( - Button::new("reset-key", "Reset Key") - .label_size(LabelSize::Small) - .icon(Some(IconName::Trash)) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .disabled(env_var_set) - .on_click( - cx.listener(|this, _, window, cx| this.reset_api_key(window, cx)), - ), + h_flex().flex_shrink_0().child( + Button::new("reset-key", "Reset Key") + .label_size(LabelSize::Small) + .icon(Some(IconName::Trash)) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .disabled(env_var_set) + .on_click( + cx.listener(|this, _, window, cx| this.reset_api_key(window, cx)), + ), + ), ) .into_any() } diff --git a/crates/language_models/src/provider/google.rs b/crates/language_models/src/provider/google.rs index a4d1202bee4fc4b2f1e071a815bc2f5887d2457d..a9c97ca939ac55c621bf38b62736af91ab2211ee 100644 --- a/crates/language_models/src/provider/google.rs +++ b/crates/language_models/src/provider/google.rs @@ -30,7 +30,7 @@ use std::sync::{ use strum::IntoEnumIterator; use ui::{Icon, IconName, List, Tooltip, prelude::*}; use ui_input::InputField; -use util::{ResultExt, truncate_and_trailoff}; +use util::ResultExt; use zed_env_vars::EnvVar; use crate::api_key::ApiKey; @@ -876,30 +876,44 @@ impl Render for ConfigurationView { .bg(cx.theme().colors().background) .child( h_flex() + .flex_1() + .min_w_0() .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) - .child(Label::new(if env_var_set { - format!("API key set in {} environment variable", API_KEY_ENV_VAR.name) - } else { - let api_url = GoogleLanguageModelProvider::api_url(cx); - if api_url == google_ai::API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", truncate_and_trailoff(&api_url, 32)) - } - })), + .child( + div() + .w_full() + .overflow_x_hidden() + .text_ellipsis() + .child(Label::new( + if env_var_set { + format!("API key set in {} environment variable", API_KEY_ENV_VAR.name) + } else { + let api_url = GoogleLanguageModelProvider::api_url(cx); + if api_url == google_ai::API_URL { + "API key configured".to_string() + } else { + format!("API key configured for {}", api_url) + } + } + )) + ), ) .child( - Button::new("reset-key", "Reset Key") - .label_size(LabelSize::Small) - .icon(Some(IconName::Trash)) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .disabled(env_var_set) - .when(env_var_set, |this| { - this.tooltip(Tooltip::text(format!("To reset your API key, make sure {GEMINI_API_KEY_VAR_NAME} and {GOOGLE_AI_API_KEY_VAR_NAME} environment variables are unset."))) - }) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), + h_flex() + .flex_shrink_0() + .child( + Button::new("reset-key", "Reset Key") + .label_size(LabelSize::Small) + .icon(Some(IconName::Trash)) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .disabled(env_var_set) + .when(env_var_set, |this| { + this.tooltip(Tooltip::text(format!("To reset your API key, make sure {GEMINI_API_KEY_VAR_NAME} and {GOOGLE_AI_API_KEY_VAR_NAME} environment variables are unset."))) + }) + .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), + ), ) .into_any() } diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index acd4a1c768e0d6ffdffbc3d69dcdc2bfd37fa928..e0bfa5d8eb66ec4ec390bc534ceebe0663610197 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -21,7 +21,7 @@ use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; use ui::{Icon, IconName, List, Tooltip, prelude::*}; use ui_input::InputField; -use util::{ResultExt, truncate_and_trailoff}; +use util::ResultExt; use zed_env_vars::{EnvVar, env_var}; use crate::{api_key::ApiKeyState, ui::InstructionListItem}; @@ -998,40 +998,56 @@ impl Render for ConfigurationView { .bg(cx.theme().colors().background) .child( h_flex() + .flex_1() + .min_w_0() .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) - .child(Label::new(if env_var_set { - format!( - "API key set in {API_KEY_ENV_VAR_NAME} environment variable" - ) - } else { - let api_url = MistralLanguageModelProvider::api_url(cx); - if api_url == MISTRAL_API_URL { - "API key configured".to_string() - } else { - format!( - "API key configured for {}", - truncate_and_trailoff(&api_url, 32) - ) - } - })), + .child( + div() + .w_full() + .overflow_x_hidden() + .text_ellipsis() + .child( + Label::new( + if env_var_set { + format!( + "API key set in {API_KEY_ENV_VAR_NAME} environment variable" + ) + } else { + let api_url = MistralLanguageModelProvider::api_url(cx); + if api_url == MISTRAL_API_URL { + "API key configured".to_string() + } else { + format!( + "API key configured for {}", + api_url + ) + } + } + ) + ), + ), ) .child( - Button::new("reset-key", "Reset Key") - .label_size(LabelSize::Small) - .icon(Some(IconName::Trash)) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .disabled(env_var_set) - .when(env_var_set, |this| { - this.tooltip(Tooltip::text(format!( - "To reset your API key, \ - unset the {API_KEY_ENV_VAR_NAME} environment variable." - ))) - }) - .on_click(cx.listener(|this, _, window, cx| { - this.reset_api_key(window, cx) - })), + h_flex() + .flex_shrink_0() + .child( + Button::new("reset-key", "Reset Key") + .label_size(LabelSize::Small) + .icon(Some(IconName::Trash)) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .disabled(env_var_set) + .when(env_var_set, |this| { + this.tooltip(Tooltip::text(format!( + "To reset your API key, \ + unset the {API_KEY_ENV_VAR_NAME} environment variable." + ))) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.reset_api_key(window, cx) + })), + ), ), ) .child(self.render_codestral_api_key_editor(cx)) diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index 38bf373c6f0de19362560f2c906c3e24a0833cae..aa925a9b582adae5c1ade0b9cdce6765e8614cb6 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -22,7 +22,7 @@ use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; use ui::{ElevationIndex, List, Tooltip, prelude::*}; use ui_input::InputField; -use util::{ResultExt, truncate_and_trailoff}; +use util::ResultExt; use zed_env_vars::{EnvVar, env_var}; use crate::{api_key::ApiKeyState, ui::InstructionListItem}; @@ -807,30 +807,44 @@ impl Render for ConfigurationView { .bg(cx.theme().colors().background) .child( h_flex() + .flex_1() + .min_w_0() .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) - .child(Label::new(if env_var_set { - format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") - } else { - let api_url = OpenAiLanguageModelProvider::api_url(cx); - if api_url == OPEN_AI_API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", truncate_and_trailoff(&api_url, 32)) - } - })), + .child( + div() + .w_full() + .overflow_x_hidden() + .text_ellipsis() + .child(Label::new( + if env_var_set { + format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") + } else { + let api_url = OpenAiLanguageModelProvider::api_url(cx); + if api_url == OPEN_AI_API_URL { + "API key configured".to_string() + } else { + format!("API key configured for {}", api_url) + } + } + )) + ), ) .child( - Button::new("reset-api-key", "Reset API Key") - .label_size(LabelSize::Small) - .icon(IconName::Undo) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .layer(ElevationIndex::ModalSurface) - .when(env_var_set, |this| { - this.tooltip(Tooltip::text(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))) - }) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), + h_flex() + .flex_shrink_0() + .child( + Button::new("reset-api-key", "Reset API Key") + .label_size(LabelSize::Small) + .icon(IconName::Undo) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .layer(ElevationIndex::ModalSurface) + .when(env_var_set, |this| { + this.tooltip(Tooltip::text(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))) + }) + .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), + ), ) .into_any() }; diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index c8a1da5f5af9feb72ec514854403d15d6e73774b..4ed0de851244d65b0f838c582ccdffe763d6775f 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -15,7 +15,7 @@ use settings::{Settings, SettingsStore}; use std::sync::Arc; use ui::{ElevationIndex, Tooltip, prelude::*}; use ui_input::InputField; -use util::{ResultExt, truncate_and_trailoff}; +use util::ResultExt; use zed_env_vars::EnvVar; use crate::api_key::ApiKeyState; @@ -455,25 +455,39 @@ impl Render for ConfigurationView { .bg(cx.theme().colors().background) .child( h_flex() + .flex_1() + .min_w_0() .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) - .child(Label::new(if env_var_set { - format!("API key set in {env_var_name} environment variable") - } else { - format!("API key configured for {}", truncate_and_trailoff(&state.settings.api_url, 32)) - })), + .child( + div() + .w_full() + .overflow_x_hidden() + .text_ellipsis() + .child(Label::new( + if env_var_set { + format!("API key set in {env_var_name} environment variable") + } else { + format!("API key configured for {}", &state.settings.api_url) + } + )) + ), ) .child( - Button::new("reset-api-key", "Reset API Key") - .label_size(LabelSize::Small) - .icon(IconName::Undo) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .layer(ElevationIndex::ModalSurface) - .when(env_var_set, |this| { - this.tooltip(Tooltip::text(format!("To reset your API key, unset the {env_var_name} environment variable."))) - }) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), + h_flex() + .flex_shrink_0() + .child( + Button::new("reset-api-key", "Reset API Key") + .label_size(LabelSize::Small) + .icon(IconName::Undo) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .layer(ElevationIndex::ModalSurface) + .when(env_var_set, |this| { + this.tooltip(Tooltip::text(format!("To reset your API key, unset the {env_var_name} environment variable."))) + }) + .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), + ), ) .into_any() }; diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index 50131f0a8ef7076420df9a9dc1dbdcd4c840a5c2..0cc3711489ab25b80c9e995558f18917fbfad343 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -19,7 +19,7 @@ use std::str::FromStr as _; use std::sync::{Arc, LazyLock}; use ui::{Icon, IconName, List, Tooltip, prelude::*}; use ui_input::InputField; -use util::{ResultExt, truncate_and_trailoff}; +use util::ResultExt; use zed_env_vars::{EnvVar, env_var}; use crate::{api_key::ApiKeyState, ui::InstructionListItem}; @@ -818,30 +818,42 @@ impl Render for ConfigurationView { .bg(cx.theme().colors().background) .child( h_flex() + .flex_1() + .min_w_0() .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) - .child(Label::new(if env_var_set { - format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") - } else { - let api_url = OpenRouterLanguageModelProvider::api_url(cx); - if api_url == OPEN_ROUTER_API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", truncate_and_trailoff(&api_url, 32)) - } - })), + .child( + div() + .w_full() + .overflow_x_hidden() + .text_ellipsis() + .child(Label::new(if env_var_set { + format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") + } else { + let api_url = OpenRouterLanguageModelProvider::api_url(cx); + if api_url == OPEN_ROUTER_API_URL { + "API key configured".to_string() + } else { + format!("API key configured for {}", api_url) + } + })) + ), ) .child( - Button::new("reset-key", "Reset Key") - .label_size(LabelSize::Small) - .icon(Some(IconName::Trash)) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .disabled(env_var_set) - .when(env_var_set, |this| { - this.tooltip(Tooltip::text(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))) - }) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), + h_flex() + .flex_shrink_0() + .child( + Button::new("reset-key", "Reset Key") + .label_size(LabelSize::Small) + .icon(Some(IconName::Trash)) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .disabled(env_var_set) + .when(env_var_set, |this| { + this.tooltip(Tooltip::text(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))) + }) + .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), + ), ) .into_any() } diff --git a/crates/language_models/src/provider/vercel.rs b/crates/language_models/src/provider/vercel.rs index ff5d4567c60423939c38d00a1203f613df353ccf..9adc794ceaf255756736b401fff45f1131d4b310 100644 --- a/crates/language_models/src/provider/vercel.rs +++ b/crates/language_models/src/provider/vercel.rs @@ -16,7 +16,7 @@ use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; use ui::{ElevationIndex, List, Tooltip, prelude::*}; use ui_input::InputField; -use util::{ResultExt, truncate_and_trailoff}; +use util::ResultExt; use vercel::{Model, VERCEL_API_URL}; use zed_env_vars::{EnvVar, env_var}; @@ -489,30 +489,42 @@ impl Render for ConfigurationView { .bg(cx.theme().colors().background) .child( h_flex() + .flex_1() + .min_w_0() .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) - .child(Label::new(if env_var_set { - format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") - } else { - let api_url = VercelLanguageModelProvider::api_url(cx); - if api_url == VERCEL_API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", truncate_and_trailoff(&api_url, 32)) - } - })), + .child( + div() + .w_full() + .overflow_x_hidden() + .text_ellipsis() + .child(Label::new(if env_var_set { + format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") + } else { + let api_url = VercelLanguageModelProvider::api_url(cx); + if api_url == VERCEL_API_URL { + "API key configured".to_string() + } else { + format!("API key configured for {}", api_url) + } + })) + ), ) .child( - Button::new("reset-api-key", "Reset API Key") - .label_size(LabelSize::Small) - .icon(IconName::Undo) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .layer(ElevationIndex::ModalSurface) - .when(env_var_set, |this| { - this.tooltip(Tooltip::text(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))) - }) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), + h_flex() + .flex_shrink_0() + .child( + Button::new("reset-api-key", "Reset API Key") + .label_size(LabelSize::Small) + .icon(IconName::Undo) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .layer(ElevationIndex::ModalSurface) + .when(env_var_set, |this| { + this.tooltip(Tooltip::text(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))) + }) + .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), + ), ) .into_any() }; diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs index 8b51ca12099691e4ae70084b509c6c40547bd432..979824442c6d03a8f735448003425e94a83e46ea 100644 --- a/crates/language_models/src/provider/x_ai.rs +++ b/crates/language_models/src/provider/x_ai.rs @@ -16,7 +16,7 @@ use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; use ui::{ElevationIndex, List, Tooltip, prelude::*}; use ui_input::InputField; -use util::{ResultExt, truncate_and_trailoff}; +use util::ResultExt; use x_ai::{Model, XAI_API_URL}; use zed_env_vars::{EnvVar, env_var}; @@ -486,30 +486,42 @@ impl Render for ConfigurationView { .bg(cx.theme().colors().background) .child( h_flex() + .flex_1() + .min_w_0() .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) - .child(Label::new(if env_var_set { - format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") - } else { - let api_url = XAiLanguageModelProvider::api_url(cx); - if api_url == XAI_API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", truncate_and_trailoff(&api_url, 32)) - } - })), + .child( + div() + .w_full() + .overflow_x_hidden() + .text_ellipsis() + .child(Label::new(if env_var_set { + format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") + } else { + let api_url = XAiLanguageModelProvider::api_url(cx); + if api_url == XAI_API_URL { + "API key configured".to_string() + } else { + format!("API key configured for {}", api_url) + } + })) + ), ) .child( - Button::new("reset-api-key", "Reset API Key") - .label_size(LabelSize::Small) - .icon(IconName::Undo) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .layer(ElevationIndex::ModalSurface) - .when(env_var_set, |this| { - this.tooltip(Tooltip::text(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))) - }) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), + h_flex() + .flex_shrink_0() + .child( + Button::new("reset-api-key", "Reset API Key") + .label_size(LabelSize::Small) + .icon(IconName::Undo) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .layer(ElevationIndex::ModalSurface) + .when(env_var_set, |this| { + this.tooltip(Tooltip::text(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))) + }) + .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), + ), ) .into_any() }; From 2fb3d593bc8f9608f0041c8c6183214be7425d5d Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Sun, 9 Nov 2025 14:32:05 -0300 Subject: [PATCH 16/19] agent_ui: Add component to standardize the configured LLM card (#42314) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds a new component to the `language_models` crate called `ConfiguredApiCard`: Screenshot 2025-11-09 at 2  07@2x We were previously recreating this component from scratch with regular divs in all LLM providers render function, which was redundant as they all essentially looked the same and didn't have any major variations aside from labels. We can clean up a bunch of similar code with this change, which is cool! Release Notes: - N/A --- .../language_models/src/provider/anthropic.rs | 77 ++++------ .../language_models/src/provider/bedrock.rs | 77 +++++----- .../src/provider/copilot_chat.rs | 67 +++++---- .../language_models/src/provider/deepseek.rs | 66 +++------ crates/language_models/src/provider/google.rs | 80 ++++------ .../language_models/src/provider/mistral.rs | 137 +++++------------- crates/language_models/src/provider/ollama.rs | 52 ++----- .../language_models/src/provider/open_ai.rs | 72 +++------ .../src/provider/open_router.rs | 74 +++------- crates/language_models/src/provider/vercel.rs | 74 +++------- crates/language_models/src/provider/x_ai.rs | 74 +++------- crates/language_models/src/ui.rs | 2 + .../src/ui/configured_api_card.rs | 86 +++++++++++ 13 files changed, 370 insertions(+), 568 deletions(-) create mode 100644 crates/language_models/src/ui/configured_api_card.rs diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index 86cc81cb7bc7b89967e69949ced891a7cb845cea..287c76fc6dfea530ce53b48178024ef185b98134 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -20,13 +20,13 @@ use std::pin::Pin; use std::str::FromStr; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; -use ui::{Icon, IconName, List, Tooltip, prelude::*}; +use ui::{List, prelude::*}; use ui_input::InputField; use util::ResultExt; use zed_env_vars::{EnvVar, env_var}; use crate::api_key::ApiKeyState; -use crate::ui::InstructionListItem; +use crate::ui::{ConfiguredApiCard, InstructionListItem}; pub use settings::AnthropicAvailableModel as AvailableModel; @@ -909,9 +909,21 @@ impl ConfigurationView { impl Render for ConfigurationView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); + let configured_card_label = if env_var_set { + format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") + } else { + let api_url = AnthropicLanguageModelProvider::api_url(cx); + if api_url == ANTHROPIC_API_URL { + "API key configured".to_string() + } else { + format!("API key configured for {}", api_url) + } + }; if self.load_credentials_task.is_some() { - div().child(Label::new("Loading credentials...")).into_any() + div() + .child(Label::new("Loading credentials...")) + .into_any_element() } else if self.should_render_editor(cx) { v_flex() .size_full() @@ -941,56 +953,17 @@ impl Render for ConfigurationView { .size(LabelSize::Small) .color(Color::Muted), ) - .into_any() + .into_any_element() } else { - h_flex() - .mt_1() - .p_1() - .justify_between() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().background) - .child( - h_flex() - .flex_1() - .min_w_0() - .gap_1() - .child(Icon::new(IconName::Check).color(Color::Success)) - .child( - div() - .w_full() - .overflow_x_hidden() - .text_ellipsis() - .child(Label::new(if env_var_set { - format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") - } else { - let api_url = AnthropicLanguageModelProvider::api_url(cx); - if api_url == ANTHROPIC_API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", api_url) - } - })) - ), - ) - .child( - h_flex() - .flex_shrink_0() - .child( - Button::new("reset-key", "Reset Key") - .label_size(LabelSize::Small) - .icon(Some(IconName::Trash)) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .disabled(env_var_set) - .when(env_var_set, |this| { - this.tooltip(Tooltip::text(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))) - }) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), - ), - ) - .into_any() + ConfiguredApiCard::new(configured_card_label) + .disabled(env_var_set) + .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) + .when(env_var_set, |this| { + this.tooltip_label(format!( + "To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable." + )) + }) + .into_any_element() } } } diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index 5699dd8e6693c26bd62f65fb160e0e30a62dda63..14dd575f23952ee732c5d9714d2e091cf50d606f 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -2,7 +2,7 @@ use std::pin::Pin; use std::str::FromStr; use std::sync::Arc; -use crate::ui::InstructionListItem; +use crate::ui::{ConfiguredApiCard, InstructionListItem}; use anyhow::{Context as _, Result, anyhow}; use aws_config::stalled_stream_protection::StalledStreamProtectionConfig; use aws_config::{BehaviorVersion, Region}; @@ -41,7 +41,7 @@ use serde_json::Value; use settings::{BedrockAvailableModel as AvailableModel, Settings, SettingsStore}; use smol::lock::OnceCell; use strum::{EnumIter, IntoEnumIterator, IntoStaticStr}; -use ui::{Icon, IconName, List, Tooltip, prelude::*}; +use ui::{List, prelude::*}; use ui_input::InputField; use util::ResultExt; @@ -1155,47 +1155,37 @@ impl Render for ConfigurationView { return div().child(Label::new("Loading credentials...")).into_any(); } + let configured_label = if env_var_set { + format!( + "Access Key ID is set in {ZED_BEDROCK_ACCESS_KEY_ID_VAR}, Secret Key is set in {ZED_BEDROCK_SECRET_ACCESS_KEY_VAR}, Region is set in {ZED_BEDROCK_REGION_VAR} environment variables." + ) + } else { + match bedrock_method { + Some(BedrockAuthMethod::Automatic) => "You are using automatic credentials.".into(), + Some(BedrockAuthMethod::NamedProfile) => "You are using named profile.".into(), + Some(BedrockAuthMethod::SingleSignOn) => { + "You are using a single sign on profile.".into() + } + None => "You are using static credentials.".into(), + } + }; + + let tooltip_label = if env_var_set { + Some(format!( + "To reset your credentials, unset the {ZED_BEDROCK_ACCESS_KEY_ID_VAR}, {ZED_BEDROCK_SECRET_ACCESS_KEY_VAR}, and {ZED_BEDROCK_REGION_VAR} environment variables." + )) + } else if bedrock_method.is_some() { + Some("You cannot reset credentials as they're being derived, check Zed settings to understand how.".to_string()) + } else { + None + }; + if self.should_render_editor(cx) { - return h_flex() - .mt_1() - .p_1() - .justify_between() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().background) - .child( - h_flex() - .gap_1() - .child(Icon::new(IconName::Check).color(Color::Success)) - .child(Label::new(if env_var_set { - format!("Access Key ID is set in {ZED_BEDROCK_ACCESS_KEY_ID_VAR}, Secret Key is set in {ZED_BEDROCK_SECRET_ACCESS_KEY_VAR}, Region is set in {ZED_BEDROCK_REGION_VAR} environment variables.") - } else { - match bedrock_method { - Some(BedrockAuthMethod::Automatic) => "You are using automatic credentials".into(), - Some(BedrockAuthMethod::NamedProfile) => { - "You are using named profile".into() - }, - Some(BedrockAuthMethod::SingleSignOn) => "You are using a single sign on profile".into(), - None => "You are using static credentials".into(), - } - })), - ) - .child( - Button::new("reset-key", "Reset Key") - .icon(Some(IconName::Trash)) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .disabled(env_var_set || bedrock_method.is_some()) - .when(env_var_set, |this| { - this.tooltip(Tooltip::text(format!("To reset your credentials, unset the {ZED_BEDROCK_ACCESS_KEY_ID_VAR}, {ZED_BEDROCK_SECRET_ACCESS_KEY_VAR}, and {ZED_BEDROCK_REGION_VAR} environment variables."))) - }) - .when(bedrock_method.is_some(), |this| { - this.tooltip(Tooltip::text("You cannot reset credentials as they're being derived, check Zed settings to understand how")) - }) - .on_click(cx.listener(|this, _, window, cx| this.reset_credentials(window, cx))), - ) - .into_any(); + return ConfiguredApiCard::new(configured_label) + .disabled(env_var_set || bedrock_method.is_some()) + .on_click(cx.listener(|this, _, window, cx| this.reset_credentials(window, cx))) + .when_some(tooltip_label, |this, label| this.tooltip_label(label)) + .into_any_element(); } v_flex() @@ -1241,7 +1231,7 @@ impl Render for ConfigurationView { } impl ConfigurationView { - fn render_static_credentials_ui(&self) -> AnyElement { + fn render_static_credentials_ui(&self) -> impl IntoElement { v_flex() .my_2() .gap_1p5() @@ -1278,6 +1268,5 @@ impl ConfigurationView { .child(self.secret_access_key_editor.clone()) .child(self.session_token_editor.clone()) .child(self.region_editor.clone()) - .into_any_element() } } diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index 6c665a0c1f06aa44e2b86f96517f7998fc02f4d3..0d95120322a592f1732aa53b3470108ccde76473 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -29,6 +29,8 @@ use settings::SettingsStore; use ui::{CommonAnimationExt, prelude::*}; use util::debug_panic; +use crate::ui::ConfiguredApiCard; + const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("copilot_chat"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("GitHub Copilot Chat"); @@ -1326,27 +1328,12 @@ impl ConfigurationView { impl Render for ConfigurationView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { if self.state.read(cx).is_authenticated(cx) { - h_flex() - .mt_1() - .p_1() - .justify_between() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().background) - .child( - h_flex() - .gap_1() - .child(Icon::new(IconName::Check).color(Color::Success)) - .child(Label::new("Authorized")), - ) - .child( - Button::new("sign_out", "Sign Out") - .label_size(LabelSize::Small) - .on_click(|_, window, cx| { - window.dispatch_action(copilot::SignOut.boxed_clone(), cx); - }), - ) + ConfiguredApiCard::new("Authorized") + .button_label("Sign Out") + .on_click(|_, window, cx| { + window.dispatch_action(copilot::SignOut.boxed_clone(), cx); + }) + .into_any_element() } else { let loading_icon = Icon::new(IconName::ArrowCircle).with_rotate_animation(4); @@ -1357,37 +1344,49 @@ impl Render for ConfigurationView { Status::Starting { task: _ } => h_flex() .gap_2() .child(loading_icon) - .child(Label::new("Starting Copilot…")), + .child(Label::new("Starting Copilot…")) + .into_any_element(), Status::SigningIn { prompt: _ } | Status::SignedOut { awaiting_signing_in: true, } => h_flex() .gap_2() .child(loading_icon) - .child(Label::new("Signing into Copilot…")), + .child(Label::new("Signing into Copilot…")) + .into_any_element(), Status::Error(_) => { const LABEL: &str = "Copilot had issues starting. Please try restarting it. If the issue persists, try reinstalling Copilot."; v_flex() .gap_6() .child(Label::new(LABEL)) .child(svg().size_8().path(IconName::CopilotError.path())) + .into_any_element() } _ => { const LABEL: &str = "To use Zed's agent with GitHub Copilot, you need to be logged in to GitHub. Note that your GitHub account must have an active Copilot Chat subscription."; - v_flex().gap_2().child(Label::new(LABEL)).child( - Button::new("sign_in", "Sign in to use GitHub Copilot") - .full_width() - .style(ButtonStyle::Outlined) - .icon_color(Color::Muted) - .icon(IconName::Github) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .on_click(|_, window, cx| copilot::initiate_sign_in(window, cx)), - ) + v_flex() + .gap_2() + .child(Label::new(LABEL)) + .child( + Button::new("sign_in", "Sign in to use GitHub Copilot") + .full_width() + .style(ButtonStyle::Outlined) + .icon_color(Color::Muted) + .icon(IconName::Github) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .on_click(|_, window, cx| { + copilot::initiate_sign_in(window, cx) + }), + ) + .into_any_element() } }, - None => v_flex().gap_6().child(Label::new(ERROR_LABEL)), + None => v_flex() + .gap_6() + .child(Label::new(ERROR_LABEL)) + .into_any_element(), } } } diff --git a/crates/language_models/src/provider/deepseek.rs b/crates/language_models/src/provider/deepseek.rs index 103d068d671acf331fbba73072e3edf2fcc10411..1d573fd964d0f183393bb766c492566f622a4901 100644 --- a/crates/language_models/src/provider/deepseek.rs +++ b/crates/language_models/src/provider/deepseek.rs @@ -19,11 +19,12 @@ use std::pin::Pin; use std::str::FromStr; use std::sync::{Arc, LazyLock}; -use ui::{Icon, IconName, List, prelude::*}; +use ui::{List, prelude::*}; use ui_input::InputField; use util::ResultExt; use zed_env_vars::{EnvVar, env_var}; +use crate::ui::ConfiguredApiCard; use crate::{api_key::ApiKeyState, ui::InstructionListItem}; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("deepseek"); @@ -601,9 +602,21 @@ impl ConfigurationView { impl Render for ConfigurationView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); + let configured_card_label = if env_var_set { + format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") + } else { + let api_url = DeepSeekLanguageModelProvider::api_url(cx); + if api_url == DEEPSEEK_API_URL { + "API key configured".to_string() + } else { + format!("API key configured for {}", api_url) + } + }; if self.load_credentials_task.is_some() { - div().child(Label::new("Loading credentials...")).into_any() + div() + .child(Label::new("Loading credentials...")) + .into_any_element() } else if self.should_render_editor(cx) { v_flex() .size_full() @@ -628,51 +641,12 @@ impl Render for ConfigurationView { .size(LabelSize::Small) .color(Color::Muted), ) - .into_any() + .into_any_element() } else { - h_flex() - .mt_1() - .p_1() - .justify_between() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().background) - .child( - h_flex() - .flex_1() - .min_w_0() - .gap_1() - .child(Icon::new(IconName::Check).color(Color::Success)) - .child(div().w_full().overflow_x_hidden().text_ellipsis().child( - Label::new(if env_var_set { - format!( - "API key set in {API_KEY_ENV_VAR_NAME} environment variable" - ) - } else { - let api_url = DeepSeekLanguageModelProvider::api_url(cx); - if api_url == DEEPSEEK_API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", api_url) - } - }), - )), - ) - .child( - h_flex().flex_shrink_0().child( - Button::new("reset-key", "Reset Key") - .label_size(LabelSize::Small) - .icon(Some(IconName::Trash)) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .disabled(env_var_set) - .on_click( - cx.listener(|this, _, window, cx| this.reset_api_key(window, cx)), - ), - ), - ) - .into_any() + ConfiguredApiCard::new(configured_card_label) + .disabled(env_var_set) + .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) + .into_any_element() } } } diff --git a/crates/language_models/src/provider/google.rs b/crates/language_models/src/provider/google.rs index a9c97ca939ac55c621bf38b62736af91ab2211ee..e33b118e30fca60e147bd2f311e844626da9b368 100644 --- a/crates/language_models/src/provider/google.rs +++ b/crates/language_models/src/provider/google.rs @@ -28,14 +28,14 @@ use std::sync::{ atomic::{self, AtomicU64}, }; use strum::IntoEnumIterator; -use ui::{Icon, IconName, List, Tooltip, prelude::*}; +use ui::{List, prelude::*}; use ui_input::InputField; use util::ResultExt; use zed_env_vars::EnvVar; use crate::api_key::ApiKey; use crate::api_key::ApiKeyState; -use crate::ui::InstructionListItem; +use crate::ui::{ConfiguredApiCard, InstructionListItem}; const PROVIDER_ID: LanguageModelProviderId = language_model::GOOGLE_PROVIDER_ID; const PROVIDER_NAME: LanguageModelProviderName = language_model::GOOGLE_PROVIDER_NAME; @@ -835,9 +835,24 @@ impl ConfigurationView { impl Render for ConfigurationView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); + let configured_card_label = if env_var_set { + format!( + "API key set in {} environment variable", + API_KEY_ENV_VAR.name + ) + } else { + let api_url = GoogleLanguageModelProvider::api_url(cx); + if api_url == google_ai::API_URL { + "API key configured".to_string() + } else { + format!("API key configured for {}", api_url) + } + }; if self.load_credentials_task.is_some() { - div().child(Label::new("Loading credentials...")).into_any() + div() + .child(Label::new("Loading credentials...")) + .into_any_element() } else if self.should_render_editor(cx) { v_flex() .size_full() @@ -864,58 +879,15 @@ impl Render for ConfigurationView { ) .size(LabelSize::Small).color(Color::Muted), ) - .into_any() + .into_any_element() } else { - h_flex() - .mt_1() - .p_1() - .justify_between() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().background) - .child( - h_flex() - .flex_1() - .min_w_0() - .gap_1() - .child(Icon::new(IconName::Check).color(Color::Success)) - .child( - div() - .w_full() - .overflow_x_hidden() - .text_ellipsis() - .child(Label::new( - if env_var_set { - format!("API key set in {} environment variable", API_KEY_ENV_VAR.name) - } else { - let api_url = GoogleLanguageModelProvider::api_url(cx); - if api_url == google_ai::API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", api_url) - } - } - )) - ), - ) - .child( - h_flex() - .flex_shrink_0() - .child( - Button::new("reset-key", "Reset Key") - .label_size(LabelSize::Small) - .icon(Some(IconName::Trash)) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .disabled(env_var_set) - .when(env_var_set, |this| { - this.tooltip(Tooltip::text(format!("To reset your API key, make sure {GEMINI_API_KEY_VAR_NAME} and {GOOGLE_AI_API_KEY_VAR_NAME} environment variables are unset."))) - }) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), - ), - ) - .into_any() + ConfiguredApiCard::new(configured_card_label) + .disabled(env_var_set) + .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) + .when(env_var_set, |this| { + this.tooltip_label(format!("To reset your API key, make sure {GEMINI_API_KEY_VAR_NAME} and {GOOGLE_AI_API_KEY_VAR_NAME} environment variables are unset.")) + }) + .into_any_element() } } } diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index e0bfa5d8eb66ec4ec390bc534ceebe0663610197..2d30dfca21d8cbc4fd1be3575801919148f705b3 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -19,11 +19,12 @@ use std::pin::Pin; use std::str::FromStr; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; -use ui::{Icon, IconName, List, Tooltip, prelude::*}; +use ui::{List, prelude::*}; use ui_input::InputField; use util::ResultExt; use zed_env_vars::{EnvVar, env_var}; +use crate::ui::ConfiguredApiCard; use crate::{api_key::ApiKeyState, ui::InstructionListItem}; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("mistral"); @@ -883,6 +884,12 @@ impl ConfigurationView { let key_state = &self.state.read(cx).codestral_api_key_state; let should_show_editor = !key_state.has_key(); let env_var_set = key_state.is_from_env_var(); + let configured_card_label = if env_var_set { + format!("API key set in {CODESTRAL_API_KEY_ENV_VAR_NAME} environment variable") + } else { + "Codestral API key configured".to_string() + }; + if should_show_editor { v_flex() .id("codestral") @@ -910,42 +917,19 @@ impl ConfigurationView { .size(LabelSize::Small).color(Color::Muted), ).into_any() } else { - h_flex() - .id("codestral") - .mt_2() - .p_1() - .justify_between() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().background) - .child( - h_flex() - .gap_1() - .child(Icon::new(IconName::Check).color(Color::Success)) - .child(Label::new(if env_var_set { - format!("API key set in {CODESTRAL_API_KEY_ENV_VAR_NAME} environment variable") - } else { - "Codestral API key configured".to_string() - })), + ConfiguredApiCard::new(configured_card_label) + .disabled(env_var_set) + .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) + .when(env_var_set, |this| { + this.tooltip_label(format!( + "To reset your API key, \ + unset the {CODESTRAL_API_KEY_ENV_VAR_NAME} environment variable." + )) + }) + .on_click( + cx.listener(|this, _, window, cx| this.reset_codestral_api_key(window, cx)), ) - .child( - Button::new("reset-key", "Reset Key") - .label_size(LabelSize::Small) - .icon(Some(IconName::Trash)) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .disabled(env_var_set) - .when(env_var_set, |this| { - this.tooltip(Tooltip::text(format!( - "To reset your API key, \ - unset the {CODESTRAL_API_KEY_ENV_VAR_NAME} environment variable." - ))) - }) - .on_click( - cx.listener(|this, _, window, cx| this.reset_codestral_api_key(window, cx)), - ), - ).into_any() + .into_any_element() } } } @@ -953,6 +937,16 @@ impl ConfigurationView { impl Render for ConfigurationView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); + let configured_card_label = if env_var_set { + format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") + } else { + let api_url = MistralLanguageModelProvider::api_url(cx); + if api_url == MISTRAL_API_URL { + "API key configured".to_string() + } else { + format!("API key configured for {}", api_url) + } + }; if self.load_credentials_task.is_some() { div().child(Label::new("Loading credentials...")).into_any() @@ -987,68 +981,17 @@ impl Render for ConfigurationView { } else { v_flex() .size_full() + .gap_1() .child( - h_flex() - .mt_1() - .p_1() - .justify_between() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().background) - .child( - h_flex() - .flex_1() - .min_w_0() - .gap_1() - .child(Icon::new(IconName::Check).color(Color::Success)) - .child( - div() - .w_full() - .overflow_x_hidden() - .text_ellipsis() - .child( - Label::new( - if env_var_set { - format!( - "API key set in {API_KEY_ENV_VAR_NAME} environment variable" - ) - } else { - let api_url = MistralLanguageModelProvider::api_url(cx); - if api_url == MISTRAL_API_URL { - "API key configured".to_string() - } else { - format!( - "API key configured for {}", - api_url - ) - } - } - ) - ), - ), - ) - .child( - h_flex() - .flex_shrink_0() - .child( - Button::new("reset-key", "Reset Key") - .label_size(LabelSize::Small) - .icon(Some(IconName::Trash)) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .disabled(env_var_set) - .when(env_var_set, |this| { - this.tooltip(Tooltip::text(format!( - "To reset your API key, \ - unset the {API_KEY_ENV_VAR_NAME} environment variable." - ))) - }) - .on_click(cx.listener(|this, _, window, cx| { - this.reset_api_key(window, cx) - })), - ), - ), + ConfiguredApiCard::new(configured_card_label) + .disabled(env_var_set) + .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) + .when(env_var_set, |this| { + this.tooltip_label(format!( + "To reset your API key, \ + unset the {API_KEY_ENV_VAR_NAME} environment variable." + )) + }), ) .child(self.render_codestral_api_key_editor(cx)) .into_any() diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs index 6341baa6f36db36a180d14c957b49dadd901e9a0..a0aada7d1a7b557e1e5aa07f19dd3e38492fc972 100644 --- a/crates/language_models/src/provider/ollama.rs +++ b/crates/language_models/src/provider/ollama.rs @@ -28,7 +28,7 @@ use zed_env_vars::{EnvVar, env_var}; use crate::AllLanguageModelSettings; use crate::api_key::ApiKeyState; -use crate::ui::InstructionListItem; +use crate::ui::{ConfiguredApiCard, InstructionListItem}; const OLLAMA_DOWNLOAD_URL: &str = "https://ollama.com/download"; const OLLAMA_LIBRARY_URL: &str = "https://ollama.com/library"; @@ -749,9 +749,14 @@ impl ConfigurationView { )) } - fn render_api_key_editor(&self, cx: &Context) -> Div { + fn render_api_key_editor(&self, cx: &Context) -> impl IntoElement { let state = self.state.read(cx); let env_var_set = state.api_key_state.is_from_env_var(); + let configured_card_label = if env_var_set { + format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable.") + } else { + "API key configured".to_string() + }; if !state.api_key_state.has_key() { v_flex() @@ -764,40 +769,15 @@ impl ConfigurationView { .size(LabelSize::Small) .color(Color::Muted), ) + .into_any_element() } else { - h_flex() - .p_3() - .justify_between() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().elevated_surface_background) - .child( - h_flex() - .gap_2() - .child(Icon::new(IconName::Check).color(Color::Success)) - .child( - Label::new( - if env_var_set { - format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable.") - } else { - "API key configured".to_string() - } - ) - ) - ) - .child( - Button::new("reset-api-key", "Reset API Key") - .label_size(LabelSize::Small) - .icon(IconName::Undo) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .layer(ElevationIndex::ModalSurface) - .when(env_var_set, |this| { - this.tooltip(Tooltip::text(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))) - }) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), - ) + ConfiguredApiCard::new(configured_card_label) + .disabled(env_var_set) + .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) + .when(env_var_set, |this| { + this.tooltip_label(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable.")) + }) + .into_any_element() } } @@ -909,7 +889,7 @@ impl Render for ConfigurationView { ) .child( IconButton::new("refresh-models", IconName::RotateCcw) - .tooltip(Tooltip::text("Refresh models")) + .tooltip(Tooltip::text("Refresh Models")) .on_click(cx.listener(|this, _, _, cx| { this.state.update(cx, |state, _| { state.fetched_models.clear(); diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index aa925a9b582adae5c1ade0b9cdce6765e8614cb6..cabd78c35be58667fd799fe34de07e1d1bfa5808 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -20,11 +20,12 @@ use std::pin::Pin; use std::str::FromStr as _; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; -use ui::{ElevationIndex, List, Tooltip, prelude::*}; +use ui::{List, prelude::*}; use ui_input::InputField; use util::ResultExt; use zed_env_vars::{EnvVar, env_var}; +use crate::ui::ConfiguredApiCard; use crate::{api_key::ApiKeyState, ui::InstructionListItem}; const PROVIDER_ID: LanguageModelProviderId = language_model::OPEN_AI_PROVIDER_ID; @@ -762,6 +763,16 @@ impl ConfigurationView { impl Render for ConfigurationView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); + let configured_card_label = if env_var_set { + format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") + } else { + let api_url = OpenAiLanguageModelProvider::api_url(cx); + if api_url == OPEN_AI_API_URL { + "API key configured".to_string() + } else { + format!("API key configured for {}", api_url) + } + }; let api_key_section = if self.should_render_editor(cx) { v_flex() @@ -795,58 +806,15 @@ impl Render for ConfigurationView { ) .size(LabelSize::Small).color(Color::Muted), ) - .into_any() + .into_any_element() } else { - h_flex() - .mt_1() - .p_1() - .justify_between() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().background) - .child( - h_flex() - .flex_1() - .min_w_0() - .gap_1() - .child(Icon::new(IconName::Check).color(Color::Success)) - .child( - div() - .w_full() - .overflow_x_hidden() - .text_ellipsis() - .child(Label::new( - if env_var_set { - format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") - } else { - let api_url = OpenAiLanguageModelProvider::api_url(cx); - if api_url == OPEN_AI_API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", api_url) - } - } - )) - ), - ) - .child( - h_flex() - .flex_shrink_0() - .child( - Button::new("reset-api-key", "Reset API Key") - .label_size(LabelSize::Small) - .icon(IconName::Undo) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .layer(ElevationIndex::ModalSurface) - .when(env_var_set, |this| { - this.tooltip(Tooltip::text(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))) - }) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), - ), - ) - .into_any() + ConfiguredApiCard::new(configured_card_label) + .disabled(env_var_set) + .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) + .when(env_var_set, |this| { + this.tooltip_label(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable.")) + }) + .into_any_element() }; let compatible_api_section = h_flex() diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index 0cc3711489ab25b80c9e995558f18917fbfad343..6326968a916a7d6a21811ee22c328564e1ec4682 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -17,11 +17,12 @@ use settings::{OpenRouterAvailableModel as AvailableModel, Settings, SettingsSto use std::pin::Pin; use std::str::FromStr as _; use std::sync::{Arc, LazyLock}; -use ui::{Icon, IconName, List, Tooltip, prelude::*}; +use ui::{List, prelude::*}; use ui_input::InputField; use util::ResultExt; use zed_env_vars::{EnvVar, env_var}; +use crate::ui::ConfiguredApiCard; use crate::{api_key::ApiKeyState, ui::InstructionListItem}; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("openrouter"); @@ -777,9 +778,21 @@ impl ConfigurationView { impl Render for ConfigurationView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); + let configured_card_label = if env_var_set { + format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") + } else { + let api_url = OpenRouterLanguageModelProvider::api_url(cx); + if api_url == OPEN_ROUTER_API_URL { + "API key configured".to_string() + } else { + format!("API key configured for {}", api_url) + } + }; if self.load_credentials_task.is_some() { - div().child(Label::new("Loading credentials...")).into_any() + div() + .child(Label::new("Loading credentials...")) + .into_any_element() } else if self.should_render_editor(cx) { v_flex() .size_full() @@ -806,56 +819,15 @@ impl Render for ConfigurationView { ) .size(LabelSize::Small).color(Color::Muted), ) - .into_any() + .into_any_element() } else { - h_flex() - .mt_1() - .p_1() - .justify_between() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().background) - .child( - h_flex() - .flex_1() - .min_w_0() - .gap_1() - .child(Icon::new(IconName::Check).color(Color::Success)) - .child( - div() - .w_full() - .overflow_x_hidden() - .text_ellipsis() - .child(Label::new(if env_var_set { - format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") - } else { - let api_url = OpenRouterLanguageModelProvider::api_url(cx); - if api_url == OPEN_ROUTER_API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", api_url) - } - })) - ), - ) - .child( - h_flex() - .flex_shrink_0() - .child( - Button::new("reset-key", "Reset Key") - .label_size(LabelSize::Small) - .icon(Some(IconName::Trash)) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .disabled(env_var_set) - .when(env_var_set, |this| { - this.tooltip(Tooltip::text(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))) - }) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), - ), - ) - .into_any() + ConfiguredApiCard::new(configured_card_label) + .disabled(env_var_set) + .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) + .when(env_var_set, |this| { + this.tooltip_label(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable.")) + }) + .into_any_element() } } } diff --git a/crates/language_models/src/provider/vercel.rs b/crates/language_models/src/provider/vercel.rs index 9adc794ceaf255756736b401fff45f1131d4b310..20db24274aae0249efcfc897cb1bdfdcce8f1220 100644 --- a/crates/language_models/src/provider/vercel.rs +++ b/crates/language_models/src/provider/vercel.rs @@ -14,13 +14,16 @@ pub use settings::VercelAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; -use ui::{ElevationIndex, List, Tooltip, prelude::*}; +use ui::{List, prelude::*}; use ui_input::InputField; use util::ResultExt; use vercel::{Model, VERCEL_API_URL}; use zed_env_vars::{EnvVar, env_var}; -use crate::{api_key::ApiKeyState, ui::InstructionListItem}; +use crate::{ + api_key::ApiKeyState, + ui::{ConfiguredApiCard, InstructionListItem}, +}; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("vercel"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("Vercel"); @@ -448,6 +451,16 @@ impl ConfigurationView { impl Render for ConfigurationView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); + let configured_card_label = if env_var_set { + format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") + } else { + let api_url = VercelLanguageModelProvider::api_url(cx); + if api_url == VERCEL_API_URL { + "API key configured".to_string() + } else { + format!("API key configured for {}", api_url) + } + }; let api_key_section = if self.should_render_editor(cx) { v_flex() @@ -477,56 +490,15 @@ impl Render for ConfigurationView { .size(LabelSize::Small) .color(Color::Muted), ) - .into_any() + .into_any_element() } else { - h_flex() - .mt_1() - .p_1() - .justify_between() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().background) - .child( - h_flex() - .flex_1() - .min_w_0() - .gap_1() - .child(Icon::new(IconName::Check).color(Color::Success)) - .child( - div() - .w_full() - .overflow_x_hidden() - .text_ellipsis() - .child(Label::new(if env_var_set { - format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") - } else { - let api_url = VercelLanguageModelProvider::api_url(cx); - if api_url == VERCEL_API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", api_url) - } - })) - ), - ) - .child( - h_flex() - .flex_shrink_0() - .child( - Button::new("reset-api-key", "Reset API Key") - .label_size(LabelSize::Small) - .icon(IconName::Undo) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .layer(ElevationIndex::ModalSurface) - .when(env_var_set, |this| { - this.tooltip(Tooltip::text(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))) - }) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), - ), - ) - .into_any() + ConfiguredApiCard::new(configured_card_label) + .disabled(env_var_set) + .when(env_var_set, |this| { + this.tooltip_label(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable.")) + }) + .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) + .into_any_element() }; if self.load_credentials_task.is_some() { diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs index 979824442c6d03a8f735448003425e94a83e46ea..e7ee71ba86e202fe17d567923f4b04d3c886ae08 100644 --- a/crates/language_models/src/provider/x_ai.rs +++ b/crates/language_models/src/provider/x_ai.rs @@ -14,13 +14,16 @@ pub use settings::XaiAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; -use ui::{ElevationIndex, List, Tooltip, prelude::*}; +use ui::{List, prelude::*}; use ui_input::InputField; use util::ResultExt; use x_ai::{Model, XAI_API_URL}; use zed_env_vars::{EnvVar, env_var}; -use crate::{api_key::ApiKeyState, ui::InstructionListItem}; +use crate::{ + api_key::ApiKeyState, + ui::{ConfiguredApiCard, InstructionListItem}, +}; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("x_ai"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("xAI"); @@ -445,6 +448,16 @@ impl ConfigurationView { impl Render for ConfigurationView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); + let configured_card_label = if env_var_set { + format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") + } else { + let api_url = XAiLanguageModelProvider::api_url(cx); + if api_url == XAI_API_URL { + "API key configured".to_string() + } else { + format!("API key configured for {}", api_url) + } + }; let api_key_section = if self.should_render_editor(cx) { v_flex() @@ -474,56 +487,15 @@ impl Render for ConfigurationView { .size(LabelSize::Small) .color(Color::Muted), ) - .into_any() + .into_any_element() } else { - h_flex() - .mt_1() - .p_1() - .justify_between() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().background) - .child( - h_flex() - .flex_1() - .min_w_0() - .gap_1() - .child(Icon::new(IconName::Check).color(Color::Success)) - .child( - div() - .w_full() - .overflow_x_hidden() - .text_ellipsis() - .child(Label::new(if env_var_set { - format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") - } else { - let api_url = XAiLanguageModelProvider::api_url(cx); - if api_url == XAI_API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", api_url) - } - })) - ), - ) - .child( - h_flex() - .flex_shrink_0() - .child( - Button::new("reset-api-key", "Reset API Key") - .label_size(LabelSize::Small) - .icon(IconName::Undo) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .layer(ElevationIndex::ModalSurface) - .when(env_var_set, |this| { - this.tooltip(Tooltip::text(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))) - }) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), - ), - ) - .into_any() + ConfiguredApiCard::new(configured_card_label) + .disabled(env_var_set) + .when(env_var_set, |this| { + this.tooltip_label(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable.")) + }) + .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) + .into_any_element() }; if self.load_credentials_task.is_some() { diff --git a/crates/language_models/src/ui.rs b/crates/language_models/src/ui.rs index 80321656007ab3dfa19a5171d5bead18c9a5cc99..1d7796ecc2b6c2a78b3ebc02dc9cd29bd8cfa2c6 100644 --- a/crates/language_models/src/ui.rs +++ b/crates/language_models/src/ui.rs @@ -1,2 +1,4 @@ +pub mod configured_api_card; pub mod instruction_list_item; +pub use configured_api_card::ConfiguredApiCard; pub use instruction_list_item::InstructionListItem; diff --git a/crates/language_models/src/ui/configured_api_card.rs b/crates/language_models/src/ui/configured_api_card.rs new file mode 100644 index 0000000000000000000000000000000000000000..063ac1717f3aa5de1a448e26c94df7530fec588f --- /dev/null +++ b/crates/language_models/src/ui/configured_api_card.rs @@ -0,0 +1,86 @@ +use gpui::{ClickEvent, IntoElement, ParentElement, SharedString}; +use ui::{Tooltip, prelude::*}; + +#[derive(IntoElement)] +pub struct ConfiguredApiCard { + label: SharedString, + button_label: Option, + tooltip_label: Option, + disabled: bool, + on_click: Option>, +} + +impl ConfiguredApiCard { + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + button_label: None, + tooltip_label: None, + disabled: false, + on_click: None, + } + } + + pub fn on_click( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_click = Some(Box::new(handler)); + self + } + + pub fn button_label(mut self, button_label: impl Into) -> Self { + self.button_label = Some(button_label.into()); + self + } + + pub fn tooltip_label(mut self, tooltip_label: impl Into) -> Self { + self.tooltip_label = Some(tooltip_label.into()); + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } +} + +impl RenderOnce for ConfiguredApiCard { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let button_label = self.button_label.unwrap_or("Reset Key".into()); + let button_id = SharedString::new(format!("id-{}", button_label)); + + h_flex() + .mt_0p5() + .p_1() + .justify_between() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().background) + .child( + h_flex() + .flex_1() + .min_w_0() + .gap_1() + .child(Icon::new(IconName::Check).color(Color::Success)) + .child(Label::new(self.label).truncate()), + ) + .child( + Button::new(button_id, button_label) + .label_size(LabelSize::Small) + .icon(IconName::Undo) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .disabled(self.disabled) + .when_some(self.tooltip_label, |this, label| { + this.tooltip(Tooltip::text(label)) + }) + .when_some( + self.on_click.filter(|_| !self.disabled), + |this, on_click| this.on_click(on_click), + ), + ) + } +} From 6db6251484eca693456e2c550c64230e563128bb Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Sun, 9 Nov 2025 14:32:19 -0300 Subject: [PATCH 17/19] agent_ui: Fix external agent icons in configuration view (#42313) This PR makes the icons for external agents in the configuration view use `from_external_svg` instead of `from_path`. Release Notes: - N/A --- crates/agent_ui/src/agent_configuration.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index ca033ced5c7d2e403b1880cb5d0dd522500fcac4..3cbc6e7145d2e3611cdb229447a8795ffb0301ca 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -1047,7 +1047,7 @@ impl AgentConfiguration { AgentIcon::Name(icon_name) => Icon::new(icon_name) .size(IconSize::Small) .color(Color::Muted), - AgentIcon::Path(icon_path) => Icon::from_path(icon_path) + AgentIcon::Path(icon_path) => Icon::from_external_svg(icon_path) .size(IconSize::Small) .color(Color::Muted), }; From 431a195c32da08590ace304eb73fd9b0754d5375 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Sun, 9 Nov 2025 20:44:07 +0100 Subject: [PATCH 18/19] acp: Fix issue with mentions when `embedded_context` is set to `false` (#42260) Release Notes: - acp: Fixed an issue where Zed would not respect `PromptCapabilities::embedded_context` --- crates/agent_ui/src/acp/message_editor.rs | 305 ++++++++++++++-------- 1 file changed, 199 insertions(+), 106 deletions(-) diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index 8259cc5b0dc6076a4e636e7da0879324bbc2461a..780d59f13fcaded6665d46bb1c46707a8edf2807 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -356,7 +356,7 @@ impl MessageEditor { let task = match mention_uri.clone() { MentionUri::Fetch { url } => self.confirm_mention_for_fetch(url, cx), - MentionUri::Directory { .. } => Task::ready(Ok(Mention::UriOnly)), + MentionUri::Directory { .. } => Task::ready(Ok(Mention::Link)), MentionUri::Thread { id, .. } => self.confirm_mention_for_thread(id, cx), MentionUri::TextThread { path, .. } => self.confirm_mention_for_text_thread(path, cx), MentionUri::File { abs_path } => self.confirm_mention_for_file(abs_path, cx), @@ -373,7 +373,6 @@ impl MessageEditor { ))) } MentionUri::Selection { .. } => { - // Handled elsewhere debug_panic!("unexpected selection URI"); Task::ready(Err(anyhow!("unexpected selection URI"))) } @@ -704,13 +703,11 @@ impl MessageEditor { return Task::ready(Err(err)); } - let contents = self.mention_set.contents( - &self.prompt_capabilities.borrow(), - full_mention_content, - self.project.clone(), - cx, - ); + let contents = self + .mention_set + .contents(full_mention_content, self.project.clone(), cx); let editor = self.editor.clone(); + let supports_embedded_context = self.prompt_capabilities.borrow().embedded_context; cx.spawn(async move |_, cx| { let contents = contents.await?; @@ -741,18 +738,32 @@ impl MessageEditor { tracked_buffers, } => { all_tracked_buffers.extend(tracked_buffers.iter().cloned()); - acp::ContentBlock::Resource(acp::EmbeddedResource { - annotations: None, - resource: acp::EmbeddedResourceResource::TextResourceContents( - acp::TextResourceContents { - mime_type: None, - text: content.clone(), - uri: uri.to_uri().to_string(), - meta: None, - }, - ), - meta: None, - }) + if supports_embedded_context { + acp::ContentBlock::Resource(acp::EmbeddedResource { + annotations: None, + resource: + acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents { + mime_type: None, + text: content.clone(), + uri: uri.to_uri().to_string(), + meta: None, + }, + ), + meta: None, + }) + } else { + acp::ContentBlock::ResourceLink(acp::ResourceLink { + name: uri.name(), + uri: uri.to_uri().to_string(), + annotations: None, + description: None, + mime_type: None, + size: None, + title: None, + meta: None, + }) + } } Mention::Image(mention_image) => { let uri = match uri { @@ -774,18 +785,16 @@ impl MessageEditor { meta: None, }) } - Mention::UriOnly => { - acp::ContentBlock::ResourceLink(acp::ResourceLink { - name: uri.name(), - uri: uri.to_uri().to_string(), - annotations: None, - description: None, - mime_type: None, - size: None, - title: None, - meta: None, - }) - } + Mention::Link => acp::ContentBlock::ResourceLink(acp::ResourceLink { + name: uri.name(), + uri: uri.to_uri().to_string(), + annotations: None, + description: None, + mime_type: None, + size: None, + title: None, + meta: None, + }), }; chunks.push(chunk); ix = crease_range.end; @@ -1114,7 +1123,7 @@ impl MessageEditor { let start = text.len(); write!(&mut text, "{}", mention_uri.as_link()).ok(); let end = text.len(); - mentions.push((start..end, mention_uri, Mention::UriOnly)); + mentions.push((start..end, mention_uri, Mention::Link)); } } acp::ContentBlock::Image(acp::ImageContent { @@ -1520,7 +1529,7 @@ pub enum Mention { tracked_buffers: Vec>, }, Image(MentionImage), - UriOnly, + Link, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -1537,21 +1546,10 @@ pub struct MentionSet { impl MentionSet { fn contents( &self, - prompt_capabilities: &acp::PromptCapabilities, full_mention_content: bool, project: Entity, cx: &mut App, ) -> Task>> { - if !prompt_capabilities.embedded_context { - let mentions = self - .mentions - .iter() - .map(|(crease_id, (uri, _))| (*crease_id, (uri.clone(), Mention::UriOnly))) - .collect(); - - return Task::ready(Ok(mentions)); - } - let mentions = self.mentions.clone(); cx.spawn(async move |cx| { let mut contents = HashMap::default(); @@ -2285,21 +2283,11 @@ mod tests { assert_eq!(fold_ranges(editor, cx).len(), 1); }); - let all_prompt_capabilities = acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - meta: None, - }; - let contents = message_editor .update(&mut cx, |message_editor, cx| { - message_editor.mention_set().contents( - &all_prompt_capabilities, - false, - project.clone(), - cx, - ) + message_editor + .mention_set() + .contents(false, project.clone(), cx) }) .await .unwrap() @@ -2317,30 +2305,6 @@ mod tests { ); } - let contents = message_editor - .update(&mut cx, |message_editor, cx| { - message_editor.mention_set().contents( - &acp::PromptCapabilities::default(), - false, - project.clone(), - cx, - ) - }) - .await - .unwrap() - .into_values() - .collect::>(); - - { - let [(uri, Mention::UriOnly)] = contents.as_slice() else { - panic!("Unexpected mentions"); - }; - pretty_assertions::assert_eq!( - uri, - &MentionUri::parse(&url_one, PathStyle::local()).unwrap() - ); - } - cx.simulate_input(" "); editor.update(&mut cx, |editor, cx| { @@ -2376,12 +2340,9 @@ mod tests { let contents = message_editor .update(&mut cx, |message_editor, cx| { - message_editor.mention_set().contents( - &all_prompt_capabilities, - false, - project.clone(), - cx, - ) + message_editor + .mention_set() + .contents(false, project.clone(), cx) }) .await .unwrap() @@ -2502,12 +2463,9 @@ mod tests { let contents = message_editor .update(&mut cx, |message_editor, cx| { - message_editor.mention_set().contents( - &all_prompt_capabilities, - false, - project.clone(), - cx, - ) + message_editor + .mention_set() + .contents(false, project.clone(), cx) }) .await .unwrap() @@ -2553,12 +2511,9 @@ mod tests { // Getting the message contents fails message_editor .update(&mut cx, |message_editor, cx| { - message_editor.mention_set().contents( - &all_prompt_capabilities, - false, - project.clone(), - cx, - ) + message_editor + .mention_set() + .contents(false, project.clone(), cx) }) .await .expect_err("Should fail to load x.png"); @@ -2609,12 +2564,9 @@ mod tests { // Now getting the contents succeeds, because the invalid mention was removed let contents = message_editor .update(&mut cx, |message_editor, cx| { - message_editor.mention_set().contents( - &all_prompt_capabilities, - false, - project.clone(), - cx, - ) + message_editor + .mention_set() + .contents(false, project.clone(), cx) }) .await .unwrap(); @@ -2896,6 +2848,147 @@ mod tests { ); } + #[gpui::test] + async fn test_editor_respects_embedded_context_capability(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + + let file_content = "fn main() { println!(\"Hello, world!\"); }\n"; + + fs.insert_tree( + "/project", + json!({ + "src": { + "main.rs": file_content, + } + }), + ) + .await; + + let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; + + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + + let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| { + let workspace_handle = cx.weak_entity(); + let message_editor = cx.new(|cx| { + MessageEditor::new( + workspace_handle, + project.clone(), + history_store.clone(), + None, + Default::default(), + Default::default(), + "Test Agent".into(), + "Test", + EditorMode::AutoHeight { + max_lines: None, + min_lines: 1, + }, + window, + cx, + ) + }); + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item( + Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))), + true, + true, + None, + window, + cx, + ); + }); + message_editor.read(cx).focus_handle(cx).focus(window); + let editor = message_editor.read(cx).editor().clone(); + (message_editor, editor) + }); + + cx.simulate_input("What is in @file main"); + + editor.update_in(cx, |editor, window, cx| { + assert!(editor.has_visible_completions_menu()); + assert_eq!(editor.text(cx), "What is in @file main"); + editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); + }); + + let content = message_editor + .update(cx, |editor, cx| editor.contents(false, cx)) + .await + .unwrap() + .0; + + let main_rs_uri = if cfg!(windows) { + "file:///C:/project/src/main.rs".to_string() + } else { + "file:///project/src/main.rs".to_string() + }; + + // When embedded context is `false` we should get a resource link + pretty_assertions::assert_eq!( + content, + vec![ + acp::ContentBlock::Text(acp::TextContent { + text: "What is in ".to_string(), + annotations: None, + meta: None + }), + acp::ContentBlock::ResourceLink(acp::ResourceLink { + uri: main_rs_uri.clone(), + name: "main.rs".to_string(), + annotations: None, + meta: None, + description: None, + mime_type: None, + size: None, + title: None, + }) + ] + ); + + message_editor.update(cx, |editor, _cx| { + editor.prompt_capabilities.replace(acp::PromptCapabilities { + embedded_context: true, + ..Default::default() + }) + }); + + let content = message_editor + .update(cx, |editor, cx| editor.contents(false, cx)) + .await + .unwrap() + .0; + + // When embedded context is `true` we should get a resource + pretty_assertions::assert_eq!( + content, + vec![ + acp::ContentBlock::Text(acp::TextContent { + text: "What is in ".to_string(), + annotations: None, + meta: None + }), + acp::ContentBlock::Resource(acp::EmbeddedResource { + resource: acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents { + text: file_content.to_string(), + uri: main_rs_uri, + mime_type: None, + meta: None + } + ), + annotations: None, + meta: None + }) + ] + ); + } + #[gpui::test] async fn test_autoscroll_after_insert_selections(cx: &mut TestAppContext) { init_test(cx); From 0bcf607a283b2bd3c10a6d90d27c48f9bf5407f1 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Sun, 9 Nov 2025 20:45:00 +0100 Subject: [PATCH 19/19] agent_ui: Always allow to include symbols (#42261) We can always include symbols, since we either include a ResourceLink to the symbol (when `PromptCapabilities::embedded_context = false`) or a Resource (when `PromptCapabilities::embedded_context = true`) Release Notes: - Fixed an issue where symbols could not be included when using specific ACP agents --- crates/agent_ui/src/acp/completion_provider.rs | 18 ++++++++---------- crates/agent_ui/src/acp/message_editor.rs | 2 ++ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/agent_ui/src/acp/completion_provider.rs b/crates/agent_ui/src/acp/completion_provider.rs index 583d8070d98697f4620bf45a3284d88760ebf9e7..84d75ebe4133b3145b892eec659867b137bce2f0 100644 --- a/crates/agent_ui/src/acp/completion_provider.rs +++ b/crates/agent_ui/src/acp/completion_provider.rs @@ -646,16 +646,14 @@ impl ContextPickerCompletionProvider { cx: &mut App, ) -> Vec { let embedded_context = self.prompt_capabilities.borrow().embedded_context; - let mut entries = if embedded_context { - vec![ - ContextPickerEntry::Mode(ContextPickerMode::File), - ContextPickerEntry::Mode(ContextPickerMode::Symbol), - ContextPickerEntry::Mode(ContextPickerMode::Thread), - ] - } else { - // File is always available, but we don't need a mode entry - vec![] - }; + let mut entries = vec![ + ContextPickerEntry::Mode(ContextPickerMode::File), + ContextPickerEntry::Mode(ContextPickerMode::Symbol), + ]; + + if embedded_context { + entries.push(ContextPickerEntry::Mode(ContextPickerMode::Thread)); + } let has_selection = workspace .read(cx) diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index 780d59f13fcaded6665d46bb1c46707a8edf2807..7789564d3b8b0c03ebb207e634d718a359befafe 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -2199,6 +2199,8 @@ mod tests { format!("seven.txt b{slash}"), format!("six.txt b{slash}"), format!("five.txt b{slash}"), + "Files & Directories".into(), + "Symbols".into() ] ); editor.set_text("", window, cx);