diff --git a/Cargo.lock b/Cargo.lock
index 6964ed4890..c835b503ad 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -403,6 +403,7 @@ dependencies = [
"parking_lot",
"paths",
"picker",
+ "postage",
"pretty_assertions",
"project",
"prompt_store",
@@ -8467,6 +8468,7 @@ dependencies = [
"theme",
"ui",
"util",
+ "util_macros",
"workspace",
"workspace-hack",
"zed_actions",
diff --git a/assets/icons/copy.svg b/assets/icons/copy.svg
index bca13f8d56..aba193930b 100644
--- a/assets/icons/copy.svg
+++ b/assets/icons/copy.svg
@@ -1 +1,4 @@
-
+
diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs
index c748f22275..d9a7a2582a 100644
--- a/crates/acp_thread/src/acp_thread.rs
+++ b/crates/acp_thread/src/acp_thread.rs
@@ -509,7 +509,7 @@ impl ContentBlock {
"`Image`".into()
}
- fn to_markdown<'a>(&'a self, cx: &'a App) -> &'a str {
+ pub fn to_markdown<'a>(&'a self, cx: &'a App) -> &'a str {
match self {
ContentBlock::Empty => "",
ContentBlock::Markdown { markdown } => markdown.read(cx).source(),
@@ -1373,6 +1373,10 @@ impl AcpThread {
})
}
+ pub fn can_resume(&self, cx: &App) -> bool {
+ self.connection.resume(&self.session_id, cx).is_some()
+ }
+
pub fn resume(&mut self, cx: &mut Context) -> BoxFuture<'static, Result<()>> {
self.run_turn(cx, async move |this, cx| {
this.update(cx, |this, cx| {
@@ -2659,7 +2663,7 @@ mod tests {
fn truncate(
&self,
session_id: &acp::SessionId,
- _cx: &mut App,
+ _cx: &App,
) -> Option> {
Some(Rc::new(FakeAgentSessionEditor {
_session_id: session_id.clone(),
diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs
index 91e46dbac1..5f5032e588 100644
--- a/crates/acp_thread/src/connection.rs
+++ b/crates/acp_thread/src/connection.rs
@@ -43,7 +43,7 @@ pub trait AgentConnection {
fn resume(
&self,
_session_id: &acp::SessionId,
- _cx: &mut App,
+ _cx: &App,
) -> Option> {
None
}
@@ -53,7 +53,7 @@ pub trait AgentConnection {
fn truncate(
&self,
_session_id: &acp::SessionId,
- _cx: &mut App,
+ _cx: &App,
) -> Option> {
None
}
@@ -61,7 +61,7 @@ pub trait AgentConnection {
fn set_title(
&self,
_session_id: &acp::SessionId,
- _cx: &mut App,
+ _cx: &App,
) -> Option> {
None
}
@@ -439,7 +439,7 @@ mod test_support {
fn truncate(
&self,
_session_id: &agent_client_protocol::SessionId,
- _cx: &mut App,
+ _cx: &App,
) -> Option> {
Some(Rc::new(StubAgentSessionEditor))
}
diff --git a/crates/acp_tools/src/acp_tools.rs b/crates/acp_tools/src/acp_tools.rs
index ca5e57e85a..ee12b04cde 100644
--- a/crates/acp_tools/src/acp_tools.rs
+++ b/crates/acp_tools/src/acp_tools.rs
@@ -46,7 +46,7 @@ pub struct AcpConnectionRegistry {
}
struct ActiveConnection {
- server_name: &'static str,
+ server_name: SharedString,
connection: Weak,
}
@@ -63,12 +63,12 @@ impl AcpConnectionRegistry {
pub fn set_active_connection(
&self,
- server_name: &'static str,
+ server_name: impl Into,
connection: &Rc,
cx: &mut Context,
) {
self.active_connection.replace(Some(ActiveConnection {
- server_name,
+ server_name: server_name.into(),
connection: Rc::downgrade(connection),
}));
cx.notify();
@@ -85,7 +85,7 @@ struct AcpTools {
}
struct WatchedConnection {
- server_name: &'static str,
+ server_name: SharedString,
messages: Vec,
list_state: ListState,
connection: Weak,
@@ -142,7 +142,7 @@ impl AcpTools {
});
self.watched_connection = Some(WatchedConnection {
- server_name: active_connection.server_name,
+ server_name: active_connection.server_name.clone(),
messages: vec![],
list_state: ListState::new(0, ListAlignment::Bottom, px(2048.)),
connection: active_connection.connection.clone(),
@@ -442,7 +442,7 @@ impl Item for AcpTools {
"ACP: {}",
self.watched_connection
.as_ref()
- .map_or("Disconnected", |connection| connection.server_name)
+ .map_or("Disconnected", |connection| &connection.server_name)
)
.into()
}
diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs
index 4eaf87e218..415933b7d1 100644
--- a/crates/agent2/src/agent.rs
+++ b/crates/agent2/src/agent.rs
@@ -936,7 +936,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
fn resume(
&self,
session_id: &acp::SessionId,
- _cx: &mut App,
+ _cx: &App,
) -> Option> {
Some(Rc::new(NativeAgentSessionResume {
connection: self.clone(),
@@ -956,9 +956,9 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
fn truncate(
&self,
session_id: &agent_client_protocol::SessionId,
- cx: &mut App,
+ cx: &App,
) -> Option> {
- self.0.update(cx, |agent, _cx| {
+ self.0.read_with(cx, |agent, _cx| {
agent.sessions.get(session_id).map(|session| {
Rc::new(NativeAgentSessionEditor {
thread: session.thread.clone(),
@@ -971,7 +971,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
fn set_title(
&self,
session_id: &acp::SessionId,
- _cx: &mut App,
+ _cx: &App,
) -> Option> {
Some(Rc::new(NativeAgentSessionSetTitle {
connection: self.clone(),
diff --git a/crates/agent2/src/native_agent_server.rs b/crates/agent2/src/native_agent_server.rs
index 4ce467d6fd..12d3c79d1b 100644
--- a/crates/agent2/src/native_agent_server.rs
+++ b/crates/agent2/src/native_agent_server.rs
@@ -3,7 +3,7 @@ use std::{any::Any, path::Path, rc::Rc, sync::Arc};
use agent_servers::AgentServer;
use anyhow::Result;
use fs::Fs;
-use gpui::{App, Entity, Task};
+use gpui::{App, Entity, SharedString, Task};
use project::Project;
use prompt_store::PromptStore;
@@ -22,16 +22,16 @@ impl NativeAgentServer {
}
impl AgentServer for NativeAgentServer {
- fn name(&self) -> &'static str {
- "Zed Agent"
+ fn name(&self) -> SharedString {
+ "Zed Agent".into()
}
- fn empty_state_headline(&self) -> &'static str {
+ fn empty_state_headline(&self) -> SharedString {
self.name()
}
- fn empty_state_message(&self) -> &'static str {
- ""
+ fn empty_state_message(&self) -> SharedString {
+ "".into()
}
fn logo(&self) -> ui::IconName {
diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs
index 60b3198081..87ecc1037c 100644
--- a/crates/agent2/src/tests/mod.rs
+++ b/crates/agent2/src/tests/mod.rs
@@ -4,6 +4,8 @@ use agent_client_protocol::{self as acp};
use agent_settings::AgentProfileId;
use anyhow::Result;
use client::{Client, UserStore};
+use cloud_llm_client::CompletionIntent;
+use collections::IndexMap;
use context_server::{ContextServer, ContextServerCommand, ContextServerId};
use fs::{FakeFs, Fs};
use futures::{
@@ -672,15 +674,6 @@ async fn test_resume_after_tool_use_limit(cx: &mut TestAppContext) {
"}
)
});
-
- // Ensure we error if calling resume when tool use limit was *not* reached.
- let error = thread
- .update(cx, |thread, cx| thread.resume(cx))
- .unwrap_err();
- assert_eq!(
- error.to_string(),
- "can only resume after tool use limit is reached"
- )
}
#[gpui::test]
@@ -1737,6 +1730,81 @@ async fn test_title_generation(cx: &mut TestAppContext) {
thread.read_with(cx, |thread, _| assert_eq!(thread.title(), "Hello world"));
}
+#[gpui::test]
+async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) {
+ let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
+ let fake_model = model.as_fake();
+
+ let _events = thread
+ .update(cx, |thread, cx| {
+ thread.add_tool(ToolRequiringPermission);
+ thread.add_tool(EchoTool);
+ thread.send(UserMessageId::new(), ["Hey!"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ let permission_tool_use = LanguageModelToolUse {
+ id: "tool_id_1".into(),
+ name: ToolRequiringPermission::name().into(),
+ raw_input: "{}".into(),
+ input: json!({}),
+ is_input_complete: true,
+ };
+ let echo_tool_use = LanguageModelToolUse {
+ id: "tool_id_2".into(),
+ name: EchoTool::name().into(),
+ raw_input: json!({"text": "test"}).to_string(),
+ input: json!({"text": "test"}),
+ is_input_complete: true,
+ };
+ fake_model.send_last_completion_stream_text_chunk("Hi!");
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
+ permission_tool_use,
+ ));
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
+ echo_tool_use.clone(),
+ ));
+ fake_model.end_last_completion_stream();
+ cx.run_until_parked();
+
+ // Ensure pending tools are skipped when building a request.
+ let request = thread
+ .read_with(cx, |thread, cx| {
+ thread.build_completion_request(CompletionIntent::EditFile, cx)
+ })
+ .unwrap();
+ assert_eq!(
+ request.messages[1..],
+ vec![
+ LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec!["Hey!".into()],
+ cache: true
+ },
+ LanguageModelRequestMessage {
+ role: Role::Assistant,
+ content: vec![
+ MessageContent::Text("Hi!".into()),
+ MessageContent::ToolUse(echo_tool_use.clone())
+ ],
+ cache: false
+ },
+ LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec![MessageContent::ToolResult(LanguageModelToolResult {
+ tool_use_id: echo_tool_use.id.clone(),
+ tool_name: echo_tool_use.name,
+ is_error: false,
+ content: "test".into(),
+ output: Some("test".into())
+ })],
+ cache: false
+ },
+ ],
+ );
+}
+
#[gpui::test]
async fn test_agent_connection(cx: &mut TestAppContext) {
cx.update(settings::init);
@@ -2029,6 +2097,7 @@ async fn test_send_retry_on_error(cx: &mut TestAppContext) {
.unwrap();
cx.run_until_parked();
+ fake_model.send_last_completion_stream_text_chunk("Hey,");
fake_model.send_last_completion_stream_error(LanguageModelCompletionError::ServerOverloaded {
provider: LanguageModelProviderName::new("Anthropic"),
retry_after: Some(Duration::from_secs(3)),
@@ -2038,8 +2107,9 @@ async fn test_send_retry_on_error(cx: &mut TestAppContext) {
cx.executor().advance_clock(Duration::from_secs(3));
cx.run_until_parked();
- fake_model.send_last_completion_stream_text_chunk("Hey!");
+ fake_model.send_last_completion_stream_text_chunk("there!");
fake_model.end_last_completion_stream();
+ cx.run_until_parked();
let mut retry_events = Vec::new();
while let Some(Ok(event)) = events.next().await {
@@ -2067,12 +2137,94 @@ async fn test_send_retry_on_error(cx: &mut TestAppContext) {
## Assistant
- Hey!
+ Hey,
+
+ [resume]
+
+ ## Assistant
+
+ there!
"}
)
});
}
+#[gpui::test]
+async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) {
+ let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await;
+ let fake_model = model.as_fake();
+
+ let events = thread
+ .update(cx, |thread, cx| {
+ thread.set_completion_mode(agent_settings::CompletionMode::Burn, cx);
+ thread.add_tool(EchoTool);
+ thread.send(UserMessageId::new(), ["Call the echo tool!"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ let tool_use_1 = LanguageModelToolUse {
+ id: "tool_1".into(),
+ name: EchoTool::name().into(),
+ raw_input: json!({"text": "test"}).to_string(),
+ input: json!({"text": "test"}),
+ is_input_complete: true,
+ };
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
+ tool_use_1.clone(),
+ ));
+ fake_model.send_last_completion_stream_error(LanguageModelCompletionError::ServerOverloaded {
+ provider: LanguageModelProviderName::new("Anthropic"),
+ retry_after: Some(Duration::from_secs(3)),
+ });
+ fake_model.end_last_completion_stream();
+
+ cx.executor().advance_clock(Duration::from_secs(3));
+ let completion = fake_model.pending_completions().pop().unwrap();
+ assert_eq!(
+ completion.messages[1..],
+ vec![
+ LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec!["Call the echo tool!".into()],
+ cache: false
+ },
+ LanguageModelRequestMessage {
+ role: Role::Assistant,
+ content: vec![language_model::MessageContent::ToolUse(tool_use_1.clone())],
+ cache: false
+ },
+ LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec![language_model::MessageContent::ToolResult(
+ LanguageModelToolResult {
+ tool_use_id: tool_use_1.id.clone(),
+ tool_name: tool_use_1.name.clone(),
+ is_error: false,
+ content: "test".into(),
+ output: Some("test".into())
+ }
+ )],
+ cache: true
+ },
+ ]
+ );
+
+ fake_model.send_last_completion_stream_text_chunk("Done");
+ fake_model.end_last_completion_stream();
+ cx.run_until_parked();
+ events.collect::>().await;
+ thread.read_with(cx, |thread, _cx| {
+ assert_eq!(
+ thread.last_message(),
+ Some(Message::Agent(AgentMessage {
+ content: vec![AgentMessageContent::Text("Done".into())],
+ tool_results: IndexMap::default()
+ }))
+ );
+ })
+}
+
#[gpui::test]
async fn test_send_max_retries_exceeded(cx: &mut TestAppContext) {
let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await;
diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs
index 6d616f73fc..43f391ca64 100644
--- a/crates/agent2/src/thread.rs
+++ b/crates/agent2/src/thread.rs
@@ -123,7 +123,7 @@ impl Message {
match self {
Message::User(message) => message.to_markdown(),
Message::Agent(message) => message.to_markdown(),
- Message::Resume => "[resumed after tool use limit was reached]".into(),
+ Message::Resume => "[resume]\n".into(),
}
}
@@ -448,24 +448,33 @@ impl AgentMessage {
cache: false,
};
for chunk in &self.content {
- let chunk = match chunk {
+ match chunk {
AgentMessageContent::Text(text) => {
- language_model::MessageContent::Text(text.clone())
+ assistant_message
+ .content
+ .push(language_model::MessageContent::Text(text.clone()));
}
AgentMessageContent::Thinking { text, signature } => {
- language_model::MessageContent::Thinking {
- text: text.clone(),
- signature: signature.clone(),
- }
+ assistant_message
+ .content
+ .push(language_model::MessageContent::Thinking {
+ text: text.clone(),
+ signature: signature.clone(),
+ });
}
AgentMessageContent::RedactedThinking(value) => {
- language_model::MessageContent::RedactedThinking(value.clone())
+ assistant_message.content.push(
+ language_model::MessageContent::RedactedThinking(value.clone()),
+ );
}
- AgentMessageContent::ToolUse(value) => {
- language_model::MessageContent::ToolUse(value.clone())
+ AgentMessageContent::ToolUse(tool_use) => {
+ if self.tool_results.contains_key(&tool_use.id) {
+ assistant_message
+ .content
+ .push(language_model::MessageContent::ToolUse(tool_use.clone()));
+ }
}
};
- assistant_message.content.push(chunk);
}
let mut user_message = LanguageModelRequestMessage {
@@ -1076,11 +1085,6 @@ impl Thread {
&mut self,
cx: &mut Context,
) -> Result>> {
- anyhow::ensure!(
- self.tool_use_limit_reached,
- "can only resume after tool use limit is reached"
- );
-
self.messages.push(Message::Resume);
cx.notify();
@@ -1207,12 +1211,13 @@ impl Thread {
cx: &mut AsyncApp,
) -> Result<()> {
log::debug!("Stream completion started successfully");
- let request = this.update(cx, |this, cx| {
- this.build_completion_request(completion_intent, cx)
- })??;
let mut attempt = None;
- 'retry: loop {
+ loop {
+ let request = this.update(cx, |this, cx| {
+ this.build_completion_request(completion_intent, cx)
+ })??;
+
telemetry::event!(
"Agent Thread Completion",
thread_id = this.read_with(cx, |this, _| this.id.to_string())?,
@@ -1227,10 +1232,11 @@ impl Thread {
attempt.unwrap_or(0)
);
let mut events = model
- .stream_completion(request.clone(), cx)
+ .stream_completion(request, cx)
.await
.map_err(|error| anyhow!(error))?;
let mut tool_results = FuturesUnordered::new();
+ let mut error = None;
while let Some(event) = events.next().await {
match event {
@@ -1240,51 +1246,9 @@ impl Thread {
this.handle_streamed_completion_event(event, event_stream, cx)
})??);
}
- Err(error) => {
- let completion_mode =
- this.read_with(cx, |thread, _cx| thread.completion_mode())?;
- if completion_mode == CompletionMode::Normal {
- return Err(anyhow!(error))?;
- }
-
- let Some(strategy) = Self::retry_strategy_for(&error) else {
- return Err(anyhow!(error))?;
- };
-
- let max_attempts = match &strategy {
- RetryStrategy::ExponentialBackoff { max_attempts, .. } => *max_attempts,
- RetryStrategy::Fixed { max_attempts, .. } => *max_attempts,
- };
-
- let attempt = attempt.get_or_insert(0u8);
-
- *attempt += 1;
-
- let attempt = *attempt;
- if attempt > max_attempts {
- return Err(anyhow!(error))?;
- }
-
- let delay = match &strategy {
- RetryStrategy::ExponentialBackoff { initial_delay, .. } => {
- let delay_secs =
- initial_delay.as_secs() * 2u64.pow((attempt - 1) as u32);
- Duration::from_secs(delay_secs)
- }
- RetryStrategy::Fixed { delay, .. } => *delay,
- };
- log::debug!("Retry attempt {attempt} with delay {delay:?}");
-
- event_stream.send_retry(acp_thread::RetryStatus {
- last_error: error.to_string().into(),
- attempt: attempt as usize,
- max_attempts: max_attempts as usize,
- started_at: Instant::now(),
- duration: delay,
- });
-
- cx.background_executor().timer(delay).await;
- continue 'retry;
+ Err(err) => {
+ error = Some(err);
+ break;
}
}
}
@@ -1311,24 +1275,58 @@ impl Thread {
})?;
}
- return Ok(());
- }
- }
+ if let Some(error) = error {
+ let completion_mode = this.read_with(cx, |thread, _cx| thread.completion_mode())?;
+ if completion_mode == CompletionMode::Normal {
+ return Err(anyhow!(error))?;
+ }
- pub fn build_system_message(&self, cx: &App) -> LanguageModelRequestMessage {
- log::debug!("Building system message");
- let prompt = SystemPromptTemplate {
- project: self.project_context.read(cx),
- available_tools: self.tools.keys().cloned().collect(),
- }
- .render(&self.templates)
- .context("failed to build system prompt")
- .expect("Invalid template");
- log::debug!("System message built");
- LanguageModelRequestMessage {
- role: Role::System,
- content: vec![prompt.into()],
- cache: true,
+ let Some(strategy) = Self::retry_strategy_for(&error) else {
+ return Err(anyhow!(error))?;
+ };
+
+ let max_attempts = match &strategy {
+ RetryStrategy::ExponentialBackoff { max_attempts, .. } => *max_attempts,
+ RetryStrategy::Fixed { max_attempts, .. } => *max_attempts,
+ };
+
+ let attempt = attempt.get_or_insert(0u8);
+
+ *attempt += 1;
+
+ let attempt = *attempt;
+ if attempt > max_attempts {
+ return Err(anyhow!(error))?;
+ }
+
+ let delay = match &strategy {
+ RetryStrategy::ExponentialBackoff { initial_delay, .. } => {
+ let delay_secs = initial_delay.as_secs() * 2u64.pow((attempt - 1) as u32);
+ Duration::from_secs(delay_secs)
+ }
+ RetryStrategy::Fixed { delay, .. } => *delay,
+ };
+ log::debug!("Retry attempt {attempt} with delay {delay:?}");
+
+ event_stream.send_retry(acp_thread::RetryStatus {
+ last_error: error.to_string().into(),
+ attempt: attempt as usize,
+ max_attempts: max_attempts as usize,
+ started_at: Instant::now(),
+ duration: delay,
+ });
+ cx.background_executor().timer(delay).await;
+ this.update(cx, |this, cx| {
+ this.flush_pending_message(cx);
+ if let Some(Message::Agent(message)) = this.messages.last() {
+ if message.tool_results.is_empty() {
+ this.messages.push(Message::Resume);
+ }
+ }
+ })?;
+ } else {
+ return Ok(());
+ }
}
}
@@ -1745,6 +1743,10 @@ impl Thread {
return;
};
+ if message.content.is_empty() {
+ return;
+ }
+
for content in &message.content {
let AgentMessageContent::ToolUse(tool_use) = content else {
continue;
@@ -1773,7 +1775,7 @@ impl Thread {
pub(crate) fn build_completion_request(
&self,
completion_intent: CompletionIntent,
- cx: &mut App,
+ cx: &App,
) -> Result {
let model = self.model().context("No language model configured")?;
let tools = if let Some(turn) = self.running_turn.as_ref() {
@@ -1894,21 +1896,29 @@ impl Thread {
"Building request messages from {} thread messages",
self.messages.len()
);
- let mut messages = vec![self.build_system_message(cx)];
+
+ let system_prompt = SystemPromptTemplate {
+ project: self.project_context.read(cx),
+ available_tools: self.tools.keys().cloned().collect(),
+ }
+ .render(&self.templates)
+ .context("failed to build system prompt")
+ .expect("Invalid template");
+ let mut messages = vec![LanguageModelRequestMessage {
+ role: Role::System,
+ content: vec![system_prompt.into()],
+ cache: false,
+ }];
for message in &self.messages {
messages.extend(message.to_request());
}
- if let Some(message) = self.pending_message.as_ref() {
- messages.extend(message.to_request());
+ if let Some(last_message) = messages.last_mut() {
+ last_message.cache = true;
}
- if let Some(last_user_message) = messages
- .iter_mut()
- .rev()
- .find(|message| message.role == Role::User)
- {
- last_user_message.cache = true;
+ if let Some(message) = self.pending_message.as_ref() {
+ messages.extend(message.to_request());
}
messages
diff --git a/crates/agent2/src/tools/read_file_tool.rs b/crates/agent2/src/tools/read_file_tool.rs
index 903e1582ac..fea9732093 100644
--- a/crates/agent2/src/tools/read_file_tool.rs
+++ b/crates/agent2/src/tools/read_file_tool.rs
@@ -10,7 +10,7 @@ use project::{AgentLocation, ImageItem, Project, WorktreeSettings, image_store};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::Settings;
-use std::sync::Arc;
+use std::{path::Path, sync::Arc};
use crate::{AgentTool, ToolCallEventStream};
@@ -68,27 +68,12 @@ impl AgentTool for ReadFileTool {
}
fn initial_title(&self, input: Result) -> SharedString {
- if let Ok(input) = input {
- let path = &input.path;
- match (input.start_line, input.end_line) {
- (Some(start), Some(end)) => {
- format!(
- "[Read file `{}` (lines {}-{})](@selection:{}:({}-{}))",
- path, start, end, path, start, end
- )
- }
- (Some(start), None) => {
- format!(
- "[Read file `{}` (from line {})](@selection:{}:({}-{}))",
- path, start, path, start, start
- )
- }
- _ => format!("[Read file `{}`](@file:{})", path, path),
- }
- .into()
- } else {
- "Read file".into()
- }
+ input
+ .ok()
+ .as_ref()
+ .and_then(|input| Path::new(&input.path).file_name())
+ .map(|file_name| file_name.to_string_lossy().to_string().into())
+ .unwrap_or_default()
}
fn run(
diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs
index a99a401431..c9c938c6c0 100644
--- a/crates/agent_servers/src/acp.rs
+++ b/crates/agent_servers/src/acp.rs
@@ -15,7 +15,7 @@ use std::{path::Path, rc::Rc};
use thiserror::Error;
use anyhow::{Context as _, Result};
-use gpui::{App, AppContext as _, AsyncApp, Entity, Task, WeakEntity};
+use gpui::{App, AppContext as _, AsyncApp, Entity, SharedString, Task, WeakEntity};
use acp_thread::{AcpThread, AuthRequired, LoadError};
@@ -24,7 +24,7 @@ use acp_thread::{AcpThread, AuthRequired, LoadError};
pub struct UnsupportedVersion;
pub struct AcpConnection {
- server_name: &'static str,
+ server_name: SharedString,
connection: Rc,
sessions: Rc>>,
auth_methods: Vec,
@@ -38,7 +38,7 @@ pub struct AcpSession {
}
pub async fn connect(
- server_name: &'static str,
+ server_name: SharedString,
command: AgentServerCommand,
root_dir: &Path,
cx: &mut AsyncApp,
@@ -51,7 +51,7 @@ const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::V1;
impl AcpConnection {
pub async fn stdio(
- server_name: &'static str,
+ server_name: SharedString,
command: AgentServerCommand,
root_dir: &Path,
cx: &mut AsyncApp,
@@ -121,7 +121,7 @@ impl AcpConnection {
cx.update(|cx| {
AcpConnectionRegistry::default_global(cx).update(cx, |registry, cx| {
- registry.set_active_connection(server_name, &connection, cx)
+ registry.set_active_connection(server_name.clone(), &connection, cx)
});
})?;
@@ -187,7 +187,7 @@ impl AgentConnection for AcpConnection {
let action_log = cx.new(|_| ActionLog::new(project.clone()))?;
let thread = cx.new(|_cx| {
AcpThread::new(
- self.server_name,
+ self.server_name.clone(),
self.clone(),
project,
action_log,
diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs
index 2f5ec478ae..fa59201338 100644
--- a/crates/agent_servers/src/agent_servers.rs
+++ b/crates/agent_servers/src/agent_servers.rs
@@ -1,5 +1,6 @@
mod acp;
mod claude;
+mod custom;
mod gemini;
mod settings;
@@ -7,6 +8,7 @@ mod settings;
pub mod e2e_tests;
pub use claude::*;
+pub use custom::*;
pub use gemini::*;
pub use settings::*;
@@ -31,9 +33,9 @@ pub fn init(cx: &mut App) {
pub trait AgentServer: Send {
fn logo(&self) -> ui::IconName;
- fn name(&self) -> &'static str;
- fn empty_state_headline(&self) -> &'static str;
- fn empty_state_message(&self) -> &'static str;
+ fn name(&self) -> SharedString;
+ fn empty_state_headline(&self) -> SharedString;
+ fn empty_state_message(&self) -> SharedString;
fn connect(
&self,
diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs
index ef666974f1..048563103f 100644
--- a/crates/agent_servers/src/claude.rs
+++ b/crates/agent_servers/src/claude.rs
@@ -30,7 +30,7 @@ use futures::{
io::BufReader,
select_biased,
};
-use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity};
+use gpui::{App, AppContext, AsyncApp, Entity, SharedString, Task, WeakEntity};
use serde::{Deserialize, Serialize};
use util::{ResultExt, debug_panic};
@@ -43,16 +43,16 @@ use acp_thread::{AcpThread, AgentConnection, AuthRequired, LoadError, MentionUri
pub struct ClaudeCode;
impl AgentServer for ClaudeCode {
- fn name(&self) -> &'static str {
- "Claude Code"
+ fn name(&self) -> SharedString {
+ "Claude Code".into()
}
- fn empty_state_headline(&self) -> &'static str {
+ fn empty_state_headline(&self) -> SharedString {
self.name()
}
- fn empty_state_message(&self) -> &'static str {
- "How can I help you today?"
+ fn empty_state_message(&self) -> SharedString {
+ "How can I help you today?".into()
}
fn logo(&self) -> ui::IconName {
diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs
new file mode 100644
index 0000000000..e544c4f21f
--- /dev/null
+++ b/crates/agent_servers/src/custom.rs
@@ -0,0 +1,59 @@
+use crate::{AgentServerCommand, AgentServerSettings};
+use acp_thread::AgentConnection;
+use anyhow::Result;
+use gpui::{App, Entity, SharedString, Task};
+use project::Project;
+use std::{path::Path, rc::Rc};
+use ui::IconName;
+
+/// A generic agent server implementation for custom user-defined agents
+pub struct CustomAgentServer {
+ name: SharedString,
+ command: AgentServerCommand,
+}
+
+impl CustomAgentServer {
+ pub fn new(name: SharedString, settings: &AgentServerSettings) -> Self {
+ Self {
+ name,
+ command: settings.command.clone(),
+ }
+ }
+}
+
+impl crate::AgentServer for CustomAgentServer {
+ fn name(&self) -> SharedString {
+ self.name.clone()
+ }
+
+ fn logo(&self) -> IconName {
+ IconName::Terminal
+ }
+
+ fn empty_state_headline(&self) -> SharedString {
+ "No conversations yet".into()
+ }
+
+ fn empty_state_message(&self) -> SharedString {
+ format!("Start a conversation with {}", self.name).into()
+ }
+
+ fn connect(
+ &self,
+ root_dir: &Path,
+ _project: &Entity,
+ cx: &mut App,
+ ) -> Task>> {
+ let server_name = self.name();
+ let command = self.command.clone();
+ let root_dir = root_dir.to_path_buf();
+
+ cx.spawn(async move |mut cx| {
+ crate::acp::connect(server_name, command, &root_dir, &mut cx).await
+ })
+ }
+
+ fn into_any(self: Rc) -> Rc {
+ self
+ }
+}
diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs
index c271079071..42264b4b4f 100644
--- a/crates/agent_servers/src/e2e_tests.rs
+++ b/crates/agent_servers/src/e2e_tests.rs
@@ -1,17 +1,15 @@
+use crate::AgentServer;
+use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus};
+use agent_client_protocol as acp;
+use futures::{FutureExt, StreamExt, channel::mpsc, select};
+use gpui::{AppContext, Entity, TestAppContext};
+use indoc::indoc;
+use project::{FakeFs, Project};
use std::{
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
-
-use crate::AgentServer;
-use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus};
-use agent_client_protocol as acp;
-
-use futures::{FutureExt, StreamExt, channel::mpsc, select};
-use gpui::{AppContext, Entity, TestAppContext};
-use indoc::indoc;
-use project::{FakeFs, Project};
use util::path;
pub async fn test_basic(server: F, cx: &mut TestAppContext)
@@ -479,6 +477,7 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc {
gemini: Some(crate::AgentServerSettings {
command: crate::gemini::tests::local_command(),
}),
+ custom: collections::HashMap::default(),
},
cx,
);
diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs
index 29120fff6e..9ebcee745c 100644
--- a/crates/agent_servers/src/gemini.rs
+++ b/crates/agent_servers/src/gemini.rs
@@ -4,11 +4,10 @@ use std::{any::Any, path::Path};
use crate::{AgentServer, AgentServerCommand};
use acp_thread::{AgentConnection, LoadError};
use anyhow::Result;
-use gpui::{Entity, Task};
+use gpui::{App, Entity, SharedString, Task};
use language_models::provider::google::GoogleLanguageModelProvider;
use project::Project;
use settings::SettingsStore;
-use ui::App;
use crate::AllAgentServersSettings;
@@ -18,16 +17,16 @@ pub struct Gemini;
const ACP_ARG: &str = "--experimental-acp";
impl AgentServer for Gemini {
- fn name(&self) -> &'static str {
- "Gemini CLI"
+ fn name(&self) -> SharedString {
+ "Gemini CLI".into()
}
- fn empty_state_headline(&self) -> &'static str {
+ fn empty_state_headline(&self) -> SharedString {
self.name()
}
- fn empty_state_message(&self) -> &'static str {
- "Ask questions, edit files, run commands"
+ fn empty_state_message(&self) -> SharedString {
+ "Ask questions, edit files, run commands".into()
}
fn logo(&self) -> ui::IconName {
diff --git a/crates/agent_servers/src/settings.rs b/crates/agent_servers/src/settings.rs
index 645674b5f1..96ac6e3cbe 100644
--- a/crates/agent_servers/src/settings.rs
+++ b/crates/agent_servers/src/settings.rs
@@ -1,6 +1,7 @@
use crate::AgentServerCommand;
use anyhow::Result;
-use gpui::App;
+use collections::HashMap;
+use gpui::{App, SharedString};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsSources};
@@ -13,9 +14,13 @@ pub fn init(cx: &mut App) {
pub struct AllAgentServersSettings {
pub gemini: Option,
pub claude: Option,
+
+ /// Custom agent servers configured by the user
+ #[serde(flatten)]
+ pub custom: HashMap,
}
-#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug)]
+#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)]
pub struct AgentServerSettings {
#[serde(flatten)]
pub command: AgentServerCommand,
@@ -29,13 +34,26 @@ impl settings::Settings for AllAgentServersSettings {
fn load(sources: SettingsSources, _: &mut App) -> Result {
let mut settings = AllAgentServersSettings::default();
- for AllAgentServersSettings { gemini, claude } in sources.defaults_and_customizations() {
+ for AllAgentServersSettings {
+ gemini,
+ claude,
+ custom,
+ } in sources.defaults_and_customizations()
+ {
if gemini.is_some() {
settings.gemini = gemini.clone();
}
if claude.is_some() {
settings.claude = claude.clone();
}
+
+ // Merge custom agents
+ for (name, config) in custom {
+ // Skip built-in agent names to avoid conflicts
+ if name != "gemini" && name != "claude" {
+ settings.custom.insert(name.clone(), config.clone());
+ }
+ }
}
Ok(settings)
diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml
index 43e3b25124..6b0979ee69 100644
--- a/crates/agent_ui/Cargo.toml
+++ b/crates/agent_ui/Cargo.toml
@@ -67,6 +67,7 @@ ordered-float.workspace = true
parking_lot.workspace = true
paths.workspace = true
picker.workspace = true
+postage.workspace = true
project.workspace = true
prompt_store.workspace = true
proto.workspace = true
diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs
index 115008cf52..70faa0ed27 100644
--- a/crates/agent_ui/src/acp/message_editor.rs
+++ b/crates/agent_ui/src/acp/message_editor.rs
@@ -21,12 +21,13 @@ use futures::{
future::{Shared, join_all},
};
use gpui::{
- AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable,
- HighlightStyle, Image, ImageFormat, Img, KeyContext, Subscription, Task, TextStyle,
- UnderlineStyle, WeakEntity,
+ Animation, AnimationExt as _, AppContext, ClipboardEntry, Context, Entity, EntityId,
+ EventEmitter, FocusHandle, Focusable, HighlightStyle, Image, ImageFormat, Img, KeyContext,
+ Subscription, Task, TextStyle, UnderlineStyle, WeakEntity, pulsating_between,
};
use language::{Buffer, Language};
use language_model::LanguageModelImage;
+use postage::stream::Stream as _;
use project::{CompletionIntent, Project, ProjectItem, ProjectPath, Worktree};
use prompt_store::{PromptId, PromptStore};
use rope::Point;
@@ -44,10 +45,10 @@ use std::{
use text::{OffsetRangeExt, ToOffset as _};
use theme::ThemeSettings;
use ui::{
- ActiveTheme, AnyElement, App, ButtonCommon, ButtonLike, ButtonStyle, Color, Icon, IconName,
- IconSize, InteractiveElement, IntoElement, Label, LabelCommon, LabelSize, ParentElement,
- Render, SelectableButton, SharedString, Styled, TextSize, TintColor, Toggleable, Window, div,
- h_flex, px,
+ ActiveTheme, AnyElement, App, ButtonCommon, ButtonLike, ButtonStyle, Color, Element as _,
+ FluentBuilder as _, Icon, IconName, IconSize, InteractiveElement, IntoElement, Label,
+ LabelCommon, LabelSize, ParentElement, Render, SelectableButton, SharedString, Styled,
+ TextSize, TintColor, Toggleable, Window, div, h_flex, px,
};
use util::{ResultExt, debug_panic};
use workspace::{Workspace, notifications::NotifyResultExt as _};
@@ -73,6 +74,7 @@ pub enum MessageEditorEvent {
Send,
Cancel,
Focus,
+ LostFocus,
}
impl EventEmitter for MessageEditor {}
@@ -130,10 +132,14 @@ impl MessageEditor {
editor
});
- cx.on_focus(&editor.focus_handle(cx), window, |_, _, cx| {
+ cx.on_focus_in(&editor.focus_handle(cx), window, |_, _, cx| {
cx.emit(MessageEditorEvent::Focus)
})
.detach();
+ cx.on_focus_out(&editor.focus_handle(cx), window, |_, _, _, cx| {
+ cx.emit(MessageEditorEvent::LostFocus)
+ })
+ .detach();
let mut subscriptions = Vec::new();
subscriptions.push(cx.subscribe_in(&editor, window, {
@@ -246,7 +252,7 @@ impl MessageEditor {
.buffer_snapshot
.anchor_before(start_anchor.to_offset(&snapshot.buffer_snapshot) + content_len + 1);
- let crease_id = if let MentionUri::File { abs_path } = &mention_uri
+ let crease = if let MentionUri::File { abs_path } = &mention_uri
&& let Some(extension) = abs_path.extension()
&& let Some(extension) = extension.to_str()
&& Img::extensions().contains(&extension)
@@ -272,29 +278,31 @@ impl MessageEditor {
Ok(image)
})
.shared();
- insert_crease_for_image(
+ insert_crease_for_mention(
*excerpt_id,
start,
content_len,
- Some(abs_path.as_path().into()),
- image,
+ mention_uri.name().into(),
+ IconName::Image.path().into(),
+ Some(image),
self.editor.clone(),
window,
cx,
)
} else {
- crate::context_picker::insert_crease_for_mention(
+ insert_crease_for_mention(
*excerpt_id,
start,
content_len,
crease_text,
mention_uri.icon_path(cx),
+ None,
self.editor.clone(),
window,
cx,
)
};
- let Some(crease_id) = crease_id else {
+ let Some((crease_id, tx)) = crease else {
return Task::ready(());
};
@@ -331,7 +339,9 @@ impl MessageEditor {
// Notify the user if we failed to load the mentioned context
cx.spawn_in(window, async move |this, cx| {
- if task.await.notify_async_err(cx).is_none() {
+ let result = task.await.notify_async_err(cx);
+ drop(tx);
+ if result.is_none() {
this.update(cx, |this, cx| {
this.editor.update(cx, |editor, cx| {
// Remove mention
@@ -857,12 +867,13 @@ impl MessageEditor {
snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len)
});
let image = Arc::new(image);
- let Some(crease_id) = insert_crease_for_image(
+ let Some((crease_id, tx)) = insert_crease_for_mention(
excerpt_id,
text_anchor,
content_len,
- None.clone(),
- Task::ready(Ok(image.clone())).shared(),
+ MentionUri::PastedImage.name().into(),
+ IconName::Image.path().into(),
+ Some(Task::ready(Ok(image.clone())).shared()),
self.editor.clone(),
window,
cx,
@@ -877,6 +888,7 @@ impl MessageEditor {
.update(|_, cx| LanguageModelImage::from_image(image, cx))
.map_err(|e| e.to_string())?
.await;
+ drop(tx);
if let Some(image) = image {
Ok(Mention::Image(MentionImage {
data: image.source,
@@ -1097,18 +1109,20 @@ impl MessageEditor {
for (range, mention_uri, mention) in mentions {
let anchor = snapshot.anchor_before(range.start);
- let Some(crease_id) = crate::context_picker::insert_crease_for_mention(
+ let Some((crease_id, tx)) = insert_crease_for_mention(
anchor.excerpt_id,
anchor.text_anchor,
range.end - range.start,
mention_uri.name().into(),
mention_uri.icon_path(cx),
+ None,
self.editor.clone(),
window,
cx,
) else {
continue;
};
+ drop(tx);
self.mention_set.mentions.insert(
crease_id,
@@ -1160,17 +1174,16 @@ impl MessageEditor {
})
}
+ pub fn text(&self, cx: &App) -> String {
+ self.editor.read(cx).text(cx)
+ }
+
#[cfg(test)]
pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context) {
self.editor.update(cx, |editor, cx| {
editor.set_text(text, window, cx);
});
}
-
- #[cfg(test)]
- pub fn text(&self, cx: &App) -> String {
- self.editor.read(cx).text(cx)
- }
}
fn render_directory_contents(entries: Vec<(Arc, PathBuf, String)>) -> String {
@@ -1227,23 +1240,21 @@ impl Render for MessageEditor {
}
}
-pub(crate) fn insert_crease_for_image(
+pub(crate) fn insert_crease_for_mention(
excerpt_id: ExcerptId,
anchor: text::Anchor,
content_len: usize,
- abs_path: Option>,
- image: Shared, String>>>,
+ crease_label: SharedString,
+ crease_icon: SharedString,
+ // abs_path: Option>,
+ image: Option, String>>>>,
editor: Entity,
window: &mut Window,
cx: &mut App,
-) -> Option {
- let crease_label = abs_path
- .as_ref()
- .and_then(|path| path.file_name())
- .map(|name| name.to_string_lossy().to_string().into())
- .unwrap_or(SharedString::from("Image"));
+) -> Option<(CreaseId, postage::barrier::Sender)> {
+ let (tx, rx) = postage::barrier::channel();
- editor.update(cx, |editor, cx| {
+ let crease_id = editor.update(cx, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
let start = snapshot.anchor_in_excerpt(excerpt_id, anchor)?;
@@ -1252,7 +1263,15 @@ pub(crate) fn insert_crease_for_image(
let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
let placeholder = FoldPlaceholder {
- render: render_image_fold_icon_button(crease_label, image, cx.weak_entity()),
+ render: render_fold_icon_button(
+ crease_label,
+ crease_icon,
+ start..end,
+ rx,
+ image,
+ cx.weak_entity(),
+ cx,
+ ),
merge_adjacent: false,
..Default::default()
};
@@ -1269,63 +1288,112 @@ pub(crate) fn insert_crease_for_image(
editor.fold_creases(vec![crease], false, window, cx);
Some(ids[0])
- })
+ })?;
+
+ Some((crease_id, tx))
}
-fn render_image_fold_icon_button(
+fn render_fold_icon_button(
label: SharedString,
- image_task: Shared, String>>>,
+ icon: SharedString,
+ range: Range,
+ mut loading_finished: postage::barrier::Receiver,
+ image_task: Option, String>>>>,
editor: WeakEntity,
+ cx: &mut App,
) -> Arc, &mut App) -> AnyElement> {
- Arc::new({
- move |fold_id, fold_range, cx| {
- let is_in_text_selection = editor
- .update(cx, |editor, cx| editor.is_range_selected(&fold_range, cx))
- .unwrap_or_default();
-
- ButtonLike::new(fold_id)
- .style(ButtonStyle::Filled)
- .selected_style(ButtonStyle::Tinted(TintColor::Accent))
- .toggle_state(is_in_text_selection)
- .child(
- h_flex()
- .gap_1()
- .child(
- Icon::new(IconName::Image)
- .size(IconSize::XSmall)
- .color(Color::Muted),
- )
- .child(
- Label::new(label.clone())
- .size(LabelSize::Small)
- .buffer_font(cx)
- .single_line(),
- ),
- )
- .hoverable_tooltip({
- let image_task = image_task.clone();
- move |_, cx| {
- let image = image_task.peek().cloned().transpose().ok().flatten();
- let image_task = image_task.clone();
- cx.new::(|cx| ImageHover {
- image,
- _task: cx.spawn(async move |this, cx| {
- if let Ok(image) = image_task.clone().await {
- this.update(cx, |this, cx| {
- if this.image.replace(image).is_none() {
- cx.notify();
- }
- })
- .ok();
- }
- }),
- })
- .into()
- }
- })
- .into_any_element()
+ let loading = cx.new(|cx| {
+ let loading = cx.spawn(async move |this, cx| {
+ loading_finished.recv().await;
+ this.update(cx, |this: &mut LoadingContext, cx| {
+ this.loading = None;
+ cx.notify();
+ })
+ .ok();
+ });
+ LoadingContext {
+ id: cx.entity_id(),
+ label,
+ icon,
+ range,
+ editor,
+ loading: Some(loading),
+ image: image_task.clone(),
}
- })
+ });
+ Arc::new(move |_fold_id, _fold_range, _cx| loading.clone().into_any_element())
+}
+
+struct LoadingContext {
+ id: EntityId,
+ label: SharedString,
+ icon: SharedString,
+ range: Range,
+ editor: WeakEntity,
+ loading: Option>,
+ image: Option, String>>>>,
+}
+
+impl Render for LoadingContext {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ let is_in_text_selection = self
+ .editor
+ .update(cx, |editor, cx| editor.is_range_selected(&self.range, cx))
+ .unwrap_or_default();
+ ButtonLike::new(("loading-context", self.id))
+ .style(ButtonStyle::Filled)
+ .selected_style(ButtonStyle::Tinted(TintColor::Accent))
+ .toggle_state(is_in_text_selection)
+ .when_some(self.image.clone(), |el, image_task| {
+ el.hoverable_tooltip(move |_, cx| {
+ let image = image_task.peek().cloned().transpose().ok().flatten();
+ let image_task = image_task.clone();
+ cx.new::(|cx| ImageHover {
+ image,
+ _task: cx.spawn(async move |this, cx| {
+ if let Ok(image) = image_task.clone().await {
+ this.update(cx, |this, cx| {
+ if this.image.replace(image).is_none() {
+ cx.notify();
+ }
+ })
+ .ok();
+ }
+ }),
+ })
+ .into()
+ })
+ })
+ .child(
+ h_flex()
+ .gap_1()
+ .child(
+ Icon::from_path(self.icon.clone())
+ .size(IconSize::XSmall)
+ .color(Color::Muted),
+ )
+ .child(
+ Label::new(self.label.clone())
+ .size(LabelSize::Small)
+ .buffer_font(cx)
+ .single_line(),
+ )
+ .map(|el| {
+ if self.loading.is_some() {
+ el.with_animation(
+ "loading-context-crease",
+ Animation::new(Duration::from_secs(2))
+ .repeat()
+ .with_easing(pulsating_between(0.4, 0.8)),
+ |label, delta| label.opacity(delta),
+ )
+ .into_any()
+ } else {
+ el.into_any()
+ }
+ }),
+ )
+ }
}
struct ImageHover {
diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs
index 3ad1234e22..5674b15c98 100644
--- a/crates/agent_ui/src/acp/thread_view.rs
+++ b/crates/agent_ui/src/acp/thread_view.rs
@@ -274,9 +274,9 @@ pub struct AcpThreadView {
edits_expanded: bool,
plan_expanded: bool,
editor_expanded: bool,
- terminal_expanded: bool,
editing_message: Option,
prompt_capabilities: Rc>,
+ is_loading_contents: bool,
_cancel_task: Option>,
_subscriptions: [Subscription; 3],
}
@@ -385,10 +385,10 @@ impl AcpThreadView {
edits_expanded: false,
plan_expanded: false,
editor_expanded: false,
- terminal_expanded: true,
history_store,
hovered_recent_history_item: None,
prompt_capabilities,
+ is_loading_contents: false,
_subscriptions: subscriptions,
_cancel_task: None,
focus_handle: cx.focus_handle(),
@@ -600,7 +600,7 @@ impl AcpThreadView {
let view = registry.read(cx).provider(&provider_id).map(|provider| {
provider.configuration_view(
- language_model::ConfigurationViewTargetAgent::Other(agent_name),
+ language_model::ConfigurationViewTargetAgent::Other(agent_name.clone()),
window,
cx,
)
@@ -762,6 +762,7 @@ impl AcpThreadView {
MessageEditorEvent::Focus => {
self.cancel_editing(&Default::default(), window, cx);
}
+ MessageEditorEvent::LostFocus => {}
}
}
@@ -793,6 +794,18 @@ impl AcpThreadView {
cx.notify();
}
}
+ ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::LostFocus) => {
+ if let Some(thread) = self.thread()
+ && let Some(AgentThreadEntry::UserMessage(user_message)) =
+ thread.read(cx).entries().get(event.entry_index)
+ && user_message.id.is_some()
+ {
+ if editor.read(cx).text(cx).as_str() == user_message.content.to_markdown(cx) {
+ self.editing_message = None;
+ cx.notify();
+ }
+ }
+ }
ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::Send) => {
self.regenerate(event.entry_index, editor, window, cx);
}
@@ -807,6 +820,9 @@ impl AcpThreadView {
let Some(thread) = self.thread() else {
return;
};
+ if !thread.read(cx).can_resume(cx) {
+ return;
+ }
let task = thread.update(cx, |thread, cx| thread.resume(cx));
cx.spawn(async move |this, cx| {
@@ -823,6 +839,11 @@ impl AcpThreadView {
fn send(&mut self, window: &mut Window, cx: &mut Context) {
let Some(thread) = self.thread() else { return };
+
+ if self.is_loading_contents {
+ return;
+ }
+
self.history_store.update(cx, |history, cx| {
history.push_recently_opened_entry(
HistoryEntryId::AcpThread(thread.read(cx).session_id().clone()),
@@ -876,6 +897,15 @@ impl AcpThreadView {
let Some(thread) = self.thread().cloned() else {
return;
};
+
+ self.is_loading_contents = true;
+ let guard = cx.new(|_| ());
+ cx.observe_release(&guard, |this, _guard, cx| {
+ this.is_loading_contents = false;
+ cx.notify();
+ })
+ .detach();
+
let task = cx.spawn_in(window, async move |this, cx| {
let (contents, tracked_buffers) = contents.await?;
@@ -896,6 +926,7 @@ impl AcpThreadView {
action_log.buffer_read(buffer, cx)
}
});
+ drop(guard);
thread.send(contents, cx)
})?;
send.await
@@ -950,19 +981,24 @@ impl AcpThreadView {
let Some(thread) = self.thread().cloned() else {
return;
};
+ if self.is_loading_contents {
+ return;
+ }
- let Some(rewind) = thread.update(cx, |thread, cx| {
- let user_message_id = thread.entries().get(entry_ix)?.user_message()?.id.clone()?;
- Some(thread.rewind(user_message_id, cx))
+ let Some(user_message_id) = thread.update(cx, |thread, _| {
+ thread.entries().get(entry_ix)?.user_message()?.id.clone()
}) else {
return;
};
let contents = message_editor.update(cx, |message_editor, cx| message_editor.contents(cx));
- let task = cx.foreground_executor().spawn(async move {
- rewind.await?;
- contents.await
+ let task = cx.spawn(async move |_, cx| {
+ let contents = contents.await?;
+ thread
+ .update(cx, |thread, cx| thread.rewind(user_message_id, cx))?
+ .await?;
+ Ok(contents)
});
self.send_impl(task, window, cx);
}
@@ -1273,7 +1309,11 @@ impl AcpThreadView {
v_flex()
.id(("user_message", entry_ix))
- .pt_2()
+ .map(|this| if rules_item.is_some() {
+ this.pt_3()
+ } else {
+ this.pt_2()
+ })
.pb_4()
.px_2()
.gap_1p5()
@@ -1282,6 +1322,7 @@ impl AcpThreadView {
.children(message.id.clone().and_then(|message_id| {
message.checkpoint.as_ref()?.show.then(|| {
h_flex()
+ .px_3()
.gap_2()
.child(Divider::horizontal())
.child(
@@ -1341,25 +1382,34 @@ impl AcpThreadView {
base_container
.child(
IconButton::new("cancel", IconName::Close)
+ .disabled(self.is_loading_contents)
.icon_color(Color::Error)
.icon_size(IconSize::XSmall)
.on_click(cx.listener(Self::cancel_editing))
)
.child(
- IconButton::new("regenerate", IconName::Return)
- .icon_color(Color::Muted)
- .icon_size(IconSize::XSmall)
- .tooltip(Tooltip::text(
- "Editing will restart the thread from this point."
- ))
- .on_click(cx.listener({
- let editor = editor.clone();
- move |this, _, window, cx| {
- this.regenerate(
- entry_ix, &editor, window, cx,
- );
- }
- })),
+ if self.is_loading_contents {
+ div()
+ .id("loading-edited-message-content")
+ .tooltip(Tooltip::text("Loading Added Context…"))
+ .child(loading_contents_spinner(IconSize::XSmall))
+ .into_any_element()
+ } else {
+ IconButton::new("regenerate", IconName::Return)
+ .icon_color(Color::Muted)
+ .icon_size(IconSize::XSmall)
+ .tooltip(Tooltip::text(
+ "Editing will restart the thread from this point."
+ ))
+ .on_click(cx.listener({
+ let editor = editor.clone();
+ move |this, _, window, cx| {
+ this.regenerate(
+ entry_ix, &editor, window, cx,
+ );
+ }
+ })).into_any_element()
+ }
)
)
} else {
@@ -1372,7 +1422,7 @@ impl AcpThreadView {
.icon_color(Color::Muted)
.style(ButtonStyle::Transparent)
.tooltip(move |_window, cx| {
- cx.new(|_| UnavailableEditingTooltip::new(agent_name.into()))
+ cx.new(|_| UnavailableEditingTooltip::new(agent_name.clone()))
.into()
})
)
@@ -1450,9 +1500,7 @@ impl AcpThreadView {
.child(self.render_thread_controls(cx))
.when_some(
self.thread_feedback.comments_editor.clone(),
- |this, editor| {
- this.child(Self::render_feedback_feedback_editor(editor, window, cx))
- },
+ |this, editor| this.child(Self::render_feedback_feedback_editor(editor, cx)),
)
.into_any_element()
} else {
@@ -1683,6 +1731,7 @@ impl AcpThreadView {
tool_call.status,
ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed
);
+
let needs_confirmation = matches!(
tool_call.status,
ToolCallStatus::WaitingForConfirmation { .. }
@@ -1691,17 +1740,16 @@ impl AcpThreadView {
matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some();
let use_card_layout = needs_confirmation || is_edit;
- let is_collapsible = !tool_call.content.is_empty() && !use_card_layout;
+ let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation;
- let is_open =
- needs_confirmation || is_edit || self.expanded_tool_calls.contains(&tool_call.id);
+ let is_open = needs_confirmation || self.expanded_tool_calls.contains(&tool_call.id);
let gradient_overlay = |color: Hsla| {
div()
.absolute()
.top_0()
.right_0()
- .w_16()
+ .w_12()
.h_full()
.bg(linear_gradient(
90.,
@@ -1861,7 +1909,7 @@ impl AcpThreadView {
.into_any()
}),
)
- .when(in_progress && use_card_layout, |this| {
+ .when(in_progress && use_card_layout && !is_open, |this| {
this.child(
div().absolute().right_2().child(
Icon::new(IconName::ArrowCircle)
@@ -2164,6 +2212,8 @@ impl AcpThreadView {
.map(|path| format!("{}", path.display()))
.unwrap_or_else(|| "current directory".to_string());
+ let is_expanded = self.expanded_tool_calls.contains(&tool_call.id);
+
let header = h_flex()
.id(SharedString::from(format!(
"terminal-tool-header-{}",
@@ -2297,21 +2347,27 @@ impl AcpThreadView {
"terminal-tool-disclosure-{}",
terminal.entity_id()
)),
- self.terminal_expanded,
+ is_expanded,
)
.opened_icon(IconName::ChevronUp)
.closed_icon(IconName::ChevronDown)
- .on_click(cx.listener(move |this, _event, _window, _cx| {
- this.terminal_expanded = !this.terminal_expanded;
- })),
- );
+ .on_click(cx.listener({
+ let id = tool_call.id.clone();
+ move |this, _event, _window, _cx| {
+ if is_expanded {
+ this.expanded_tool_calls.remove(&id);
+ } else {
+ this.expanded_tool_calls.insert(id.clone());
+ }
+ }})),
+ );
let terminal_view = self
.entry_view_state
.read(cx)
.entry(entry_ix)
.and_then(|entry| entry.terminal(terminal));
- let show_output = self.terminal_expanded && terminal_view.is_some();
+ let show_output = is_expanded && terminal_view.is_some();
v_flex()
.mb_2()
@@ -2411,7 +2467,6 @@ impl AcpThreadView {
Some(
h_flex()
.px_2p5()
- .pb_1()
.child(
Icon::new(IconName::Attach)
.size(IconSize::XSmall)
@@ -2427,8 +2482,7 @@ impl AcpThreadView {
Label::new(user_rules_text)
.size(LabelSize::XSmall)
.color(Color::Muted)
- .truncate()
- .buffer_font(cx),
+ .truncate(),
)
.hover(|s| s.bg(cx.theme().colors().element_hover))
.tooltip(Tooltip::text("View User Rules"))
@@ -2442,7 +2496,13 @@ impl AcpThreadView {
}),
)
})
- .when(has_both, |this| this.child(Divider::vertical()))
+ .when(has_both, |this| {
+ this.child(
+ Label::new("•")
+ .size(LabelSize::XSmall)
+ .color(Color::Disabled),
+ )
+ })
.when_some(rules_file_text, |parent, rules_file_text| {
parent.child(
h_flex()
@@ -2451,8 +2511,7 @@ impl AcpThreadView {
.child(
Label::new(rules_file_text)
.size(LabelSize::XSmall)
- .color(Color::Muted)
- .buffer_font(cx),
+ .color(Color::Muted),
)
.hover(|s| s.bg(cx.theme().colors().element_hover))
.tooltip(Tooltip::text("View Project Rules"))
@@ -3029,13 +3088,13 @@ impl AcpThreadView {
h_flex()
.p_1()
.justify_between()
+ .flex_wrap()
.when(expanded, |this| {
this.border_b_1().border_color(cx.theme().colors().border)
})
.child(
h_flex()
.id("edits-container")
- .w_full()
.gap_1()
.child(Disclosure::new("edits-disclosure", expanded))
.map(|this| {
@@ -3542,7 +3601,14 @@ impl AcpThreadView {
.thread()
.is_some_and(|thread| thread.read(cx).status() != ThreadStatus::Idle);
- if is_generating && is_editor_empty {
+ if self.is_loading_contents {
+ div()
+ .id("loading-message-content")
+ .px_1()
+ .tooltip(Tooltip::text("Loading Added Context…"))
+ .child(loading_contents_spinner(IconSize::default()))
+ .into_any_element()
+ } else if is_generating && is_editor_empty {
IconButton::new("stop-generation", IconName::Stop)
.icon_color(Color::Error)
.style(ButtonStyle::Tinted(ui::TintColor::Error))
@@ -3911,13 +3977,13 @@ impl AcpThreadView {
match AgentSettings::get_global(cx).notify_when_agent_waiting {
NotifyWhenAgentWaiting::PrimaryScreen => {
if let Some(primary) = cx.primary_display() {
- self.pop_up(icon, caption.into(), title.into(), window, primary, cx);
+ self.pop_up(icon, caption.into(), title, window, primary, cx);
}
}
NotifyWhenAgentWaiting::AllScreens => {
let caption = caption.into();
for screen in cx.displays() {
- self.pop_up(icon, caption.clone(), title.into(), window, screen, cx);
+ self.pop_up(icon, caption.clone(), title.clone(), window, screen, cx);
}
}
NotifyWhenAgentWaiting::Never => {
@@ -4121,13 +4187,8 @@ impl AcpThreadView {
container.child(open_as_markdown).child(scroll_to_top)
}
- fn render_feedback_feedback_editor(
- editor: Entity,
- window: &mut Window,
- cx: &Context,
- ) -> Div {
- let focus_handle = editor.focus_handle(cx);
- v_flex()
+ fn render_feedback_feedback_editor(editor: Entity, cx: &Context) -> Div {
+ h_flex()
.key_context("AgentFeedbackMessageEditor")
.on_action(cx.listener(move |this, _: &menu::Cancel, _, cx| {
this.thread_feedback.dismiss_comments();
@@ -4136,43 +4197,31 @@ impl AcpThreadView {
.on_action(cx.listener(move |this, _: &menu::Confirm, _window, cx| {
this.submit_feedback_message(cx);
}))
- .mb_2()
- .mx_4()
.p_2()
+ .mb_2()
+ .mx_5()
+ .gap_1()
.rounded_md()
.border_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().editor_background)
- .child(editor)
+ .child(div().w_full().child(editor))
.child(
h_flex()
- .gap_1()
- .justify_end()
.child(
- Button::new("dismiss-feedback-message", "Cancel")
- .label_size(LabelSize::Small)
- .key_binding(
- KeyBinding::for_action_in(&menu::Cancel, &focus_handle, window, cx)
- .map(|kb| kb.size(rems_from_px(10.))),
- )
+ IconButton::new("dismiss-feedback-message", IconName::Close)
+ .icon_color(Color::Error)
+ .icon_size(IconSize::XSmall)
+ .shape(ui::IconButtonShape::Square)
.on_click(cx.listener(move |this, _, _window, cx| {
this.thread_feedback.dismiss_comments();
cx.notify();
})),
)
.child(
- Button::new("submit-feedback-message", "Share Feedback")
- .style(ButtonStyle::Tinted(ui::TintColor::Accent))
- .label_size(LabelSize::Small)
- .key_binding(
- KeyBinding::for_action_in(
- &menu::Confirm,
- &focus_handle,
- window,
- cx,
- )
- .map(|kb| kb.size(rems_from_px(10.))),
- )
+ IconButton::new("submit-feedback-message", IconName::Return)
+ .icon_size(IconSize::XSmall)
+ .shape(ui::IconButtonShape::Square)
.on_click(cx.listener(move |this, _, _window, cx| {
this.submit_feedback_message(cx);
})),
@@ -4413,12 +4462,53 @@ impl AcpThreadView {
}
fn render_any_thread_error(&self, error: SharedString, cx: &mut Context<'_, Self>) -> Callout {
+ let can_resume = self
+ .thread()
+ .map_or(false, |thread| thread.read(cx).can_resume(cx));
+
+ let can_enable_burn_mode = self.as_native_thread(cx).map_or(false, |thread| {
+ let thread = thread.read(cx);
+ let supports_burn_mode = thread
+ .model()
+ .map_or(false, |model| model.supports_burn_mode());
+ supports_burn_mode && thread.completion_mode() == CompletionMode::Normal
+ });
+
Callout::new()
.severity(Severity::Error)
.title("Error")
.icon(IconName::XCircle)
.description(error.clone())
- .actions_slot(self.create_copy_button(error.to_string()))
+ .actions_slot(
+ h_flex()
+ .gap_0p5()
+ .when(can_resume && can_enable_burn_mode, |this| {
+ this.child(
+ Button::new("enable-burn-mode-and-retry", "Enable Burn Mode and Retry")
+ .icon(IconName::ZedBurnMode)
+ .icon_position(IconPosition::Start)
+ .icon_size(IconSize::Small)
+ .label_size(LabelSize::Small)
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.toggle_burn_mode(&ToggleBurnMode, window, cx);
+ this.resume_chat(cx);
+ })),
+ )
+ })
+ .when(can_resume, |this| {
+ this.child(
+ Button::new("retry", "Retry")
+ .icon(IconName::RotateCw)
+ .icon_position(IconPosition::Start)
+ .icon_size(IconSize::Small)
+ .label_size(LabelSize::Small)
+ .on_click(cx.listener(|this, _, _window, cx| {
+ this.resume_chat(cx);
+ })),
+ )
+ })
+ .child(self.create_copy_button(error.to_string())),
+ )
.dismiss_action(self.dismiss_error_button(cx))
}
@@ -4643,6 +4733,18 @@ impl AcpThreadView {
}
}
+fn loading_contents_spinner(size: IconSize) -> AnyElement {
+ Icon::new(IconName::LoadCircle)
+ .size(size)
+ .color(Color::Accent)
+ .with_animation(
+ "load_context_circle",
+ Animation::new(Duration::from_secs(3)).repeat(),
+ |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
+ )
+ .into_any_element()
+}
+
impl Focusable for AcpThreadView {
fn focus_handle(&self, cx: &App) -> FocusHandle {
match self.thread_state {
@@ -5153,16 +5255,16 @@ pub(crate) mod tests {
ui::IconName::Ai
}
- fn name(&self) -> &'static str {
- "Test"
+ fn name(&self) -> SharedString {
+ "Test".into()
}
- fn empty_state_headline(&self) -> &'static str {
- "Test"
+ fn empty_state_headline(&self) -> SharedString {
+ "Test".into()
}
- fn empty_state_message(&self) -> &'static str {
- "Test"
+ fn empty_state_message(&self) -> SharedString {
+ "Test".into()
}
fn connect(
diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs
index 574af90d3c..40b5687537 100644
--- a/crates/agent_ui/src/agent_panel.rs
+++ b/crates/agent_ui/src/agent_panel.rs
@@ -5,6 +5,7 @@ use std::sync::Arc;
use std::time::Duration;
use acp_thread::AcpThread;
+use agent_servers::AgentServerSettings;
use agent2::{DbThreadMetadata, HistoryEntry};
use db::kvp::{Dismissable, KEY_VALUE_STORE};
use serde::{Deserialize, Serialize};
@@ -132,7 +133,7 @@ pub fn init(cx: &mut App) {
if let Some(panel) = workspace.panel::(cx) {
workspace.focus_panel::(window, cx);
panel.update(cx, |panel, cx| {
- panel.external_thread(action.agent, None, None, window, cx)
+ panel.external_thread(action.agent.clone(), None, None, window, cx)
});
}
})
@@ -246,7 +247,7 @@ enum WhichFontSize {
None,
}
-#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
pub enum AgentType {
#[default]
Zed,
@@ -254,23 +255,29 @@ pub enum AgentType {
Gemini,
ClaudeCode,
NativeAgent,
+ Custom {
+ name: SharedString,
+ settings: AgentServerSettings,
+ },
}
impl AgentType {
- fn label(self) -> impl Into {
+ fn label(&self) -> SharedString {
match self {
- Self::Zed | Self::TextThread => "Zed Agent",
- Self::NativeAgent => "Agent 2",
- Self::Gemini => "Gemini CLI",
- Self::ClaudeCode => "Claude Code",
+ Self::Zed | Self::TextThread => "Zed Agent".into(),
+ Self::NativeAgent => "Agent 2".into(),
+ Self::Gemini => "Gemini CLI".into(),
+ Self::ClaudeCode => "Claude Code".into(),
+ Self::Custom { name, .. } => name.into(),
}
}
- fn icon(self) -> Option {
+ fn icon(&self) -> Option {
match self {
Self::Zed | Self::NativeAgent | Self::TextThread => None,
Self::Gemini => Some(IconName::AiGemini),
Self::ClaudeCode => Some(IconName::AiClaude),
+ Self::Custom { .. } => Some(IconName::Terminal),
}
}
}
@@ -524,7 +531,7 @@ pub struct AgentPanel {
impl AgentPanel {
fn serialize(&mut self, cx: &mut Context) {
let width = self.width;
- let selected_agent = self.selected_agent;
+ let selected_agent = self.selected_agent.clone();
self.pending_serialization = Some(cx.background_spawn(async move {
KEY_VALUE_STORE
.write_kvp(
@@ -614,7 +621,7 @@ impl AgentPanel {
panel.update(cx, |panel, cx| {
panel.width = serialized_panel.width.map(|w| w.round());
if let Some(selected_agent) = serialized_panel.selected_agent {
- panel.selected_agent = selected_agent;
+ panel.selected_agent = selected_agent.clone();
panel.new_agent_thread(selected_agent, window, cx);
}
cx.notify();
@@ -1084,14 +1091,17 @@ impl AgentPanel {
cx.spawn_in(window, async move |this, cx| {
let ext_agent = match agent_choice {
Some(agent) => {
- cx.background_spawn(async move {
- if let Some(serialized) =
- serde_json::to_string(&LastUsedExternalAgent { agent }).log_err()
- {
- KEY_VALUE_STORE
- .write_kvp(LAST_USED_EXTERNAL_AGENT_KEY.to_string(), serialized)
- .await
- .log_err();
+ cx.background_spawn({
+ let agent = agent.clone();
+ async move {
+ if let Some(serialized) =
+ serde_json::to_string(&LastUsedExternalAgent { agent }).log_err()
+ {
+ KEY_VALUE_STORE
+ .write_kvp(LAST_USED_EXTERNAL_AGENT_KEY.to_string(), serialized)
+ .await
+ .log_err();
+ }
}
})
.detach();
@@ -1117,7 +1127,9 @@ impl AgentPanel {
this.update_in(cx, |this, window, cx| {
match ext_agent {
- crate::ExternalAgent::Gemini | crate::ExternalAgent::NativeAgent => {
+ crate::ExternalAgent::Gemini
+ | crate::ExternalAgent::NativeAgent
+ | crate::ExternalAgent::Custom { .. } => {
if !cx.has_flag::() {
return;
}
@@ -1846,14 +1858,14 @@ impl AgentPanel {
cx: &mut Context,
) {
if self.selected_agent != agent {
- self.selected_agent = agent;
+ self.selected_agent = agent.clone();
self.serialize(cx);
}
self.new_agent_thread(agent, window, cx);
}
pub fn selected_agent(&self) -> AgentType {
- self.selected_agent
+ self.selected_agent.clone()
}
pub fn new_agent_thread(
@@ -1892,6 +1904,13 @@ impl AgentPanel {
window,
cx,
),
+ AgentType::Custom { name, settings } => self.external_thread(
+ Some(crate::ExternalAgent::Custom { name, settings }),
+ None,
+ None,
+ window,
+ cx,
+ ),
}
}
@@ -2617,13 +2636,55 @@ impl AgentPanel {
}
}),
)
+ })
+ .when(cx.has_flag::(), |mut menu| {
+ // Add custom agents from settings
+ let settings =
+ agent_servers::AllAgentServersSettings::get_global(cx);
+ for (agent_name, agent_settings) in &settings.custom {
+ menu = menu.item(
+ ContextMenuEntry::new(format!("New {} Thread", agent_name))
+ .icon(IconName::Terminal)
+ .icon_color(Color::Muted)
+ .handler({
+ let workspace = workspace.clone();
+ let agent_name = agent_name.clone();
+ let agent_settings = agent_settings.clone();
+ move |window, cx| {
+ if let Some(workspace) = workspace.upgrade() {
+ workspace.update(cx, |workspace, cx| {
+ if let Some(panel) =
+ workspace.panel::(cx)
+ {
+ panel.update(cx, |panel, cx| {
+ panel.set_selected_agent(
+ AgentType::Custom {
+ name: agent_name
+ .clone(),
+ settings:
+ agent_settings
+ .clone(),
+ },
+ window,
+ cx,
+ );
+ });
+ }
+ });
+ }
+ }
+ }),
+ );
+ }
+
+ menu
});
menu
}))
}
});
- let selected_agent_label = self.selected_agent.label().into();
+ let selected_agent_label = self.selected_agent.label();
let selected_agent = div()
.id("selected_agent_icon")
.when_some(self.selected_agent.icon(), |this, icon| {
diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs
index 6084fd6423..40f6c6a2bb 100644
--- a/crates/agent_ui/src/agent_ui.rs
+++ b/crates/agent_ui/src/agent_ui.rs
@@ -28,13 +28,14 @@ use std::rc::Rc;
use std::sync::Arc;
use agent::{Thread, ThreadId};
+use agent_servers::AgentServerSettings;
use agent_settings::{AgentProfileId, AgentSettings, LanguageModelSelection};
use assistant_slash_command::SlashCommandRegistry;
use client::Client;
use command_palette_hooks::CommandPaletteFilter;
use feature_flags::FeatureFlagAppExt as _;
use fs::Fs;
-use gpui::{Action, App, Entity, actions};
+use gpui::{Action, App, Entity, SharedString, actions};
use language::LanguageRegistry;
use language_model::{
ConfiguredModel, LanguageModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry,
@@ -159,13 +160,17 @@ pub struct NewNativeAgentThreadFromSummary {
from_session_id: agent_client_protocol::SessionId,
}
-#[derive(Default, Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
+#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
enum ExternalAgent {
#[default]
Gemini,
ClaudeCode,
NativeAgent,
+ Custom {
+ name: SharedString,
+ settings: AgentServerSettings,
+ },
}
impl ExternalAgent {
@@ -175,9 +180,13 @@ impl ExternalAgent {
history: Entity,
) -> Rc {
match self {
- ExternalAgent::Gemini => Rc::new(agent_servers::Gemini),
- ExternalAgent::ClaudeCode => Rc::new(agent_servers::ClaudeCode),
- ExternalAgent::NativeAgent => Rc::new(agent2::NativeAgentServer::new(fs, history)),
+ Self::Gemini => Rc::new(agent_servers::Gemini),
+ Self::ClaudeCode => Rc::new(agent_servers::ClaudeCode),
+ Self::NativeAgent => Rc::new(agent2::NativeAgentServer::new(fs, history)),
+ Self::Custom { name, settings } => Rc::new(agent_servers::CustomAgentServer::new(
+ name.clone(),
+ settings,
+ )),
}
}
}
diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs
index 32582ba941..4f3580da07 100644
--- a/crates/editor/src/element.rs
+++ b/crates/editor/src/element.rs
@@ -74,7 +74,7 @@ use std::{
fmt::{self, Write},
iter, mem,
ops::{Deref, Range},
- path::Path,
+ path::{self, Path},
rc::Rc,
sync::Arc,
time::{Duration, Instant},
@@ -90,8 +90,8 @@ use unicode_segmentation::UnicodeSegmentation;
use util::post_inc;
use util::{RangeExt, ResultExt, debug_panic};
use workspace::{
- CollaboratorId, OpenInTerminal, OpenTerminal, RevealInProjectPanel, Workspace, item::Item,
- notifications::NotifyTaskExt,
+ CollaboratorId, ItemSettings, OpenInTerminal, OpenTerminal, RevealInProjectPanel, Workspace,
+ item::Item, notifications::NotifyTaskExt,
};
/// Determines what kinds of highlights should be applied to a lines background.
@@ -3603,176 +3603,187 @@ impl EditorElement {
let focus_handle = editor.focus_handle(cx);
let colors = cx.theme().colors();
- let header =
- div()
- .p_1()
- .w_full()
- .h(FILE_HEADER_HEIGHT as f32 * window.line_height())
- .child(
- h_flex()
- .size_full()
- .gap_2()
- .flex_basis(Length::Definite(DefiniteLength::Fraction(0.667)))
- .pl_0p5()
- .pr_5()
- .rounded_sm()
- .when(is_sticky, |el| el.shadow_md())
- .border_1()
- .map(|div| {
- let border_color = if is_selected
- && is_folded
- && focus_handle.contains_focused(window, cx)
- {
- colors.border_focused
- } else {
- colors.border
- };
- div.border_color(border_color)
- })
- .bg(colors.editor_subheader_background)
- .hover(|style| style.bg(colors.element_hover))
- .map(|header| {
- let editor = self.editor.clone();
- let buffer_id = for_excerpt.buffer_id;
- let toggle_chevron_icon =
- FileIcons::get_chevron_icon(!is_folded, cx).map(Icon::from_path);
- header.child(
- div()
- .hover(|style| style.bg(colors.element_selected))
- .rounded_xs()
- .child(
- ButtonLike::new("toggle-buffer-fold")
- .style(ui::ButtonStyle::Transparent)
- .height(px(28.).into())
- .width(px(28.))
- .children(toggle_chevron_icon)
- .tooltip({
- let focus_handle = focus_handle.clone();
- move |window, cx| {
- Tooltip::with_meta_in(
- "Toggle Excerpt Fold",
- Some(&ToggleFold),
- "Alt+click to toggle all",
- &focus_handle,
+ let header = div()
+ .p_1()
+ .w_full()
+ .h(FILE_HEADER_HEIGHT as f32 * window.line_height())
+ .child(
+ h_flex()
+ .size_full()
+ .gap_2()
+ .flex_basis(Length::Definite(DefiniteLength::Fraction(0.667)))
+ .pl_0p5()
+ .pr_5()
+ .rounded_sm()
+ .when(is_sticky, |el| el.shadow_md())
+ .border_1()
+ .map(|div| {
+ let border_color = if is_selected
+ && is_folded
+ && focus_handle.contains_focused(window, cx)
+ {
+ colors.border_focused
+ } else {
+ colors.border
+ };
+ div.border_color(border_color)
+ })
+ .bg(colors.editor_subheader_background)
+ .hover(|style| style.bg(colors.element_hover))
+ .map(|header| {
+ let editor = self.editor.clone();
+ let buffer_id = for_excerpt.buffer_id;
+ let toggle_chevron_icon =
+ FileIcons::get_chevron_icon(!is_folded, cx).map(Icon::from_path);
+ header.child(
+ div()
+ .hover(|style| style.bg(colors.element_selected))
+ .rounded_xs()
+ .child(
+ ButtonLike::new("toggle-buffer-fold")
+ .style(ui::ButtonStyle::Transparent)
+ .height(px(28.).into())
+ .width(px(28.))
+ .children(toggle_chevron_icon)
+ .tooltip({
+ let focus_handle = focus_handle.clone();
+ move |window, cx| {
+ Tooltip::with_meta_in(
+ "Toggle Excerpt Fold",
+ Some(&ToggleFold),
+ "Alt+click to toggle all",
+ &focus_handle,
+ window,
+ cx,
+ )
+ }
+ })
+ .on_click(move |event, window, cx| {
+ if event.modifiers().alt {
+ // Alt+click toggles all buffers
+ editor.update(cx, |editor, cx| {
+ editor.toggle_fold_all(
+ &ToggleFoldAll,
window,
cx,
- )
- }
- })
- .on_click(move |event, window, cx| {
- if event.modifiers().alt {
- // Alt+click toggles all buffers
+ );
+ });
+ } else {
+ // Regular click toggles single buffer
+ if is_folded {
editor.update(cx, |editor, cx| {
- editor.toggle_fold_all(
- &ToggleFoldAll,
- window,
- cx,
- );
+ editor.unfold_buffer(buffer_id, cx);
});
} else {
- // Regular click toggles single buffer
- if is_folded {
- editor.update(cx, |editor, cx| {
- editor.unfold_buffer(buffer_id, cx);
- });
- } else {
- editor.update(cx, |editor, cx| {
- editor.fold_buffer(buffer_id, cx);
- });
- }
+ editor.update(cx, |editor, cx| {
+ editor.fold_buffer(buffer_id, cx);
+ });
}
- }),
- ),
- )
- })
- .children(
- editor
- .addons
- .values()
- .filter_map(|addon| {
- addon.render_buffer_header_controls(for_excerpt, window, cx)
- })
- .take(1),
+ }
+ }),
+ ),
)
- .child(
- h_flex()
- .size(Pixels(12.0))
- .justify_center()
- .children(indicator),
- )
- .child(
- h_flex()
- .cursor_pointer()
- .id("path header block")
- .size_full()
- .justify_between()
- .overflow_hidden()
- .child(
- h_flex()
- .gap_2()
- .child(
- Label::new(
- filename
- .map(SharedString::from)
- .unwrap_or_else(|| "untitled".into()),
- )
- .single_line()
- .when_some(file_status, |el, status| {
- el.color(if status.is_conflicted() {
- Color::Conflict
- } else if status.is_modified() {
- Color::Modified
- } else if status.is_deleted() {
- Color::Disabled
- } else {
- Color::Created
- })
- .when(status.is_deleted(), |el| el.strikethrough())
- }),
- )
- .when_some(parent_path, |then, path| {
- then.child(div().child(path).text_color(
- if file_status.is_some_and(FileStatus::is_deleted) {
- colors.text_disabled
- } else {
- colors.text_muted
+ })
+ .children(
+ editor
+ .addons
+ .values()
+ .filter_map(|addon| {
+ addon.render_buffer_header_controls(for_excerpt, window, cx)
+ })
+ .take(1),
+ )
+ .child(
+ h_flex()
+ .size(Pixels(12.0))
+ .justify_center()
+ .children(indicator),
+ )
+ .child(
+ h_flex()
+ .cursor_pointer()
+ .id("path header block")
+ .size_full()
+ .justify_between()
+ .overflow_hidden()
+ .child(
+ h_flex()
+ .gap_2()
+ .map(|path_header| {
+ let filename = filename
+ .map(SharedString::from)
+ .unwrap_or_else(|| "untitled".into());
+
+ path_header
+ .when(ItemSettings::get_global(cx).file_icons, |el| {
+ let path = path::Path::new(filename.as_str());
+ let icon = FileIcons::get_icon(path, cx)
+ .unwrap_or_default();
+ let icon =
+ Icon::from_path(icon).color(Color::Muted);
+ el.child(icon)
+ })
+ .child(Label::new(filename).single_line().when_some(
+ file_status,
+ |el, status| {
+ el.color(if status.is_conflicted() {
+ Color::Conflict
+ } else if status.is_modified() {
+ Color::Modified
+ } else if status.is_deleted() {
+ Color::Disabled
+ } else {
+ Color::Created
+ })
+ .when(status.is_deleted(), |el| {
+ el.strikethrough()
+ })
},
))
- }),
- )
- .when(
- can_open_excerpts && is_selected && relative_path.is_some(),
- |el| {
- el.child(
- h_flex()
- .id("jump-to-file-button")
- .gap_2p5()
- .child(Label::new("Jump To File"))
- .children(
- KeyBinding::for_action_in(
- &OpenExcerpts,
- &focus_handle,
- window,
- cx,
- )
- .map(|binding| binding.into_any_element()),
- ),
- )
- },
- )
- .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
- .on_click(window.listener_for(&self.editor, {
- move |editor, e: &ClickEvent, window, cx| {
- editor.open_excerpts_common(
- Some(jump_data.clone()),
- e.modifiers().secondary(),
- window,
- cx,
- );
- }
- })),
- ),
- );
+ })
+ .when_some(parent_path, |then, path| {
+ then.child(div().child(path).text_color(
+ if file_status.is_some_and(FileStatus::is_deleted) {
+ colors.text_disabled
+ } else {
+ colors.text_muted
+ },
+ ))
+ }),
+ )
+ .when(
+ can_open_excerpts && is_selected && relative_path.is_some(),
+ |el| {
+ el.child(
+ h_flex()
+ .id("jump-to-file-button")
+ .gap_2p5()
+ .child(Label::new("Jump To File"))
+ .children(
+ KeyBinding::for_action_in(
+ &OpenExcerpts,
+ &focus_handle,
+ window,
+ cx,
+ )
+ .map(|binding| binding.into_any_element()),
+ ),
+ )
+ },
+ )
+ .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
+ .on_click(window.listener_for(&self.editor, {
+ move |editor, e: &ClickEvent, window, cx| {
+ editor.open_excerpts_common(
+ Some(jump_data.clone()),
+ e.modifiers().secondary(),
+ window,
+ cx,
+ );
+ }
+ })),
+ ),
+ );
let file = for_excerpt.buffer.file().cloned();
let editor = self.editor.clone();
diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs
index 8aaaa04729..7512152324 100644
--- a/crates/file_finder/src/file_finder.rs
+++ b/crates/file_finder/src/file_finder.rs
@@ -1401,13 +1401,16 @@ impl PickerDelegate for FileFinderDelegate {
#[cfg(windows)]
let raw_query = raw_query.trim().to_owned().replace("/", "\\");
#[cfg(not(windows))]
- let raw_query = raw_query.trim().to_owned();
+ let raw_query = raw_query.trim();
- let file_query_end = if path_position.path.to_str().unwrap_or(&raw_query) == raw_query {
+ let raw_query = raw_query.trim_end_matches(':').to_owned();
+ let path = path_position.path.to_str();
+ let path_trimmed = path.unwrap_or(&raw_query).trim_end_matches(':');
+ let file_query_end = if path_trimmed == raw_query {
None
} else {
// Safe to unwrap as we won't get here when the unwrap in if fails
- Some(path_position.path.to_str().unwrap().len())
+ Some(path.unwrap().len())
};
let query = FileSearchQuery {
diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs
index 8203d1b1fd..cd0f203d6a 100644
--- a/crates/file_finder/src/file_finder_tests.rs
+++ b/crates/file_finder/src/file_finder_tests.rs
@@ -218,6 +218,7 @@ async fn test_matching_paths(cx: &mut TestAppContext) {
" ndan ",
" band ",
"a bandana",
+ "bandana:",
] {
picker
.update_in(cx, |picker, window, cx| {
@@ -252,6 +253,53 @@ async fn test_matching_paths(cx: &mut TestAppContext) {
}
}
+#[gpui::test]
+async fn test_matching_paths_with_colon(cx: &mut TestAppContext) {
+ let app_state = init_test(cx);
+ app_state
+ .fs
+ .as_fake()
+ .insert_tree(
+ path!("/root"),
+ json!({
+ "a": {
+ "foo:bar.rs": "",
+ "foo.rs": "",
+ }
+ }),
+ )
+ .await;
+
+ let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
+
+ let (picker, _, cx) = build_find_picker(project, cx);
+
+ // 'foo:' matches both files
+ cx.simulate_input("foo:");
+ picker.update(cx, |picker, _| {
+ assert_eq!(picker.delegate.matches.len(), 3);
+ assert_match_at_position(picker, 0, "foo.rs");
+ assert_match_at_position(picker, 1, "foo:bar.rs");
+ });
+
+ // 'foo:b' matches one of the files
+ cx.simulate_input("b");
+ picker.update(cx, |picker, _| {
+ assert_eq!(picker.delegate.matches.len(), 2);
+ assert_match_at_position(picker, 0, "foo:bar.rs");
+ });
+
+ cx.dispatch_action(editor::actions::Backspace);
+
+ // 'foo:1' matches both files, specifying which row to jump to
+ cx.simulate_input("1");
+ picker.update(cx, |picker, _| {
+ assert_eq!(picker.delegate.matches.len(), 3);
+ assert_match_at_position(picker, 0, "foo.rs");
+ assert_match_at_position(picker, 1, "foo:bar.rs");
+ });
+}
+
#[gpui::test]
async fn test_unicode_paths(cx: &mut TestAppContext) {
let app_state = init_test(cx);
diff --git a/crates/inspector_ui/Cargo.toml b/crates/inspector_ui/Cargo.toml
index 8e55a8a477..cefe888974 100644
--- a/crates/inspector_ui/Cargo.toml
+++ b/crates/inspector_ui/Cargo.toml
@@ -24,6 +24,7 @@ serde_json_lenient.workspace = true
theme.workspace = true
ui.workspace = true
util.workspace = true
+util_macros.workspace = true
workspace-hack.workspace = true
workspace.workspace = true
zed_actions.workspace = true
diff --git a/crates/inspector_ui/src/div_inspector.rs b/crates/inspector_ui/src/div_inspector.rs
index 0c2b16b9f4..c3d687e57a 100644
--- a/crates/inspector_ui/src/div_inspector.rs
+++ b/crates/inspector_ui/src/div_inspector.rs
@@ -25,7 +25,7 @@ use util::split_str_with_ranges;
/// Path used for unsaved buffer that contains style json. To support the json language server, this
/// matches the name used in the generated schemas.
-const ZED_INSPECTOR_STYLE_JSON: &str = "/zed-inspector-style.json";
+const ZED_INSPECTOR_STYLE_JSON: &str = util_macros::path!("/zed-inspector-style.json");
pub(crate) struct DivInspector {
state: State,
diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs
index e0a3866443..d5313b6a3a 100644
--- a/crates/language_model/src/language_model.rs
+++ b/crates/language_model/src/language_model.rs
@@ -643,11 +643,11 @@ pub trait LanguageModelProvider: 'static {
fn reset_credentials(&self, cx: &mut App) -> Task>;
}
-#[derive(Default, Clone, Copy)]
+#[derive(Default, Clone)]
pub enum ConfigurationViewTargetAgent {
#[default]
ZedAgent,
- Other(&'static str),
+ Other(SharedString),
}
#[derive(PartialEq, Eq)]
diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs
index 0d061c0587..c492edeaf5 100644
--- a/crates/language_models/src/provider/anthropic.rs
+++ b/crates/language_models/src/provider/anthropic.rs
@@ -1041,9 +1041,9 @@ impl Render for ConfigurationView {
v_flex()
.size_full()
.on_action(cx.listener(Self::save_api_key))
- .child(Label::new(format!("To use {}, you need to add an API key. Follow these steps:", match self.target_agent {
- ConfigurationViewTargetAgent::ZedAgent => "Zed's agent with Anthropic",
- ConfigurationViewTargetAgent::Other(agent) => agent,
+ .child(Label::new(format!("To use {}, you need to add an API key. Follow these steps:", match &self.target_agent {
+ ConfigurationViewTargetAgent::ZedAgent => "Zed's agent with Anthropic".into(),
+ ConfigurationViewTargetAgent::Other(agent) => agent.clone(),
})))
.child(
List::new()
diff --git a/crates/language_models/src/provider/google.rs b/crates/language_models/src/provider/google.rs
index 566620675e..f252ab7aa3 100644
--- a/crates/language_models/src/provider/google.rs
+++ b/crates/language_models/src/provider/google.rs
@@ -921,9 +921,9 @@ impl Render for ConfigurationView {
v_flex()
.size_full()
.on_action(cx.listener(Self::save_api_key))
- .child(Label::new(format!("To use {}, you need to add an API key. Follow these steps:", match self.target_agent {
- ConfigurationViewTargetAgent::ZedAgent => "Zed's agent with Google AI",
- ConfigurationViewTargetAgent::Other(agent) => agent,
+ .child(Label::new(format!("To use {}, you need to add an API key. Follow these steps:", match &self.target_agent {
+ ConfigurationViewTargetAgent::ZedAgent => "Zed's agent with Google AI".into(),
+ ConfigurationViewTargetAgent::Other(agent) => agent.clone(),
})))
.child(
List::new()
diff --git a/crates/languages/src/javascript/highlights.scm b/crates/languages/src/javascript/highlights.scm
index 9d5ebbaf71..ebeac7efff 100644
--- a/crates/languages/src/javascript/highlights.scm
+++ b/crates/languages/src/javascript/highlights.scm
@@ -231,6 +231,7 @@
"implements"
"interface"
"keyof"
+ "module"
"namespace"
"private"
"protected"
@@ -250,4 +251,4 @@
(jsx_closing_element (["" ">"]) @punctuation.bracket.jsx)
(jsx_self_closing_element (["<" "/>"]) @punctuation.bracket.jsx)
(jsx_attribute "=" @punctuation.delimiter.jsx)
-(jsx_text) @text.jsx
\ No newline at end of file
+(jsx_text) @text.jsx
diff --git a/crates/languages/src/tsx/highlights.scm b/crates/languages/src/tsx/highlights.scm
index 5e2fbbf63a..f7cb987831 100644
--- a/crates/languages/src/tsx/highlights.scm
+++ b/crates/languages/src/tsx/highlights.scm
@@ -237,6 +237,7 @@
"implements"
"interface"
"keyof"
+ "module"
"namespace"
"private"
"protected"
@@ -256,4 +257,4 @@
(jsx_closing_element (["" ">"]) @punctuation.bracket.jsx)
(jsx_self_closing_element (["<" "/>"]) @punctuation.bracket.jsx)
(jsx_attribute "=" @punctuation.delimiter.jsx)
-(jsx_text) @text.jsx
\ No newline at end of file
+(jsx_text) @text.jsx
diff --git a/crates/languages/src/typescript/highlights.scm b/crates/languages/src/typescript/highlights.scm
index af37ef6415..84cbbae77d 100644
--- a/crates/languages/src/typescript/highlights.scm
+++ b/crates/languages/src/typescript/highlights.scm
@@ -248,6 +248,7 @@
"is"
"keyof"
"let"
+ "module"
"namespace"
"new"
"of"
@@ -272,4 +273,4 @@
"while"
"with"
"yield"
-] @keyword
\ No newline at end of file
+] @keyword
diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs
index 39a438c512..f16da45d79 100644
--- a/crates/markdown/src/markdown.rs
+++ b/crates/markdown/src/markdown.rs
@@ -1085,10 +1085,10 @@ impl Element for MarkdownElement {
);
el.child(
h_flex()
- .w_5()
+ .w_4()
.absolute()
- .top_1()
- .right_1()
+ .top_1p5()
+ .right_1p5()
.justify_end()
.child(codeblock),
)
@@ -1115,11 +1115,12 @@ impl Element for MarkdownElement {
cx,
);
el.child(
- div()
+ h_flex()
+ .w_4()
.absolute()
.top_0()
.right_0()
- .w_5()
+ .justify_end()
.visible_on_hover("code_block")
.child(codeblock),
)
diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs
index a54d38163d..e27cbf868a 100644
--- a/crates/multi_buffer/src/multi_buffer.rs
+++ b/crates/multi_buffer/src/multi_buffer.rs
@@ -835,7 +835,7 @@ impl MultiBuffer {
this.convert_edits_to_buffer_edits(edits, &snapshot, &original_indent_columns);
drop(snapshot);
- let mut buffer_ids = Vec::new();
+ let mut buffer_ids = Vec::with_capacity(buffer_edits.len());
for (buffer_id, mut edits) in buffer_edits {
buffer_ids.push(buffer_id);
edits.sort_by_key(|edit| edit.range.start);
diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs
index fb1fae3736..d2958dce01 100644
--- a/crates/project/src/lsp_store.rs
+++ b/crates/project/src/lsp_store.rs
@@ -11913,7 +11913,7 @@ impl LspStore {
notify_server_capabilities_updated(&server, cx);
}
}
- "textDocument/colorProvider" => {
+ "textDocument/documentColor" => {
if let Some(caps) = reg
.register_options
.map(serde_json::from_value)
@@ -12064,7 +12064,7 @@ impl LspStore {
});
notify_server_capabilities_updated(&server, cx);
}
- "textDocument/colorProvider" => {
+ "textDocument/documentColor" => {
server.update_capabilities(|capabilities| {
capabilities.color_provider = None;
});
diff --git a/crates/zed/resources/info/SupportedPlatforms.plist b/crates/zed/resources/info/SupportedPlatforms.plist
new file mode 100644
index 0000000000..fd2a4101d8
--- /dev/null
+++ b/crates/zed/resources/info/SupportedPlatforms.plist
@@ -0,0 +1,4 @@
+CFBundleSupportedPlatforms
+
+ MacOSX
+
diff --git a/docs/src/diagnostics.md b/docs/src/diagnostics.md
index a015fbebf8..9603c8197c 100644
--- a/docs/src/diagnostics.md
+++ b/docs/src/diagnostics.md
@@ -51,7 +51,7 @@ To configure, use
```json5
"project_panel": {
- "diagnostics": "all",
+ "show_diagnostics": "all",
}
```
|