Merge remote-tracking branch 'origin/main' into acp-onb-modal

This commit is contained in:
Danilo Leal 2025-08-25 10:16:32 -03:00
commit ee8a342cbb
38 changed files with 1115 additions and 570 deletions

2
Cargo.lock generated
View file

@ -403,6 +403,7 @@ dependencies = [
"parking_lot", "parking_lot",
"paths", "paths",
"picker", "picker",
"postage",
"pretty_assertions", "pretty_assertions",
"project", "project",
"prompt_store", "prompt_store",
@ -8467,6 +8468,7 @@ dependencies = [
"theme", "theme",
"ui", "ui",
"util", "util",
"util_macros",
"workspace", "workspace",
"workspace-hack", "workspace-hack",
"zed_actions", "zed_actions",

View file

@ -1 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M12.286 6H7.048C6.469 6 6 6.469 6 7.048v5.238c0 .578.469 1.047 1.048 1.047h5.238c.578 0 1.047-.469 1.047-1.047V7.048c0-.579-.469-1.048-1.047-1.048Z"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M3.714 10a1.05 1.05 0 0 1-1.047-1.048V3.714a1.05 1.05 0 0 1 1.047-1.047h5.238A1.05 1.05 0 0 1 10 3.714"/></svg> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.486 6.2H7.24795C6.66895 6.2 6.19995 6.669 6.19995 7.248V12.486C6.19995 13.064 6.66895 13.533 7.24795 13.533H12.486C13.064 13.533 13.533 13.064 13.533 12.486V7.248C13.533 6.669 13.064 6.2 12.486 6.2Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.91712 10.203C3.63951 10.2022 3.37351 10.0915 3.1773 9.89511C2.98109 9.69872 2.87064 9.43261 2.87012 9.155V3.917C2.87091 3.63956 2.98147 3.37371 3.17765 3.17753C3.37383 2.98135 3.63968 2.87079 3.91712 2.87H9.15512C9.43273 2.87053 9.69883 2.98097 9.89523 3.17718C10.0916 3.37339 10.2023 3.63939 10.2031 3.917" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 515 B

After

Width:  |  Height:  |  Size: 802 B

Before After
Before After

View file

@ -509,7 +509,7 @@ impl ContentBlock {
"`Image`".into() "`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 { match self {
ContentBlock::Empty => "", ContentBlock::Empty => "",
ContentBlock::Markdown { markdown } => markdown.read(cx).source(), 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<Self>) -> BoxFuture<'static, Result<()>> { pub fn resume(&mut self, cx: &mut Context<Self>) -> BoxFuture<'static, Result<()>> {
self.run_turn(cx, async move |this, cx| { self.run_turn(cx, async move |this, cx| {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
@ -2659,7 +2663,7 @@ mod tests {
fn truncate( fn truncate(
&self, &self,
session_id: &acp::SessionId, session_id: &acp::SessionId,
_cx: &mut App, _cx: &App,
) -> Option<Rc<dyn AgentSessionTruncate>> { ) -> Option<Rc<dyn AgentSessionTruncate>> {
Some(Rc::new(FakeAgentSessionEditor { Some(Rc::new(FakeAgentSessionEditor {
_session_id: session_id.clone(), _session_id: session_id.clone(),

View file

@ -43,7 +43,7 @@ pub trait AgentConnection {
fn resume( fn resume(
&self, &self,
_session_id: &acp::SessionId, _session_id: &acp::SessionId,
_cx: &mut App, _cx: &App,
) -> Option<Rc<dyn AgentSessionResume>> { ) -> Option<Rc<dyn AgentSessionResume>> {
None None
} }
@ -53,7 +53,7 @@ pub trait AgentConnection {
fn truncate( fn truncate(
&self, &self,
_session_id: &acp::SessionId, _session_id: &acp::SessionId,
_cx: &mut App, _cx: &App,
) -> Option<Rc<dyn AgentSessionTruncate>> { ) -> Option<Rc<dyn AgentSessionTruncate>> {
None None
} }
@ -61,7 +61,7 @@ pub trait AgentConnection {
fn set_title( fn set_title(
&self, &self,
_session_id: &acp::SessionId, _session_id: &acp::SessionId,
_cx: &mut App, _cx: &App,
) -> Option<Rc<dyn AgentSessionSetTitle>> { ) -> Option<Rc<dyn AgentSessionSetTitle>> {
None None
} }
@ -439,7 +439,7 @@ mod test_support {
fn truncate( fn truncate(
&self, &self,
_session_id: &agent_client_protocol::SessionId, _session_id: &agent_client_protocol::SessionId,
_cx: &mut App, _cx: &App,
) -> Option<Rc<dyn AgentSessionTruncate>> { ) -> Option<Rc<dyn AgentSessionTruncate>> {
Some(Rc::new(StubAgentSessionEditor)) Some(Rc::new(StubAgentSessionEditor))
} }

View file

@ -46,7 +46,7 @@ pub struct AcpConnectionRegistry {
} }
struct ActiveConnection { struct ActiveConnection {
server_name: &'static str, server_name: SharedString,
connection: Weak<acp::ClientSideConnection>, connection: Weak<acp::ClientSideConnection>,
} }
@ -63,12 +63,12 @@ impl AcpConnectionRegistry {
pub fn set_active_connection( pub fn set_active_connection(
&self, &self,
server_name: &'static str, server_name: impl Into<SharedString>,
connection: &Rc<acp::ClientSideConnection>, connection: &Rc<acp::ClientSideConnection>,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
self.active_connection.replace(Some(ActiveConnection { self.active_connection.replace(Some(ActiveConnection {
server_name, server_name: server_name.into(),
connection: Rc::downgrade(connection), connection: Rc::downgrade(connection),
})); }));
cx.notify(); cx.notify();
@ -85,7 +85,7 @@ struct AcpTools {
} }
struct WatchedConnection { struct WatchedConnection {
server_name: &'static str, server_name: SharedString,
messages: Vec<WatchedConnectionMessage>, messages: Vec<WatchedConnectionMessage>,
list_state: ListState, list_state: ListState,
connection: Weak<acp::ClientSideConnection>, connection: Weak<acp::ClientSideConnection>,
@ -142,7 +142,7 @@ impl AcpTools {
}); });
self.watched_connection = Some(WatchedConnection { self.watched_connection = Some(WatchedConnection {
server_name: active_connection.server_name, server_name: active_connection.server_name.clone(),
messages: vec![], messages: vec![],
list_state: ListState::new(0, ListAlignment::Bottom, px(2048.)), list_state: ListState::new(0, ListAlignment::Bottom, px(2048.)),
connection: active_connection.connection.clone(), connection: active_connection.connection.clone(),
@ -442,7 +442,7 @@ impl Item for AcpTools {
"ACP: {}", "ACP: {}",
self.watched_connection self.watched_connection
.as_ref() .as_ref()
.map_or("Disconnected", |connection| connection.server_name) .map_or("Disconnected", |connection| &connection.server_name)
) )
.into() .into()
} }

View file

@ -936,7 +936,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
fn resume( fn resume(
&self, &self,
session_id: &acp::SessionId, session_id: &acp::SessionId,
_cx: &mut App, _cx: &App,
) -> Option<Rc<dyn acp_thread::AgentSessionResume>> { ) -> Option<Rc<dyn acp_thread::AgentSessionResume>> {
Some(Rc::new(NativeAgentSessionResume { Some(Rc::new(NativeAgentSessionResume {
connection: self.clone(), connection: self.clone(),
@ -956,9 +956,9 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
fn truncate( fn truncate(
&self, &self,
session_id: &agent_client_protocol::SessionId, session_id: &agent_client_protocol::SessionId,
cx: &mut App, cx: &App,
) -> Option<Rc<dyn acp_thread::AgentSessionTruncate>> { ) -> Option<Rc<dyn acp_thread::AgentSessionTruncate>> {
self.0.update(cx, |agent, _cx| { self.0.read_with(cx, |agent, _cx| {
agent.sessions.get(session_id).map(|session| { agent.sessions.get(session_id).map(|session| {
Rc::new(NativeAgentSessionEditor { Rc::new(NativeAgentSessionEditor {
thread: session.thread.clone(), thread: session.thread.clone(),
@ -971,7 +971,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
fn set_title( fn set_title(
&self, &self,
session_id: &acp::SessionId, session_id: &acp::SessionId,
_cx: &mut App, _cx: &App,
) -> Option<Rc<dyn acp_thread::AgentSessionSetTitle>> { ) -> Option<Rc<dyn acp_thread::AgentSessionSetTitle>> {
Some(Rc::new(NativeAgentSessionSetTitle { Some(Rc::new(NativeAgentSessionSetTitle {
connection: self.clone(), connection: self.clone(),

View file

@ -3,7 +3,7 @@ use std::{any::Any, path::Path, rc::Rc, sync::Arc};
use agent_servers::AgentServer; use agent_servers::AgentServer;
use anyhow::Result; use anyhow::Result;
use fs::Fs; use fs::Fs;
use gpui::{App, Entity, Task}; use gpui::{App, Entity, SharedString, Task};
use project::Project; use project::Project;
use prompt_store::PromptStore; use prompt_store::PromptStore;
@ -22,16 +22,16 @@ impl NativeAgentServer {
} }
impl AgentServer for NativeAgentServer { impl AgentServer for NativeAgentServer {
fn name(&self) -> &'static str { fn name(&self) -> SharedString {
"Zed Agent" "Zed Agent".into()
} }
fn empty_state_headline(&self) -> &'static str { fn empty_state_headline(&self) -> SharedString {
self.name() self.name()
} }
fn empty_state_message(&self) -> &'static str { fn empty_state_message(&self) -> SharedString {
"" "".into()
} }
fn logo(&self) -> ui::IconName { fn logo(&self) -> ui::IconName {

View file

@ -4,6 +4,8 @@ use agent_client_protocol::{self as acp};
use agent_settings::AgentProfileId; use agent_settings::AgentProfileId;
use anyhow::Result; use anyhow::Result;
use client::{Client, UserStore}; use client::{Client, UserStore};
use cloud_llm_client::CompletionIntent;
use collections::IndexMap;
use context_server::{ContextServer, ContextServerCommand, ContextServerId}; use context_server::{ContextServer, ContextServerCommand, ContextServerId};
use fs::{FakeFs, Fs}; use fs::{FakeFs, Fs};
use futures::{ 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] #[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")); 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] #[gpui::test]
async fn test_agent_connection(cx: &mut TestAppContext) { async fn test_agent_connection(cx: &mut TestAppContext) {
cx.update(settings::init); cx.update(settings::init);
@ -2029,6 +2097,7 @@ async fn test_send_retry_on_error(cx: &mut TestAppContext) {
.unwrap(); .unwrap();
cx.run_until_parked(); cx.run_until_parked();
fake_model.send_last_completion_stream_text_chunk("Hey,");
fake_model.send_last_completion_stream_error(LanguageModelCompletionError::ServerOverloaded { fake_model.send_last_completion_stream_error(LanguageModelCompletionError::ServerOverloaded {
provider: LanguageModelProviderName::new("Anthropic"), provider: LanguageModelProviderName::new("Anthropic"),
retry_after: Some(Duration::from_secs(3)), 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.executor().advance_clock(Duration::from_secs(3));
cx.run_until_parked(); 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(); fake_model.end_last_completion_stream();
cx.run_until_parked();
let mut retry_events = Vec::new(); let mut retry_events = Vec::new();
while let Some(Ok(event)) = events.next().await { while let Some(Ok(event)) = events.next().await {
@ -2067,12 +2137,94 @@ async fn test_send_retry_on_error(cx: &mut TestAppContext) {
## Assistant ## 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::<Vec<_>>().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] #[gpui::test]
async fn test_send_max_retries_exceeded(cx: &mut TestAppContext) { async fn test_send_max_retries_exceeded(cx: &mut TestAppContext) {
let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await;

View file

@ -123,7 +123,7 @@ impl Message {
match self { match self {
Message::User(message) => message.to_markdown(), Message::User(message) => message.to_markdown(),
Message::Agent(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, cache: false,
}; };
for chunk in &self.content { for chunk in &self.content {
let chunk = match chunk { match chunk {
AgentMessageContent::Text(text) => { AgentMessageContent::Text(text) => {
language_model::MessageContent::Text(text.clone()) assistant_message
.content
.push(language_model::MessageContent::Text(text.clone()));
} }
AgentMessageContent::Thinking { text, signature } => { AgentMessageContent::Thinking { text, signature } => {
language_model::MessageContent::Thinking { assistant_message
text: text.clone(), .content
signature: signature.clone(), .push(language_model::MessageContent::Thinking {
} text: text.clone(),
signature: signature.clone(),
});
} }
AgentMessageContent::RedactedThinking(value) => { AgentMessageContent::RedactedThinking(value) => {
language_model::MessageContent::RedactedThinking(value.clone()) assistant_message.content.push(
language_model::MessageContent::RedactedThinking(value.clone()),
);
} }
AgentMessageContent::ToolUse(value) => { AgentMessageContent::ToolUse(tool_use) => {
language_model::MessageContent::ToolUse(value.clone()) 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 { let mut user_message = LanguageModelRequestMessage {
@ -1076,11 +1085,6 @@ impl Thread {
&mut self, &mut self,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>> { ) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>> {
anyhow::ensure!(
self.tool_use_limit_reached,
"can only resume after tool use limit is reached"
);
self.messages.push(Message::Resume); self.messages.push(Message::Resume);
cx.notify(); cx.notify();
@ -1207,12 +1211,13 @@ impl Thread {
cx: &mut AsyncApp, cx: &mut AsyncApp,
) -> Result<()> { ) -> Result<()> {
log::debug!("Stream completion started successfully"); log::debug!("Stream completion started successfully");
let request = this.update(cx, |this, cx| {
this.build_completion_request(completion_intent, cx)
})??;
let mut attempt = None; let mut attempt = None;
'retry: loop { loop {
let request = this.update(cx, |this, cx| {
this.build_completion_request(completion_intent, cx)
})??;
telemetry::event!( telemetry::event!(
"Agent Thread Completion", "Agent Thread Completion",
thread_id = this.read_with(cx, |this, _| this.id.to_string())?, thread_id = this.read_with(cx, |this, _| this.id.to_string())?,
@ -1227,10 +1232,11 @@ impl Thread {
attempt.unwrap_or(0) attempt.unwrap_or(0)
); );
let mut events = model let mut events = model
.stream_completion(request.clone(), cx) .stream_completion(request, cx)
.await .await
.map_err(|error| anyhow!(error))?; .map_err(|error| anyhow!(error))?;
let mut tool_results = FuturesUnordered::new(); let mut tool_results = FuturesUnordered::new();
let mut error = None;
while let Some(event) = events.next().await { while let Some(event) = events.next().await {
match event { match event {
@ -1240,51 +1246,9 @@ impl Thread {
this.handle_streamed_completion_event(event, event_stream, cx) this.handle_streamed_completion_event(event, event_stream, cx)
})??); })??);
} }
Err(error) => { Err(err) => {
let completion_mode = error = Some(err);
this.read_with(cx, |thread, _cx| thread.completion_mode())?; break;
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;
} }
} }
} }
@ -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 { let Some(strategy) = Self::retry_strategy_for(&error) else {
log::debug!("Building system message"); return Err(anyhow!(error))?;
let prompt = SystemPromptTemplate { };
project: self.project_context.read(cx),
available_tools: self.tools.keys().cloned().collect(), let max_attempts = match &strategy {
} RetryStrategy::ExponentialBackoff { max_attempts, .. } => *max_attempts,
.render(&self.templates) RetryStrategy::Fixed { max_attempts, .. } => *max_attempts,
.context("failed to build system prompt") };
.expect("Invalid template");
log::debug!("System message built"); let attempt = attempt.get_or_insert(0u8);
LanguageModelRequestMessage {
role: Role::System, *attempt += 1;
content: vec![prompt.into()],
cache: true, 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; return;
}; };
if message.content.is_empty() {
return;
}
for content in &message.content { for content in &message.content {
let AgentMessageContent::ToolUse(tool_use) = content else { let AgentMessageContent::ToolUse(tool_use) = content else {
continue; continue;
@ -1773,7 +1775,7 @@ impl Thread {
pub(crate) fn build_completion_request( pub(crate) fn build_completion_request(
&self, &self,
completion_intent: CompletionIntent, completion_intent: CompletionIntent,
cx: &mut App, cx: &App,
) -> Result<LanguageModelRequest> { ) -> Result<LanguageModelRequest> {
let model = self.model().context("No language model configured")?; let model = self.model().context("No language model configured")?;
let tools = if let Some(turn) = self.running_turn.as_ref() { let tools = if let Some(turn) = self.running_turn.as_ref() {
@ -1894,21 +1896,29 @@ impl Thread {
"Building request messages from {} thread messages", "Building request messages from {} thread messages",
self.messages.len() 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 { for message in &self.messages {
messages.extend(message.to_request()); messages.extend(message.to_request());
} }
if let Some(message) = self.pending_message.as_ref() { if let Some(last_message) = messages.last_mut() {
messages.extend(message.to_request()); last_message.cache = true;
} }
if let Some(last_user_message) = messages if let Some(message) = self.pending_message.as_ref() {
.iter_mut() messages.extend(message.to_request());
.rev()
.find(|message| message.role == Role::User)
{
last_user_message.cache = true;
} }
messages messages

View file

@ -10,7 +10,7 @@ use project::{AgentLocation, ImageItem, Project, WorktreeSettings, image_store};
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use settings::Settings; use settings::Settings;
use std::sync::Arc; use std::{path::Path, sync::Arc};
use crate::{AgentTool, ToolCallEventStream}; use crate::{AgentTool, ToolCallEventStream};
@ -68,27 +68,12 @@ impl AgentTool for ReadFileTool {
} }
fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString { fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
if let Ok(input) = input { input
let path = &input.path; .ok()
match (input.start_line, input.end_line) { .as_ref()
(Some(start), Some(end)) => { .and_then(|input| Path::new(&input.path).file_name())
format!( .map(|file_name| file_name.to_string_lossy().to_string().into())
"[Read file `{}` (lines {}-{})](@selection:{}:({}-{}))", .unwrap_or_default()
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()
}
} }
fn run( fn run(

View file

@ -15,7 +15,7 @@ use std::{path::Path, rc::Rc};
use thiserror::Error; use thiserror::Error;
use anyhow::{Context as _, Result}; 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}; use acp_thread::{AcpThread, AuthRequired, LoadError};
@ -24,7 +24,7 @@ use acp_thread::{AcpThread, AuthRequired, LoadError};
pub struct UnsupportedVersion; pub struct UnsupportedVersion;
pub struct AcpConnection { pub struct AcpConnection {
server_name: &'static str, server_name: SharedString,
connection: Rc<acp::ClientSideConnection>, connection: Rc<acp::ClientSideConnection>,
sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>, sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
auth_methods: Vec<acp::AuthMethod>, auth_methods: Vec<acp::AuthMethod>,
@ -38,7 +38,7 @@ pub struct AcpSession {
} }
pub async fn connect( pub async fn connect(
server_name: &'static str, server_name: SharedString,
command: AgentServerCommand, command: AgentServerCommand,
root_dir: &Path, root_dir: &Path,
cx: &mut AsyncApp, cx: &mut AsyncApp,
@ -51,7 +51,7 @@ const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::V1;
impl AcpConnection { impl AcpConnection {
pub async fn stdio( pub async fn stdio(
server_name: &'static str, server_name: SharedString,
command: AgentServerCommand, command: AgentServerCommand,
root_dir: &Path, root_dir: &Path,
cx: &mut AsyncApp, cx: &mut AsyncApp,
@ -121,7 +121,7 @@ impl AcpConnection {
cx.update(|cx| { cx.update(|cx| {
AcpConnectionRegistry::default_global(cx).update(cx, |registry, 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 action_log = cx.new(|_| ActionLog::new(project.clone()))?;
let thread = cx.new(|_cx| { let thread = cx.new(|_cx| {
AcpThread::new( AcpThread::new(
self.server_name, self.server_name.clone(),
self.clone(), self.clone(),
project, project,
action_log, action_log,

View file

@ -1,5 +1,6 @@
mod acp; mod acp;
mod claude; mod claude;
mod custom;
mod gemini; mod gemini;
mod settings; mod settings;
@ -7,6 +8,7 @@ mod settings;
pub mod e2e_tests; pub mod e2e_tests;
pub use claude::*; pub use claude::*;
pub use custom::*;
pub use gemini::*; pub use gemini::*;
pub use settings::*; pub use settings::*;
@ -31,9 +33,9 @@ pub fn init(cx: &mut App) {
pub trait AgentServer: Send { pub trait AgentServer: Send {
fn logo(&self) -> ui::IconName; fn logo(&self) -> ui::IconName;
fn name(&self) -> &'static str; fn name(&self) -> SharedString;
fn empty_state_headline(&self) -> &'static str; fn empty_state_headline(&self) -> SharedString;
fn empty_state_message(&self) -> &'static str; fn empty_state_message(&self) -> SharedString;
fn connect( fn connect(
&self, &self,

View file

@ -30,7 +30,7 @@ use futures::{
io::BufReader, io::BufReader,
select_biased, select_biased,
}; };
use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity}; use gpui::{App, AppContext, AsyncApp, Entity, SharedString, Task, WeakEntity};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use util::{ResultExt, debug_panic}; use util::{ResultExt, debug_panic};
@ -43,16 +43,16 @@ use acp_thread::{AcpThread, AgentConnection, AuthRequired, LoadError, MentionUri
pub struct ClaudeCode; pub struct ClaudeCode;
impl AgentServer for ClaudeCode { impl AgentServer for ClaudeCode {
fn name(&self) -> &'static str { fn name(&self) -> SharedString {
"Claude Code" "Claude Code".into()
} }
fn empty_state_headline(&self) -> &'static str { fn empty_state_headline(&self) -> SharedString {
self.name() self.name()
} }
fn empty_state_message(&self) -> &'static str { fn empty_state_message(&self) -> SharedString {
"How can I help you today?" "How can I help you today?".into()
} }
fn logo(&self) -> ui::IconName { fn logo(&self) -> ui::IconName {

View file

@ -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<Project>,
cx: &mut App,
) -> Task<Result<Rc<dyn AgentConnection>>> {
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<Self>) -> Rc<dyn std::any::Any> {
self
}
}

View file

@ -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::{ use std::{
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::Arc, sync::Arc,
time::Duration, 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; use util::path;
pub async fn test_basic<T, F>(server: F, cx: &mut TestAppContext) pub async fn test_basic<T, F>(server: F, cx: &mut TestAppContext)
@ -479,6 +477,7 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
gemini: Some(crate::AgentServerSettings { gemini: Some(crate::AgentServerSettings {
command: crate::gemini::tests::local_command(), command: crate::gemini::tests::local_command(),
}), }),
custom: collections::HashMap::default(),
}, },
cx, cx,
); );

View file

@ -4,11 +4,10 @@ use std::{any::Any, path::Path};
use crate::{AgentServer, AgentServerCommand}; use crate::{AgentServer, AgentServerCommand};
use acp_thread::{AgentConnection, LoadError}; use acp_thread::{AgentConnection, LoadError};
use anyhow::Result; use anyhow::Result;
use gpui::{Entity, Task}; use gpui::{App, Entity, SharedString, Task};
use language_models::provider::google::GoogleLanguageModelProvider; use language_models::provider::google::GoogleLanguageModelProvider;
use project::Project; use project::Project;
use settings::SettingsStore; use settings::SettingsStore;
use ui::App;
use crate::AllAgentServersSettings; use crate::AllAgentServersSettings;
@ -18,16 +17,16 @@ pub struct Gemini;
const ACP_ARG: &str = "--experimental-acp"; const ACP_ARG: &str = "--experimental-acp";
impl AgentServer for Gemini { impl AgentServer for Gemini {
fn name(&self) -> &'static str { fn name(&self) -> SharedString {
"Gemini CLI" "Gemini CLI".into()
} }
fn empty_state_headline(&self) -> &'static str { fn empty_state_headline(&self) -> SharedString {
self.name() self.name()
} }
fn empty_state_message(&self) -> &'static str { fn empty_state_message(&self) -> SharedString {
"Ask questions, edit files, run commands" "Ask questions, edit files, run commands".into()
} }
fn logo(&self) -> ui::IconName { fn logo(&self) -> ui::IconName {

View file

@ -1,6 +1,7 @@
use crate::AgentServerCommand; use crate::AgentServerCommand;
use anyhow::Result; use anyhow::Result;
use gpui::App; use collections::HashMap;
use gpui::{App, SharedString};
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsSources}; use settings::{Settings, SettingsSources};
@ -13,9 +14,13 @@ pub fn init(cx: &mut App) {
pub struct AllAgentServersSettings { pub struct AllAgentServersSettings {
pub gemini: Option<AgentServerSettings>, pub gemini: Option<AgentServerSettings>,
pub claude: Option<AgentServerSettings>, pub claude: Option<AgentServerSettings>,
/// Custom agent servers configured by the user
#[serde(flatten)]
pub custom: HashMap<SharedString, AgentServerSettings>,
} }
#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug)] #[derive(Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)]
pub struct AgentServerSettings { pub struct AgentServerSettings {
#[serde(flatten)] #[serde(flatten)]
pub command: AgentServerCommand, pub command: AgentServerCommand,
@ -29,13 +34,26 @@ impl settings::Settings for AllAgentServersSettings {
fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> { fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
let mut settings = AllAgentServersSettings::default(); 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() { if gemini.is_some() {
settings.gemini = gemini.clone(); settings.gemini = gemini.clone();
} }
if claude.is_some() { if claude.is_some() {
settings.claude = claude.clone(); 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) Ok(settings)

View file

@ -67,6 +67,7 @@ ordered-float.workspace = true
parking_lot.workspace = true parking_lot.workspace = true
paths.workspace = true paths.workspace = true
picker.workspace = true picker.workspace = true
postage.workspace = true
project.workspace = true project.workspace = true
prompt_store.workspace = true prompt_store.workspace = true
proto.workspace = true proto.workspace = true

View file

@ -21,12 +21,13 @@ use futures::{
future::{Shared, join_all}, future::{Shared, join_all},
}; };
use gpui::{ use gpui::{
AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable, Animation, AnimationExt as _, AppContext, ClipboardEntry, Context, Entity, EntityId,
HighlightStyle, Image, ImageFormat, Img, KeyContext, Subscription, Task, TextStyle, EventEmitter, FocusHandle, Focusable, HighlightStyle, Image, ImageFormat, Img, KeyContext,
UnderlineStyle, WeakEntity, Subscription, Task, TextStyle, UnderlineStyle, WeakEntity, pulsating_between,
}; };
use language::{Buffer, Language}; use language::{Buffer, Language};
use language_model::LanguageModelImage; use language_model::LanguageModelImage;
use postage::stream::Stream as _;
use project::{CompletionIntent, Project, ProjectItem, ProjectPath, Worktree}; use project::{CompletionIntent, Project, ProjectItem, ProjectPath, Worktree};
use prompt_store::{PromptId, PromptStore}; use prompt_store::{PromptId, PromptStore};
use rope::Point; use rope::Point;
@ -44,10 +45,10 @@ use std::{
use text::{OffsetRangeExt, ToOffset as _}; use text::{OffsetRangeExt, ToOffset as _};
use theme::ThemeSettings; use theme::ThemeSettings;
use ui::{ use ui::{
ActiveTheme, AnyElement, App, ButtonCommon, ButtonLike, ButtonStyle, Color, Icon, IconName, ActiveTheme, AnyElement, App, ButtonCommon, ButtonLike, ButtonStyle, Color, Element as _,
IconSize, InteractiveElement, IntoElement, Label, LabelCommon, LabelSize, ParentElement, FluentBuilder as _, Icon, IconName, IconSize, InteractiveElement, IntoElement, Label,
Render, SelectableButton, SharedString, Styled, TextSize, TintColor, Toggleable, Window, div, LabelCommon, LabelSize, ParentElement, Render, SelectableButton, SharedString, Styled,
h_flex, px, TextSize, TintColor, Toggleable, Window, div, h_flex, px,
}; };
use util::{ResultExt, debug_panic}; use util::{ResultExt, debug_panic};
use workspace::{Workspace, notifications::NotifyResultExt as _}; use workspace::{Workspace, notifications::NotifyResultExt as _};
@ -73,6 +74,7 @@ pub enum MessageEditorEvent {
Send, Send,
Cancel, Cancel,
Focus, Focus,
LostFocus,
} }
impl EventEmitter<MessageEditorEvent> for MessageEditor {} impl EventEmitter<MessageEditorEvent> for MessageEditor {}
@ -130,10 +132,14 @@ impl MessageEditor {
editor editor
}); });
cx.on_focus(&editor.focus_handle(cx), window, |_, _, cx| { cx.on_focus_in(&editor.focus_handle(cx), window, |_, _, cx| {
cx.emit(MessageEditorEvent::Focus) cx.emit(MessageEditorEvent::Focus)
}) })
.detach(); .detach();
cx.on_focus_out(&editor.focus_handle(cx), window, |_, _, _, cx| {
cx.emit(MessageEditorEvent::LostFocus)
})
.detach();
let mut subscriptions = Vec::new(); let mut subscriptions = Vec::new();
subscriptions.push(cx.subscribe_in(&editor, window, { subscriptions.push(cx.subscribe_in(&editor, window, {
@ -246,7 +252,7 @@ impl MessageEditor {
.buffer_snapshot .buffer_snapshot
.anchor_before(start_anchor.to_offset(&snapshot.buffer_snapshot) + content_len + 1); .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) = abs_path.extension()
&& let Some(extension) = extension.to_str() && let Some(extension) = extension.to_str()
&& Img::extensions().contains(&extension) && Img::extensions().contains(&extension)
@ -272,29 +278,31 @@ impl MessageEditor {
Ok(image) Ok(image)
}) })
.shared(); .shared();
insert_crease_for_image( insert_crease_for_mention(
*excerpt_id, *excerpt_id,
start, start,
content_len, content_len,
Some(abs_path.as_path().into()), mention_uri.name().into(),
image, IconName::Image.path().into(),
Some(image),
self.editor.clone(), self.editor.clone(),
window, window,
cx, cx,
) )
} else { } else {
crate::context_picker::insert_crease_for_mention( insert_crease_for_mention(
*excerpt_id, *excerpt_id,
start, start,
content_len, content_len,
crease_text, crease_text,
mention_uri.icon_path(cx), mention_uri.icon_path(cx),
None,
self.editor.clone(), self.editor.clone(),
window, window,
cx, cx,
) )
}; };
let Some(crease_id) = crease_id else { let Some((crease_id, tx)) = crease else {
return Task::ready(()); return Task::ready(());
}; };
@ -331,7 +339,9 @@ impl MessageEditor {
// Notify the user if we failed to load the mentioned context // Notify the user if we failed to load the mentioned context
cx.spawn_in(window, async move |this, cx| { 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.update(cx, |this, cx| {
this.editor.update(cx, |editor, cx| { this.editor.update(cx, |editor, cx| {
// Remove mention // Remove mention
@ -857,12 +867,13 @@ impl MessageEditor {
snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len) snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len)
}); });
let image = Arc::new(image); let image = Arc::new(image);
let Some(crease_id) = insert_crease_for_image( let Some((crease_id, tx)) = insert_crease_for_mention(
excerpt_id, excerpt_id,
text_anchor, text_anchor,
content_len, content_len,
None.clone(), MentionUri::PastedImage.name().into(),
Task::ready(Ok(image.clone())).shared(), IconName::Image.path().into(),
Some(Task::ready(Ok(image.clone())).shared()),
self.editor.clone(), self.editor.clone(),
window, window,
cx, cx,
@ -877,6 +888,7 @@ impl MessageEditor {
.update(|_, cx| LanguageModelImage::from_image(image, cx)) .update(|_, cx| LanguageModelImage::from_image(image, cx))
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.await; .await;
drop(tx);
if let Some(image) = image { if let Some(image) = image {
Ok(Mention::Image(MentionImage { Ok(Mention::Image(MentionImage {
data: image.source, data: image.source,
@ -1097,18 +1109,20 @@ impl MessageEditor {
for (range, mention_uri, mention) in mentions { for (range, mention_uri, mention) in mentions {
let anchor = snapshot.anchor_before(range.start); 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.excerpt_id,
anchor.text_anchor, anchor.text_anchor,
range.end - range.start, range.end - range.start,
mention_uri.name().into(), mention_uri.name().into(),
mention_uri.icon_path(cx), mention_uri.icon_path(cx),
None,
self.editor.clone(), self.editor.clone(),
window, window,
cx, cx,
) else { ) else {
continue; continue;
}; };
drop(tx);
self.mention_set.mentions.insert( self.mention_set.mentions.insert(
crease_id, crease_id,
@ -1160,17 +1174,16 @@ impl MessageEditor {
}) })
} }
pub fn text(&self, cx: &App) -> String {
self.editor.read(cx).text(cx)
}
#[cfg(test)] #[cfg(test)]
pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) { pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
self.editor.update(cx, |editor, cx| { self.editor.update(cx, |editor, cx| {
editor.set_text(text, window, 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<Path>, PathBuf, String)>) -> String { fn render_directory_contents(entries: Vec<(Arc<Path>, 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, excerpt_id: ExcerptId,
anchor: text::Anchor, anchor: text::Anchor,
content_len: usize, content_len: usize,
abs_path: Option<Arc<Path>>, crease_label: SharedString,
image: Shared<Task<Result<Arc<Image>, String>>>, crease_icon: SharedString,
// abs_path: Option<Arc<Path>>,
image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
editor: Entity<Editor>, editor: Entity<Editor>,
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) -> Option<CreaseId> { ) -> Option<(CreaseId, postage::barrier::Sender)> {
let crease_label = abs_path let (tx, rx) = postage::barrier::channel();
.as_ref()
.and_then(|path| path.file_name())
.map(|name| name.to_string_lossy().to_string().into())
.unwrap_or(SharedString::from("Image"));
editor.update(cx, |editor, cx| { let crease_id = editor.update(cx, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx); let snapshot = editor.buffer().read(cx).snapshot(cx);
let start = snapshot.anchor_in_excerpt(excerpt_id, anchor)?; 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 end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
let placeholder = FoldPlaceholder { 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, merge_adjacent: false,
..Default::default() ..Default::default()
}; };
@ -1269,63 +1288,112 @@ pub(crate) fn insert_crease_for_image(
editor.fold_creases(vec![crease], false, window, cx); editor.fold_creases(vec![crease], false, window, cx);
Some(ids[0]) Some(ids[0])
}) })?;
Some((crease_id, tx))
} }
fn render_image_fold_icon_button( fn render_fold_icon_button(
label: SharedString, label: SharedString,
image_task: Shared<Task<Result<Arc<Image>, String>>>, icon: SharedString,
range: Range<Anchor>,
mut loading_finished: postage::barrier::Receiver,
image_task: Option<Shared<Task<Result<Arc<Image>, String>>>>,
editor: WeakEntity<Editor>, editor: WeakEntity<Editor>,
cx: &mut App,
) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> { ) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
Arc::new({ let loading = cx.new(|cx| {
move |fold_id, fold_range, cx| { let loading = cx.spawn(async move |this, cx| {
let is_in_text_selection = editor loading_finished.recv().await;
.update(cx, |editor, cx| editor.is_range_selected(&fold_range, cx)) this.update(cx, |this: &mut LoadingContext, cx| {
.unwrap_or_default(); this.loading = None;
cx.notify();
ButtonLike::new(fold_id) })
.style(ButtonStyle::Filled) .ok();
.selected_style(ButtonStyle::Tinted(TintColor::Accent)) });
.toggle_state(is_in_text_selection) LoadingContext {
.child( id: cx.entity_id(),
h_flex() label,
.gap_1() icon,
.child( range,
Icon::new(IconName::Image) editor,
.size(IconSize::XSmall) loading: Some(loading),
.color(Color::Muted), image: image_task.clone(),
)
.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::<ImageHover>(|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()
} }
}) });
Arc::new(move |_fold_id, _fold_range, _cx| loading.clone().into_any_element())
}
struct LoadingContext {
id: EntityId,
label: SharedString,
icon: SharedString,
range: Range<Anchor>,
editor: WeakEntity<Editor>,
loading: Option<Task<()>>,
image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
}
impl Render for LoadingContext {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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::<ImageHover>(|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 { struct ImageHover {

View file

@ -274,9 +274,9 @@ pub struct AcpThreadView {
edits_expanded: bool, edits_expanded: bool,
plan_expanded: bool, plan_expanded: bool,
editor_expanded: bool, editor_expanded: bool,
terminal_expanded: bool,
editing_message: Option<usize>, editing_message: Option<usize>,
prompt_capabilities: Rc<Cell<PromptCapabilities>>, prompt_capabilities: Rc<Cell<PromptCapabilities>>,
is_loading_contents: bool,
_cancel_task: Option<Task<()>>, _cancel_task: Option<Task<()>>,
_subscriptions: [Subscription; 3], _subscriptions: [Subscription; 3],
} }
@ -385,10 +385,10 @@ impl AcpThreadView {
edits_expanded: false, edits_expanded: false,
plan_expanded: false, plan_expanded: false,
editor_expanded: false, editor_expanded: false,
terminal_expanded: true,
history_store, history_store,
hovered_recent_history_item: None, hovered_recent_history_item: None,
prompt_capabilities, prompt_capabilities,
is_loading_contents: false,
_subscriptions: subscriptions, _subscriptions: subscriptions,
_cancel_task: None, _cancel_task: None,
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
@ -600,7 +600,7 @@ impl AcpThreadView {
let view = registry.read(cx).provider(&provider_id).map(|provider| { let view = registry.read(cx).provider(&provider_id).map(|provider| {
provider.configuration_view( provider.configuration_view(
language_model::ConfigurationViewTargetAgent::Other(agent_name), language_model::ConfigurationViewTargetAgent::Other(agent_name.clone()),
window, window,
cx, cx,
) )
@ -762,6 +762,7 @@ impl AcpThreadView {
MessageEditorEvent::Focus => { MessageEditorEvent::Focus => {
self.cancel_editing(&Default::default(), window, cx); self.cancel_editing(&Default::default(), window, cx);
} }
MessageEditorEvent::LostFocus => {}
} }
} }
@ -793,6 +794,18 @@ impl AcpThreadView {
cx.notify(); 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) => { ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::Send) => {
self.regenerate(event.entry_index, editor, window, cx); self.regenerate(event.entry_index, editor, window, cx);
} }
@ -807,6 +820,9 @@ impl AcpThreadView {
let Some(thread) = self.thread() else { let Some(thread) = self.thread() else {
return; return;
}; };
if !thread.read(cx).can_resume(cx) {
return;
}
let task = thread.update(cx, |thread, cx| thread.resume(cx)); let task = thread.update(cx, |thread, cx| thread.resume(cx));
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
@ -823,6 +839,11 @@ impl AcpThreadView {
fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(thread) = self.thread() else { return }; let Some(thread) = self.thread() else { return };
if self.is_loading_contents {
return;
}
self.history_store.update(cx, |history, cx| { self.history_store.update(cx, |history, cx| {
history.push_recently_opened_entry( history.push_recently_opened_entry(
HistoryEntryId::AcpThread(thread.read(cx).session_id().clone()), HistoryEntryId::AcpThread(thread.read(cx).session_id().clone()),
@ -876,6 +897,15 @@ impl AcpThreadView {
let Some(thread) = self.thread().cloned() else { let Some(thread) = self.thread().cloned() else {
return; 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 task = cx.spawn_in(window, async move |this, cx| {
let (contents, tracked_buffers) = contents.await?; let (contents, tracked_buffers) = contents.await?;
@ -896,6 +926,7 @@ impl AcpThreadView {
action_log.buffer_read(buffer, cx) action_log.buffer_read(buffer, cx)
} }
}); });
drop(guard);
thread.send(contents, cx) thread.send(contents, cx)
})?; })?;
send.await send.await
@ -950,19 +981,24 @@ impl AcpThreadView {
let Some(thread) = self.thread().cloned() else { let Some(thread) = self.thread().cloned() else {
return; return;
}; };
if self.is_loading_contents {
return;
}
let Some(rewind) = thread.update(cx, |thread, cx| { let Some(user_message_id) = thread.update(cx, |thread, _| {
let user_message_id = thread.entries().get(entry_ix)?.user_message()?.id.clone()?; thread.entries().get(entry_ix)?.user_message()?.id.clone()
Some(thread.rewind(user_message_id, cx))
}) else { }) else {
return; return;
}; };
let contents = message_editor.update(cx, |message_editor, cx| message_editor.contents(cx)); let contents = message_editor.update(cx, |message_editor, cx| message_editor.contents(cx));
let task = cx.foreground_executor().spawn(async move { let task = cx.spawn(async move |_, cx| {
rewind.await?; let contents = contents.await?;
contents.await thread
.update(cx, |thread, cx| thread.rewind(user_message_id, cx))?
.await?;
Ok(contents)
}); });
self.send_impl(task, window, cx); self.send_impl(task, window, cx);
} }
@ -1273,7 +1309,11 @@ impl AcpThreadView {
v_flex() v_flex()
.id(("user_message", entry_ix)) .id(("user_message", entry_ix))
.pt_2() .map(|this| if rules_item.is_some() {
this.pt_3()
} else {
this.pt_2()
})
.pb_4() .pb_4()
.px_2() .px_2()
.gap_1p5() .gap_1p5()
@ -1282,6 +1322,7 @@ impl AcpThreadView {
.children(message.id.clone().and_then(|message_id| { .children(message.id.clone().and_then(|message_id| {
message.checkpoint.as_ref()?.show.then(|| { message.checkpoint.as_ref()?.show.then(|| {
h_flex() h_flex()
.px_3()
.gap_2() .gap_2()
.child(Divider::horizontal()) .child(Divider::horizontal())
.child( .child(
@ -1341,25 +1382,34 @@ impl AcpThreadView {
base_container base_container
.child( .child(
IconButton::new("cancel", IconName::Close) IconButton::new("cancel", IconName::Close)
.disabled(self.is_loading_contents)
.icon_color(Color::Error) .icon_color(Color::Error)
.icon_size(IconSize::XSmall) .icon_size(IconSize::XSmall)
.on_click(cx.listener(Self::cancel_editing)) .on_click(cx.listener(Self::cancel_editing))
) )
.child( .child(
IconButton::new("regenerate", IconName::Return) if self.is_loading_contents {
.icon_color(Color::Muted) div()
.icon_size(IconSize::XSmall) .id("loading-edited-message-content")
.tooltip(Tooltip::text( .tooltip(Tooltip::text("Loading Added Context…"))
"Editing will restart the thread from this point." .child(loading_contents_spinner(IconSize::XSmall))
)) .into_any_element()
.on_click(cx.listener({ } else {
let editor = editor.clone(); IconButton::new("regenerate", IconName::Return)
move |this, _, window, cx| { .icon_color(Color::Muted)
this.regenerate( .icon_size(IconSize::XSmall)
entry_ix, &editor, window, cx, .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 { } else {
@ -1372,7 +1422,7 @@ impl AcpThreadView {
.icon_color(Color::Muted) .icon_color(Color::Muted)
.style(ButtonStyle::Transparent) .style(ButtonStyle::Transparent)
.tooltip(move |_window, cx| { .tooltip(move |_window, cx| {
cx.new(|_| UnavailableEditingTooltip::new(agent_name.into())) cx.new(|_| UnavailableEditingTooltip::new(agent_name.clone()))
.into() .into()
}) })
) )
@ -1450,9 +1500,7 @@ impl AcpThreadView {
.child(self.render_thread_controls(cx)) .child(self.render_thread_controls(cx))
.when_some( .when_some(
self.thread_feedback.comments_editor.clone(), self.thread_feedback.comments_editor.clone(),
|this, editor| { |this, editor| this.child(Self::render_feedback_feedback_editor(editor, cx)),
this.child(Self::render_feedback_feedback_editor(editor, window, cx))
},
) )
.into_any_element() .into_any_element()
} else { } else {
@ -1683,6 +1731,7 @@ impl AcpThreadView {
tool_call.status, tool_call.status,
ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed
); );
let needs_confirmation = matches!( let needs_confirmation = matches!(
tool_call.status, tool_call.status,
ToolCallStatus::WaitingForConfirmation { .. } ToolCallStatus::WaitingForConfirmation { .. }
@ -1691,17 +1740,16 @@ impl AcpThreadView {
matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some(); matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some();
let use_card_layout = needs_confirmation || is_edit; 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 = let is_open = needs_confirmation || self.expanded_tool_calls.contains(&tool_call.id);
needs_confirmation || is_edit || self.expanded_tool_calls.contains(&tool_call.id);
let gradient_overlay = |color: Hsla| { let gradient_overlay = |color: Hsla| {
div() div()
.absolute() .absolute()
.top_0() .top_0()
.right_0() .right_0()
.w_16() .w_12()
.h_full() .h_full()
.bg(linear_gradient( .bg(linear_gradient(
90., 90.,
@ -1861,7 +1909,7 @@ impl AcpThreadView {
.into_any() .into_any()
}), }),
) )
.when(in_progress && use_card_layout, |this| { .when(in_progress && use_card_layout && !is_open, |this| {
this.child( this.child(
div().absolute().right_2().child( div().absolute().right_2().child(
Icon::new(IconName::ArrowCircle) Icon::new(IconName::ArrowCircle)
@ -2164,6 +2212,8 @@ impl AcpThreadView {
.map(|path| format!("{}", path.display())) .map(|path| format!("{}", path.display()))
.unwrap_or_else(|| "current directory".to_string()); .unwrap_or_else(|| "current directory".to_string());
let is_expanded = self.expanded_tool_calls.contains(&tool_call.id);
let header = h_flex() let header = h_flex()
.id(SharedString::from(format!( .id(SharedString::from(format!(
"terminal-tool-header-{}", "terminal-tool-header-{}",
@ -2297,21 +2347,27 @@ impl AcpThreadView {
"terminal-tool-disclosure-{}", "terminal-tool-disclosure-{}",
terminal.entity_id() terminal.entity_id()
)), )),
self.terminal_expanded, is_expanded,
) )
.opened_icon(IconName::ChevronUp) .opened_icon(IconName::ChevronUp)
.closed_icon(IconName::ChevronDown) .closed_icon(IconName::ChevronDown)
.on_click(cx.listener(move |this, _event, _window, _cx| { .on_click(cx.listener({
this.terminal_expanded = !this.terminal_expanded; 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 let terminal_view = self
.entry_view_state .entry_view_state
.read(cx) .read(cx)
.entry(entry_ix) .entry(entry_ix)
.and_then(|entry| entry.terminal(terminal)); .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() v_flex()
.mb_2() .mb_2()
@ -2411,7 +2467,6 @@ impl AcpThreadView {
Some( Some(
h_flex() h_flex()
.px_2p5() .px_2p5()
.pb_1()
.child( .child(
Icon::new(IconName::Attach) Icon::new(IconName::Attach)
.size(IconSize::XSmall) .size(IconSize::XSmall)
@ -2427,8 +2482,7 @@ impl AcpThreadView {
Label::new(user_rules_text) Label::new(user_rules_text)
.size(LabelSize::XSmall) .size(LabelSize::XSmall)
.color(Color::Muted) .color(Color::Muted)
.truncate() .truncate(),
.buffer_font(cx),
) )
.hover(|s| s.bg(cx.theme().colors().element_hover)) .hover(|s| s.bg(cx.theme().colors().element_hover))
.tooltip(Tooltip::text("View User Rules")) .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| { .when_some(rules_file_text, |parent, rules_file_text| {
parent.child( parent.child(
h_flex() h_flex()
@ -2451,8 +2511,7 @@ impl AcpThreadView {
.child( .child(
Label::new(rules_file_text) Label::new(rules_file_text)
.size(LabelSize::XSmall) .size(LabelSize::XSmall)
.color(Color::Muted) .color(Color::Muted),
.buffer_font(cx),
) )
.hover(|s| s.bg(cx.theme().colors().element_hover)) .hover(|s| s.bg(cx.theme().colors().element_hover))
.tooltip(Tooltip::text("View Project Rules")) .tooltip(Tooltip::text("View Project Rules"))
@ -3029,13 +3088,13 @@ impl AcpThreadView {
h_flex() h_flex()
.p_1() .p_1()
.justify_between() .justify_between()
.flex_wrap()
.when(expanded, |this| { .when(expanded, |this| {
this.border_b_1().border_color(cx.theme().colors().border) this.border_b_1().border_color(cx.theme().colors().border)
}) })
.child( .child(
h_flex() h_flex()
.id("edits-container") .id("edits-container")
.w_full()
.gap_1() .gap_1()
.child(Disclosure::new("edits-disclosure", expanded)) .child(Disclosure::new("edits-disclosure", expanded))
.map(|this| { .map(|this| {
@ -3542,7 +3601,14 @@ impl AcpThreadView {
.thread() .thread()
.is_some_and(|thread| thread.read(cx).status() != ThreadStatus::Idle); .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) IconButton::new("stop-generation", IconName::Stop)
.icon_color(Color::Error) .icon_color(Color::Error)
.style(ButtonStyle::Tinted(ui::TintColor::Error)) .style(ButtonStyle::Tinted(ui::TintColor::Error))
@ -3911,13 +3977,13 @@ impl AcpThreadView {
match AgentSettings::get_global(cx).notify_when_agent_waiting { match AgentSettings::get_global(cx).notify_when_agent_waiting {
NotifyWhenAgentWaiting::PrimaryScreen => { NotifyWhenAgentWaiting::PrimaryScreen => {
if let Some(primary) = cx.primary_display() { 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 => { NotifyWhenAgentWaiting::AllScreens => {
let caption = caption.into(); let caption = caption.into();
for screen in cx.displays() { 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 => { NotifyWhenAgentWaiting::Never => {
@ -4121,13 +4187,8 @@ impl AcpThreadView {
container.child(open_as_markdown).child(scroll_to_top) container.child(open_as_markdown).child(scroll_to_top)
} }
fn render_feedback_feedback_editor( fn render_feedback_feedback_editor(editor: Entity<Editor>, cx: &Context<Self>) -> Div {
editor: Entity<Editor>, h_flex()
window: &mut Window,
cx: &Context<Self>,
) -> Div {
let focus_handle = editor.focus_handle(cx);
v_flex()
.key_context("AgentFeedbackMessageEditor") .key_context("AgentFeedbackMessageEditor")
.on_action(cx.listener(move |this, _: &menu::Cancel, _, cx| { .on_action(cx.listener(move |this, _: &menu::Cancel, _, cx| {
this.thread_feedback.dismiss_comments(); this.thread_feedback.dismiss_comments();
@ -4136,43 +4197,31 @@ impl AcpThreadView {
.on_action(cx.listener(move |this, _: &menu::Confirm, _window, cx| { .on_action(cx.listener(move |this, _: &menu::Confirm, _window, cx| {
this.submit_feedback_message(cx); this.submit_feedback_message(cx);
})) }))
.mb_2()
.mx_4()
.p_2() .p_2()
.mb_2()
.mx_5()
.gap_1()
.rounded_md() .rounded_md()
.border_1() .border_1()
.border_color(cx.theme().colors().border) .border_color(cx.theme().colors().border)
.bg(cx.theme().colors().editor_background) .bg(cx.theme().colors().editor_background)
.child(editor) .child(div().w_full().child(editor))
.child( .child(
h_flex() h_flex()
.gap_1()
.justify_end()
.child( .child(
Button::new("dismiss-feedback-message", "Cancel") IconButton::new("dismiss-feedback-message", IconName::Close)
.label_size(LabelSize::Small) .icon_color(Color::Error)
.key_binding( .icon_size(IconSize::XSmall)
KeyBinding::for_action_in(&menu::Cancel, &focus_handle, window, cx) .shape(ui::IconButtonShape::Square)
.map(|kb| kb.size(rems_from_px(10.))),
)
.on_click(cx.listener(move |this, _, _window, cx| { .on_click(cx.listener(move |this, _, _window, cx| {
this.thread_feedback.dismiss_comments(); this.thread_feedback.dismiss_comments();
cx.notify(); cx.notify();
})), })),
) )
.child( .child(
Button::new("submit-feedback-message", "Share Feedback") IconButton::new("submit-feedback-message", IconName::Return)
.style(ButtonStyle::Tinted(ui::TintColor::Accent)) .icon_size(IconSize::XSmall)
.label_size(LabelSize::Small) .shape(ui::IconButtonShape::Square)
.key_binding(
KeyBinding::for_action_in(
&menu::Confirm,
&focus_handle,
window,
cx,
)
.map(|kb| kb.size(rems_from_px(10.))),
)
.on_click(cx.listener(move |this, _, _window, cx| { .on_click(cx.listener(move |this, _, _window, cx| {
this.submit_feedback_message(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 { 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() Callout::new()
.severity(Severity::Error) .severity(Severity::Error)
.title("Error") .title("Error")
.icon(IconName::XCircle) .icon(IconName::XCircle)
.description(error.clone()) .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)) .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 { impl Focusable for AcpThreadView {
fn focus_handle(&self, cx: &App) -> FocusHandle { fn focus_handle(&self, cx: &App) -> FocusHandle {
match self.thread_state { match self.thread_state {
@ -5153,16 +5255,16 @@ pub(crate) mod tests {
ui::IconName::Ai ui::IconName::Ai
} }
fn name(&self) -> &'static str { fn name(&self) -> SharedString {
"Test" "Test".into()
} }
fn empty_state_headline(&self) -> &'static str { fn empty_state_headline(&self) -> SharedString {
"Test" "Test".into()
} }
fn empty_state_message(&self) -> &'static str { fn empty_state_message(&self) -> SharedString {
"Test" "Test".into()
} }
fn connect( fn connect(

View file

@ -5,6 +5,7 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use acp_thread::AcpThread; use acp_thread::AcpThread;
use agent_servers::AgentServerSettings;
use agent2::{DbThreadMetadata, HistoryEntry}; use agent2::{DbThreadMetadata, HistoryEntry};
use db::kvp::{Dismissable, KEY_VALUE_STORE}; use db::kvp::{Dismissable, KEY_VALUE_STORE};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -132,7 +133,7 @@ pub fn init(cx: &mut App) {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) { if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx); workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, 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, None,
} }
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
pub enum AgentType { pub enum AgentType {
#[default] #[default]
Zed, Zed,
@ -254,23 +255,29 @@ pub enum AgentType {
Gemini, Gemini,
ClaudeCode, ClaudeCode,
NativeAgent, NativeAgent,
Custom {
name: SharedString,
settings: AgentServerSettings,
},
} }
impl AgentType { impl AgentType {
fn label(self) -> impl Into<SharedString> { fn label(&self) -> SharedString {
match self { match self {
Self::Zed | Self::TextThread => "Zed Agent", Self::Zed | Self::TextThread => "Zed Agent".into(),
Self::NativeAgent => "Agent 2", Self::NativeAgent => "Agent 2".into(),
Self::Gemini => "Gemini CLI", Self::Gemini => "Gemini CLI".into(),
Self::ClaudeCode => "Claude Code", Self::ClaudeCode => "Claude Code".into(),
Self::Custom { name, .. } => name.into(),
} }
} }
fn icon(self) -> Option<IconName> { fn icon(&self) -> Option<IconName> {
match self { match self {
Self::Zed | Self::NativeAgent | Self::TextThread => None, Self::Zed | Self::NativeAgent | Self::TextThread => None,
Self::Gemini => Some(IconName::AiGemini), Self::Gemini => Some(IconName::AiGemini),
Self::ClaudeCode => Some(IconName::AiClaude), Self::ClaudeCode => Some(IconName::AiClaude),
Self::Custom { .. } => Some(IconName::Terminal),
} }
} }
} }
@ -524,7 +531,7 @@ pub struct AgentPanel {
impl AgentPanel { impl AgentPanel {
fn serialize(&mut self, cx: &mut Context<Self>) { fn serialize(&mut self, cx: &mut Context<Self>) {
let width = self.width; 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 { self.pending_serialization = Some(cx.background_spawn(async move {
KEY_VALUE_STORE KEY_VALUE_STORE
.write_kvp( .write_kvp(
@ -614,7 +621,7 @@ impl AgentPanel {
panel.update(cx, |panel, cx| { panel.update(cx, |panel, cx| {
panel.width = serialized_panel.width.map(|w| w.round()); panel.width = serialized_panel.width.map(|w| w.round());
if let Some(selected_agent) = serialized_panel.selected_agent { 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); panel.new_agent_thread(selected_agent, window, cx);
} }
cx.notify(); cx.notify();
@ -1084,14 +1091,17 @@ impl AgentPanel {
cx.spawn_in(window, async move |this, cx| { cx.spawn_in(window, async move |this, cx| {
let ext_agent = match agent_choice { let ext_agent = match agent_choice {
Some(agent) => { Some(agent) => {
cx.background_spawn(async move { cx.background_spawn({
if let Some(serialized) = let agent = agent.clone();
serde_json::to_string(&LastUsedExternalAgent { agent }).log_err() async move {
{ if let Some(serialized) =
KEY_VALUE_STORE serde_json::to_string(&LastUsedExternalAgent { agent }).log_err()
.write_kvp(LAST_USED_EXTERNAL_AGENT_KEY.to_string(), serialized) {
.await KEY_VALUE_STORE
.log_err(); .write_kvp(LAST_USED_EXTERNAL_AGENT_KEY.to_string(), serialized)
.await
.log_err();
}
} }
}) })
.detach(); .detach();
@ -1117,7 +1127,9 @@ impl AgentPanel {
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
match ext_agent { match ext_agent {
crate::ExternalAgent::Gemini | crate::ExternalAgent::NativeAgent => { crate::ExternalAgent::Gemini
| crate::ExternalAgent::NativeAgent
| crate::ExternalAgent::Custom { .. } => {
if !cx.has_flag::<GeminiAndNativeFeatureFlag>() { if !cx.has_flag::<GeminiAndNativeFeatureFlag>() {
return; return;
} }
@ -1846,14 +1858,14 @@ impl AgentPanel {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
if self.selected_agent != agent { if self.selected_agent != agent {
self.selected_agent = agent; self.selected_agent = agent.clone();
self.serialize(cx); self.serialize(cx);
} }
self.new_agent_thread(agent, window, cx); self.new_agent_thread(agent, window, cx);
} }
pub fn selected_agent(&self) -> AgentType { pub fn selected_agent(&self) -> AgentType {
self.selected_agent self.selected_agent.clone()
} }
pub fn new_agent_thread( pub fn new_agent_thread(
@ -1892,6 +1904,13 @@ impl AgentPanel {
window, window,
cx, 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::<GeminiAndNativeFeatureFlag>(), |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::<AgentPanel>(cx)
{
panel.update(cx, |panel, cx| {
panel.set_selected_agent(
AgentType::Custom {
name: agent_name
.clone(),
settings:
agent_settings
.clone(),
},
window,
cx,
);
});
}
});
}
}
}),
);
}
menu
}); });
menu menu
})) }))
} }
}); });
let selected_agent_label = self.selected_agent.label().into(); let selected_agent_label = self.selected_agent.label();
let selected_agent = div() let selected_agent = div()
.id("selected_agent_icon") .id("selected_agent_icon")
.when_some(self.selected_agent.icon(), |this, icon| { .when_some(self.selected_agent.icon(), |this, icon| {

View file

@ -28,13 +28,14 @@ use std::rc::Rc;
use std::sync::Arc; use std::sync::Arc;
use agent::{Thread, ThreadId}; use agent::{Thread, ThreadId};
use agent_servers::AgentServerSettings;
use agent_settings::{AgentProfileId, AgentSettings, LanguageModelSelection}; use agent_settings::{AgentProfileId, AgentSettings, LanguageModelSelection};
use assistant_slash_command::SlashCommandRegistry; use assistant_slash_command::SlashCommandRegistry;
use client::Client; use client::Client;
use command_palette_hooks::CommandPaletteFilter; use command_palette_hooks::CommandPaletteFilter;
use feature_flags::FeatureFlagAppExt as _; use feature_flags::FeatureFlagAppExt as _;
use fs::Fs; use fs::Fs;
use gpui::{Action, App, Entity, actions}; use gpui::{Action, App, Entity, SharedString, actions};
use language::LanguageRegistry; use language::LanguageRegistry;
use language_model::{ use language_model::{
ConfiguredModel, LanguageModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry, ConfiguredModel, LanguageModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry,
@ -159,13 +160,17 @@ pub struct NewNativeAgentThreadFromSummary {
from_session_id: agent_client_protocol::SessionId, 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")] #[serde(rename_all = "snake_case")]
enum ExternalAgent { enum ExternalAgent {
#[default] #[default]
Gemini, Gemini,
ClaudeCode, ClaudeCode,
NativeAgent, NativeAgent,
Custom {
name: SharedString,
settings: AgentServerSettings,
},
} }
impl ExternalAgent { impl ExternalAgent {
@ -175,9 +180,13 @@ impl ExternalAgent {
history: Entity<agent2::HistoryStore>, history: Entity<agent2::HistoryStore>,
) -> Rc<dyn agent_servers::AgentServer> { ) -> Rc<dyn agent_servers::AgentServer> {
match self { match self {
ExternalAgent::Gemini => Rc::new(agent_servers::Gemini), Self::Gemini => Rc::new(agent_servers::Gemini),
ExternalAgent::ClaudeCode => Rc::new(agent_servers::ClaudeCode), Self::ClaudeCode => Rc::new(agent_servers::ClaudeCode),
ExternalAgent::NativeAgent => Rc::new(agent2::NativeAgentServer::new(fs, history)), Self::NativeAgent => Rc::new(agent2::NativeAgentServer::new(fs, history)),
Self::Custom { name, settings } => Rc::new(agent_servers::CustomAgentServer::new(
name.clone(),
settings,
)),
} }
} }
} }

View file

@ -74,7 +74,7 @@ use std::{
fmt::{self, Write}, fmt::{self, Write},
iter, mem, iter, mem,
ops::{Deref, Range}, ops::{Deref, Range},
path::Path, path::{self, Path},
rc::Rc, rc::Rc,
sync::Arc, sync::Arc,
time::{Duration, Instant}, time::{Duration, Instant},
@ -90,8 +90,8 @@ use unicode_segmentation::UnicodeSegmentation;
use util::post_inc; use util::post_inc;
use util::{RangeExt, ResultExt, debug_panic}; use util::{RangeExt, ResultExt, debug_panic};
use workspace::{ use workspace::{
CollaboratorId, OpenInTerminal, OpenTerminal, RevealInProjectPanel, Workspace, item::Item, CollaboratorId, ItemSettings, OpenInTerminal, OpenTerminal, RevealInProjectPanel, Workspace,
notifications::NotifyTaskExt, item::Item, notifications::NotifyTaskExt,
}; };
/// Determines what kinds of highlights should be applied to a lines background. /// 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 focus_handle = editor.focus_handle(cx);
let colors = cx.theme().colors(); let colors = cx.theme().colors();
let header = let header = div()
div() .p_1()
.p_1() .w_full()
.w_full() .h(FILE_HEADER_HEIGHT as f32 * window.line_height())
.h(FILE_HEADER_HEIGHT as f32 * window.line_height()) .child(
.child( h_flex()
h_flex() .size_full()
.size_full() .gap_2()
.gap_2() .flex_basis(Length::Definite(DefiniteLength::Fraction(0.667)))
.flex_basis(Length::Definite(DefiniteLength::Fraction(0.667))) .pl_0p5()
.pl_0p5() .pr_5()
.pr_5() .rounded_sm()
.rounded_sm() .when(is_sticky, |el| el.shadow_md())
.when(is_sticky, |el| el.shadow_md()) .border_1()
.border_1() .map(|div| {
.map(|div| { let border_color = if is_selected
let border_color = if is_selected && is_folded
&& is_folded && focus_handle.contains_focused(window, cx)
&& focus_handle.contains_focused(window, cx) {
{ colors.border_focused
colors.border_focused } else {
} else { colors.border
colors.border };
}; div.border_color(border_color)
div.border_color(border_color) })
}) .bg(colors.editor_subheader_background)
.bg(colors.editor_subheader_background) .hover(|style| style.bg(colors.element_hover))
.hover(|style| style.bg(colors.element_hover)) .map(|header| {
.map(|header| { let editor = self.editor.clone();
let editor = self.editor.clone(); let buffer_id = for_excerpt.buffer_id;
let buffer_id = for_excerpt.buffer_id; let toggle_chevron_icon =
let toggle_chevron_icon = FileIcons::get_chevron_icon(!is_folded, cx).map(Icon::from_path);
FileIcons::get_chevron_icon(!is_folded, cx).map(Icon::from_path); header.child(
header.child( div()
div() .hover(|style| style.bg(colors.element_selected))
.hover(|style| style.bg(colors.element_selected)) .rounded_xs()
.rounded_xs() .child(
.child( ButtonLike::new("toggle-buffer-fold")
ButtonLike::new("toggle-buffer-fold") .style(ui::ButtonStyle::Transparent)
.style(ui::ButtonStyle::Transparent) .height(px(28.).into())
.height(px(28.).into()) .width(px(28.))
.width(px(28.)) .children(toggle_chevron_icon)
.children(toggle_chevron_icon) .tooltip({
.tooltip({ let focus_handle = focus_handle.clone();
let focus_handle = focus_handle.clone(); move |window, cx| {
move |window, cx| { Tooltip::with_meta_in(
Tooltip::with_meta_in( "Toggle Excerpt Fold",
"Toggle Excerpt Fold", Some(&ToggleFold),
Some(&ToggleFold), "Alt+click to toggle all",
"Alt+click to toggle all", &focus_handle,
&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, window,
cx, cx,
) );
} });
}) } else {
.on_click(move |event, window, cx| { // Regular click toggles single buffer
if event.modifiers().alt { if is_folded {
// Alt+click toggles all buffers
editor.update(cx, |editor, cx| { editor.update(cx, |editor, cx| {
editor.toggle_fold_all( editor.unfold_buffer(buffer_id, cx);
&ToggleFoldAll,
window,
cx,
);
}); });
} else { } else {
// Regular click toggles single buffer editor.update(cx, |editor, cx| {
if is_folded { editor.fold_buffer(buffer_id, cx);
editor.update(cx, |editor, cx| { });
editor.unfold_buffer(buffer_id, cx);
});
} else {
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() .children(
.size(Pixels(12.0)) editor
.justify_center() .addons
.children(indicator), .values()
) .filter_map(|addon| {
.child( addon.render_buffer_header_controls(for_excerpt, window, cx)
h_flex() })
.cursor_pointer() .take(1),
.id("path header block") )
.size_full() .child(
.justify_between() h_flex()
.overflow_hidden() .size(Pixels(12.0))
.child( .justify_center()
h_flex() .children(indicator),
.gap_2() )
.child( .child(
Label::new( h_flex()
filename .cursor_pointer()
.map(SharedString::from) .id("path header block")
.unwrap_or_else(|| "untitled".into()), .size_full()
) .justify_between()
.single_line() .overflow_hidden()
.when_some(file_status, |el, status| { .child(
el.color(if status.is_conflicted() { h_flex()
Color::Conflict .gap_2()
} else if status.is_modified() { .map(|path_header| {
Color::Modified let filename = filename
} else if status.is_deleted() { .map(SharedString::from)
Color::Disabled .unwrap_or_else(|| "untitled".into());
} else {
Color::Created path_header
}) .when(ItemSettings::get_global(cx).file_icons, |el| {
.when(status.is_deleted(), |el| el.strikethrough()) let path = path::Path::new(filename.as_str());
}), let icon = FileIcons::get_icon(path, cx)
) .unwrap_or_default();
.when_some(parent_path, |then, path| { let icon =
then.child(div().child(path).text_color( Icon::from_path(icon).color(Color::Muted);
if file_status.is_some_and(FileStatus::is_deleted) { el.child(icon)
colors.text_disabled })
} else { .child(Label::new(filename).single_line().when_some(
colors.text_muted 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| {
.when( then.child(div().child(path).text_color(
can_open_excerpts && is_selected && relative_path.is_some(), if file_status.is_some_and(FileStatus::is_deleted) {
|el| { colors.text_disabled
el.child( } else {
h_flex() colors.text_muted
.id("jump-to-file-button") },
.gap_2p5() ))
.child(Label::new("Jump To File")) }),
.children( )
KeyBinding::for_action_in( .when(
&OpenExcerpts, can_open_excerpts && is_selected && relative_path.is_some(),
&focus_handle, |el| {
window, el.child(
cx, h_flex()
) .id("jump-to-file-button")
.map(|binding| binding.into_any_element()), .gap_2p5()
), .child(Label::new("Jump To File"))
) .children(
}, KeyBinding::for_action_in(
) &OpenExcerpts,
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) &focus_handle,
.on_click(window.listener_for(&self.editor, { window,
move |editor, e: &ClickEvent, window, cx| { cx,
editor.open_excerpts_common( )
Some(jump_data.clone()), .map(|binding| binding.into_any_element()),
e.modifiers().secondary(), ),
window, )
cx, },
); )
} .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 file = for_excerpt.buffer.file().cloned();
let editor = self.editor.clone(); let editor = self.editor.clone();

View file

@ -1401,13 +1401,16 @@ impl PickerDelegate for FileFinderDelegate {
#[cfg(windows)] #[cfg(windows)]
let raw_query = raw_query.trim().to_owned().replace("/", "\\"); let raw_query = raw_query.trim().to_owned().replace("/", "\\");
#[cfg(not(windows))] #[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 None
} else { } else {
// Safe to unwrap as we won't get here when the unwrap in if fails // 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 { let query = FileSearchQuery {

View file

@ -218,6 +218,7 @@ async fn test_matching_paths(cx: &mut TestAppContext) {
" ndan ", " ndan ",
" band ", " band ",
"a bandana", "a bandana",
"bandana:",
] { ] {
picker picker
.update_in(cx, |picker, window, cx| { .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] #[gpui::test]
async fn test_unicode_paths(cx: &mut TestAppContext) { async fn test_unicode_paths(cx: &mut TestAppContext) {
let app_state = init_test(cx); let app_state = init_test(cx);

View file

@ -24,6 +24,7 @@ serde_json_lenient.workspace = true
theme.workspace = true theme.workspace = true
ui.workspace = true ui.workspace = true
util.workspace = true util.workspace = true
util_macros.workspace = true
workspace-hack.workspace = true workspace-hack.workspace = true
workspace.workspace = true workspace.workspace = true
zed_actions.workspace = true zed_actions.workspace = true

View file

@ -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 /// Path used for unsaved buffer that contains style json. To support the json language server, this
/// matches the name used in the generated schemas. /// 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 { pub(crate) struct DivInspector {
state: State, state: State,

View file

@ -643,11 +643,11 @@ pub trait LanguageModelProvider: 'static {
fn reset_credentials(&self, cx: &mut App) -> Task<Result<()>>; fn reset_credentials(&self, cx: &mut App) -> Task<Result<()>>;
} }
#[derive(Default, Clone, Copy)] #[derive(Default, Clone)]
pub enum ConfigurationViewTargetAgent { pub enum ConfigurationViewTargetAgent {
#[default] #[default]
ZedAgent, ZedAgent,
Other(&'static str), Other(SharedString),
} }
#[derive(PartialEq, Eq)] #[derive(PartialEq, Eq)]

View file

@ -1041,9 +1041,9 @@ impl Render for ConfigurationView {
v_flex() v_flex()
.size_full() .size_full()
.on_action(cx.listener(Self::save_api_key)) .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 { .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::ZedAgent => "Zed's agent with Anthropic".into(),
ConfigurationViewTargetAgent::Other(agent) => agent, ConfigurationViewTargetAgent::Other(agent) => agent.clone(),
}))) })))
.child( .child(
List::new() List::new()

View file

@ -921,9 +921,9 @@ impl Render for ConfigurationView {
v_flex() v_flex()
.size_full() .size_full()
.on_action(cx.listener(Self::save_api_key)) .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 { .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::ZedAgent => "Zed's agent with Google AI".into(),
ConfigurationViewTargetAgent::Other(agent) => agent, ConfigurationViewTargetAgent::Other(agent) => agent.clone(),
}))) })))
.child( .child(
List::new() List::new()

View file

@ -231,6 +231,7 @@
"implements" "implements"
"interface" "interface"
"keyof" "keyof"
"module"
"namespace" "namespace"
"private" "private"
"protected" "protected"
@ -250,4 +251,4 @@
(jsx_closing_element (["</" ">"]) @punctuation.bracket.jsx) (jsx_closing_element (["</" ">"]) @punctuation.bracket.jsx)
(jsx_self_closing_element (["<" "/>"]) @punctuation.bracket.jsx) (jsx_self_closing_element (["<" "/>"]) @punctuation.bracket.jsx)
(jsx_attribute "=" @punctuation.delimiter.jsx) (jsx_attribute "=" @punctuation.delimiter.jsx)
(jsx_text) @text.jsx (jsx_text) @text.jsx

View file

@ -237,6 +237,7 @@
"implements" "implements"
"interface" "interface"
"keyof" "keyof"
"module"
"namespace" "namespace"
"private" "private"
"protected" "protected"
@ -256,4 +257,4 @@
(jsx_closing_element (["</" ">"]) @punctuation.bracket.jsx) (jsx_closing_element (["</" ">"]) @punctuation.bracket.jsx)
(jsx_self_closing_element (["<" "/>"]) @punctuation.bracket.jsx) (jsx_self_closing_element (["<" "/>"]) @punctuation.bracket.jsx)
(jsx_attribute "=" @punctuation.delimiter.jsx) (jsx_attribute "=" @punctuation.delimiter.jsx)
(jsx_text) @text.jsx (jsx_text) @text.jsx

View file

@ -248,6 +248,7 @@
"is" "is"
"keyof" "keyof"
"let" "let"
"module"
"namespace" "namespace"
"new" "new"
"of" "of"
@ -272,4 +273,4 @@
"while" "while"
"with" "with"
"yield" "yield"
] @keyword ] @keyword

View file

@ -1085,10 +1085,10 @@ impl Element for MarkdownElement {
); );
el.child( el.child(
h_flex() h_flex()
.w_5() .w_4()
.absolute() .absolute()
.top_1() .top_1p5()
.right_1() .right_1p5()
.justify_end() .justify_end()
.child(codeblock), .child(codeblock),
) )
@ -1115,11 +1115,12 @@ impl Element for MarkdownElement {
cx, cx,
); );
el.child( el.child(
div() h_flex()
.w_4()
.absolute() .absolute()
.top_0() .top_0()
.right_0() .right_0()
.w_5() .justify_end()
.visible_on_hover("code_block") .visible_on_hover("code_block")
.child(codeblock), .child(codeblock),
) )

View file

@ -835,7 +835,7 @@ impl MultiBuffer {
this.convert_edits_to_buffer_edits(edits, &snapshot, &original_indent_columns); this.convert_edits_to_buffer_edits(edits, &snapshot, &original_indent_columns);
drop(snapshot); 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 { for (buffer_id, mut edits) in buffer_edits {
buffer_ids.push(buffer_id); buffer_ids.push(buffer_id);
edits.sort_by_key(|edit| edit.range.start); edits.sort_by_key(|edit| edit.range.start);

View file

@ -11913,7 +11913,7 @@ impl LspStore {
notify_server_capabilities_updated(&server, cx); notify_server_capabilities_updated(&server, cx);
} }
} }
"textDocument/colorProvider" => { "textDocument/documentColor" => {
if let Some(caps) = reg if let Some(caps) = reg
.register_options .register_options
.map(serde_json::from_value) .map(serde_json::from_value)
@ -12064,7 +12064,7 @@ impl LspStore {
}); });
notify_server_capabilities_updated(&server, cx); notify_server_capabilities_updated(&server, cx);
} }
"textDocument/colorProvider" => { "textDocument/documentColor" => {
server.update_capabilities(|capabilities| { server.update_capabilities(|capabilities| {
capabilities.color_provider = None; capabilities.color_provider = None;
}); });

View file

@ -0,0 +1,4 @@
<key>CFBundleSupportedPlatforms</key>
<array>
<string>MacOSX</string>
</array>

View file

@ -51,7 +51,7 @@ To configure, use
```json5 ```json5
"project_panel": { "project_panel": {
"diagnostics": "all", "show_diagnostics": "all",
} }
``` ```