Merge branch 'main' into acp-following-stateful

This commit is contained in:
Conrad Irwin 2025-08-25 09:02:52 -06:00
commit 5e0691cbd7
49 changed files with 1846 additions and 1645 deletions

3
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",
@ -4685,7 +4686,6 @@ dependencies = [
"component", "component",
"ctor", "ctor",
"editor", "editor",
"futures 0.3.31",
"gpui", "gpui",
"indoc", "indoc",
"language", "language",
@ -8468,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

@ -1133,11 +1133,6 @@
// The minimum severity of the diagnostics to show inline. // The minimum severity of the diagnostics to show inline.
// Inherits editor's diagnostics' max severity settings when `null`. // Inherits editor's diagnostics' max severity settings when `null`.
"max_severity": null "max_severity": null
},
"cargo": {
// When enabled, Zed disables rust-analyzer's check on save and starts to query
// Cargo diagnostics separately.
"fetch_cargo_diagnostics": false
} }
}, },
// Files or globs of files that will be excluded by Zed entirely. They will be skipped during file // Files or globs of files that will be excluded by Zed entirely. They will be skipped during file

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

@ -5,7 +5,7 @@ use prompt_store::{PromptId, UserPromptId};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{ use std::{
fmt, fmt,
ops::Range, ops::RangeInclusive,
path::{Path, PathBuf}, path::{Path, PathBuf},
str::FromStr, str::FromStr,
}; };
@ -17,13 +17,14 @@ pub enum MentionUri {
File { File {
abs_path: PathBuf, abs_path: PathBuf,
}, },
PastedImage,
Directory { Directory {
abs_path: PathBuf, abs_path: PathBuf,
}, },
Symbol { Symbol {
path: PathBuf, abs_path: PathBuf,
name: String, name: String,
line_range: Range<u32>, line_range: RangeInclusive<u32>,
}, },
Thread { Thread {
id: acp::SessionId, id: acp::SessionId,
@ -38,8 +39,9 @@ pub enum MentionUri {
name: String, name: String,
}, },
Selection { Selection {
path: PathBuf, #[serde(default, skip_serializing_if = "Option::is_none")]
line_range: Range<u32>, abs_path: Option<PathBuf>,
line_range: RangeInclusive<u32>,
}, },
Fetch { Fetch {
url: Url, url: Url,
@ -48,36 +50,44 @@ pub enum MentionUri {
impl MentionUri { impl MentionUri {
pub fn parse(input: &str) -> Result<Self> { pub fn parse(input: &str) -> Result<Self> {
let url = url::Url::parse(input)?; fn parse_line_range(fragment: &str) -> Result<RangeInclusive<u32>> {
let path = url.path();
match url.scheme() {
"file" => {
let path = url.to_file_path().ok().context("Extracting file path")?;
if let Some(fragment) = url.fragment() {
let range = fragment let range = fragment
.strip_prefix("L") .strip_prefix("L")
.context("Line range must start with \"L\"")?; .context("Line range must start with \"L\"")?;
let (start, end) = range let (start, end) = range
.split_once(":") .split_once(":")
.context("Line range must use colon as separator")?; .context("Line range must use colon as separator")?;
let line_range = start let range = start
.parse::<u32>() .parse::<u32>()
.context("Parsing line range start")? .context("Parsing line range start")?
.checked_sub(1) .checked_sub(1)
.context("Line numbers should be 1-based")? .context("Line numbers should be 1-based")?
..end ..=end
.parse::<u32>() .parse::<u32>()
.context("Parsing line range end")? .context("Parsing line range end")?
.checked_sub(1) .checked_sub(1)
.context("Line numbers should be 1-based")?; .context("Line numbers should be 1-based")?;
Ok(range)
}
let url = url::Url::parse(input)?;
let path = url.path();
match url.scheme() {
"file" => {
let path = url.to_file_path().ok().context("Extracting file path")?;
if let Some(fragment) = url.fragment() {
let line_range = parse_line_range(fragment)?;
if let Some(name) = single_query_param(&url, "symbol")? { if let Some(name) = single_query_param(&url, "symbol")? {
Ok(Self::Symbol { Ok(Self::Symbol {
name, name,
path, abs_path: path,
line_range, line_range,
}) })
} else { } else {
Ok(Self::Selection { path, line_range }) Ok(Self::Selection {
abs_path: Some(path),
line_range,
})
} }
} else if input.ends_with("/") { } else if input.ends_with("/") {
Ok(Self::Directory { abs_path: path }) Ok(Self::Directory { abs_path: path })
@ -105,6 +115,17 @@ impl MentionUri {
id: rule_id.into(), id: rule_id.into(),
name, name,
}) })
} else if path.starts_with("/agent/pasted-image") {
Ok(Self::PastedImage)
} else if path.starts_with("/agent/untitled-buffer") {
let fragment = url
.fragment()
.context("Missing fragment for untitled buffer selection")?;
let line_range = parse_line_range(fragment)?;
Ok(Self::Selection {
abs_path: None,
line_range,
})
} else { } else {
bail!("invalid zed url: {:?}", input); bail!("invalid zed url: {:?}", input);
} }
@ -121,13 +142,16 @@ impl MentionUri {
.unwrap_or_default() .unwrap_or_default()
.to_string_lossy() .to_string_lossy()
.into_owned(), .into_owned(),
MentionUri::PastedImage => "Image".to_string(),
MentionUri::Symbol { name, .. } => name.clone(), MentionUri::Symbol { name, .. } => name.clone(),
MentionUri::Thread { name, .. } => name.clone(), MentionUri::Thread { name, .. } => name.clone(),
MentionUri::TextThread { name, .. } => name.clone(), MentionUri::TextThread { name, .. } => name.clone(),
MentionUri::Rule { name, .. } => name.clone(), MentionUri::Rule { name, .. } => name.clone(),
MentionUri::Selection { MentionUri::Selection {
path, line_range, .. abs_path: path,
} => selection_name(path, line_range), line_range,
..
} => selection_name(path.as_deref(), line_range),
MentionUri::Fetch { url } => url.to_string(), MentionUri::Fetch { url } => url.to_string(),
} }
} }
@ -137,6 +161,7 @@ impl MentionUri {
MentionUri::File { abs_path } => { MentionUri::File { abs_path } => {
FileIcons::get_icon(abs_path, cx).unwrap_or_else(|| IconName::File.path().into()) FileIcons::get_icon(abs_path, cx).unwrap_or_else(|| IconName::File.path().into())
} }
MentionUri::PastedImage => IconName::Image.path().into(),
MentionUri::Directory { .. } => FileIcons::get_folder_icon(false, cx) MentionUri::Directory { .. } => FileIcons::get_folder_icon(false, cx)
.unwrap_or_else(|| IconName::Folder.path().into()), .unwrap_or_else(|| IconName::Folder.path().into()),
MentionUri::Symbol { .. } => IconName::Code.path().into(), MentionUri::Symbol { .. } => IconName::Code.path().into(),
@ -157,29 +182,40 @@ impl MentionUri {
MentionUri::File { abs_path } => { MentionUri::File { abs_path } => {
Url::from_file_path(abs_path).expect("mention path should be absolute") Url::from_file_path(abs_path).expect("mention path should be absolute")
} }
MentionUri::PastedImage => Url::parse("zed:///agent/pasted-image").unwrap(),
MentionUri::Directory { abs_path } => { MentionUri::Directory { abs_path } => {
Url::from_directory_path(abs_path).expect("mention path should be absolute") Url::from_directory_path(abs_path).expect("mention path should be absolute")
} }
MentionUri::Symbol { MentionUri::Symbol {
path, abs_path,
name, name,
line_range, line_range,
} => { } => {
let mut url = Url::from_file_path(path).expect("mention path should be absolute"); let mut url =
Url::from_file_path(abs_path).expect("mention path should be absolute");
url.query_pairs_mut().append_pair("symbol", name); url.query_pairs_mut().append_pair("symbol", name);
url.set_fragment(Some(&format!( url.set_fragment(Some(&format!(
"L{}:{}", "L{}:{}",
line_range.start + 1, line_range.start() + 1,
line_range.end + 1 line_range.end() + 1
))); )));
url url
} }
MentionUri::Selection { path, line_range } => { MentionUri::Selection {
let mut url = Url::from_file_path(path).expect("mention path should be absolute"); abs_path: path,
line_range,
} => {
let mut url = if let Some(path) = path {
Url::from_file_path(path).expect("mention path should be absolute")
} else {
let mut url = Url::parse("zed:///").unwrap();
url.set_path("/agent/untitled-buffer");
url
};
url.set_fragment(Some(&format!( url.set_fragment(Some(&format!(
"L{}:{}", "L{}:{}",
line_range.start + 1, line_range.start() + 1,
line_range.end + 1 line_range.end() + 1
))); )));
url url
} }
@ -191,7 +227,10 @@ impl MentionUri {
} }
MentionUri::TextThread { path, name } => { MentionUri::TextThread { path, name } => {
let mut url = Url::parse("zed:///").unwrap(); let mut url = Url::parse("zed:///").unwrap();
url.set_path(&format!("/agent/text-thread/{}", path.to_string_lossy())); url.set_path(&format!(
"/agent/text-thread/{}",
path.to_string_lossy().trim_start_matches('/')
));
url.query_pairs_mut().append_pair("name", name); url.query_pairs_mut().append_pair("name", name);
url url
} }
@ -237,12 +276,14 @@ fn single_query_param(url: &Url, name: &'static str) -> Result<Option<String>> {
} }
} }
pub fn selection_name(path: &Path, line_range: &Range<u32>) -> String { pub fn selection_name(path: Option<&Path>, line_range: &RangeInclusive<u32>) -> String {
format!( format!(
"{} ({}:{})", "{} ({}:{})",
path.file_name().unwrap_or_default().display(), path.and_then(|path| path.file_name())
line_range.start + 1, .unwrap_or("Untitled".as_ref())
line_range.end + 1 .display(),
*line_range.start() + 1,
*line_range.end() + 1
) )
} }
@ -302,14 +343,14 @@ mod tests {
let parsed = MentionUri::parse(symbol_uri).unwrap(); let parsed = MentionUri::parse(symbol_uri).unwrap();
match &parsed { match &parsed {
MentionUri::Symbol { MentionUri::Symbol {
path, abs_path: path,
name, name,
line_range, line_range,
} => { } => {
assert_eq!(path.to_str().unwrap(), path!("/path/to/file.rs")); assert_eq!(path.to_str().unwrap(), path!("/path/to/file.rs"));
assert_eq!(name, "MySymbol"); assert_eq!(name, "MySymbol");
assert_eq!(line_range.start, 9); assert_eq!(line_range.start(), &9);
assert_eq!(line_range.end, 19); assert_eq!(line_range.end(), &19);
} }
_ => panic!("Expected Symbol variant"), _ => panic!("Expected Symbol variant"),
} }
@ -321,16 +362,39 @@ mod tests {
let selection_uri = uri!("file:///path/to/file.rs#L5:15"); let selection_uri = uri!("file:///path/to/file.rs#L5:15");
let parsed = MentionUri::parse(selection_uri).unwrap(); let parsed = MentionUri::parse(selection_uri).unwrap();
match &parsed { match &parsed {
MentionUri::Selection { path, line_range } => { MentionUri::Selection {
assert_eq!(path.to_str().unwrap(), path!("/path/to/file.rs")); abs_path: path,
assert_eq!(line_range.start, 4); line_range,
assert_eq!(line_range.end, 14); } => {
assert_eq!(
path.as_ref().unwrap().to_str().unwrap(),
path!("/path/to/file.rs")
);
assert_eq!(line_range.start(), &4);
assert_eq!(line_range.end(), &14);
} }
_ => panic!("Expected Selection variant"), _ => panic!("Expected Selection variant"),
} }
assert_eq!(parsed.to_uri().to_string(), selection_uri); assert_eq!(parsed.to_uri().to_string(), selection_uri);
} }
#[test]
fn test_parse_untitled_selection_uri() {
let selection_uri = uri!("zed:///agent/untitled-buffer#L1:10");
let parsed = MentionUri::parse(selection_uri).unwrap();
match &parsed {
MentionUri::Selection {
abs_path: None,
line_range,
} => {
assert_eq!(line_range.start(), &0);
assert_eq!(line_range.end(), &9);
}
_ => panic!("Expected Selection variant without path"),
}
assert_eq!(parsed.to_uri().to_string(), selection_uri);
}
#[test] #[test]
fn test_parse_thread_uri() { fn test_parse_thread_uri() {
let thread_uri = "zed:///agent/thread/session123?name=Thread+name"; let thread_uri = "zed:///agent/thread/session123?name=Thread+name";

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

@ -893,8 +893,19 @@ impl ThreadsDatabase {
let needs_migration_from_heed = mdb_path.exists(); let needs_migration_from_heed = mdb_path.exists();
let connection = if *ZED_STATELESS || cfg!(any(feature = "test-support", test)) { let connection = if *ZED_STATELESS {
Connection::open_memory(Some("THREAD_FALLBACK_DB")) Connection::open_memory(Some("THREAD_FALLBACK_DB"))
} else if cfg!(any(feature = "test-support", test)) {
// rust stores the name of the test on the current thread.
// We use this to automatically create a database that will
// be shared within the test (for the test_retrieve_old_thread)
// but not with concurrent tests.
let thread = std::thread::current();
let test_name = thread.name();
Connection::open_memory(Some(&format!(
"THREAD_FALLBACK_{}",
test_name.unwrap_or_default()
)))
} else { } else {
Connection::open_file(&sqlite_path.to_string_lossy()) Connection::open_file(&sqlite_path.to_string_lossy())
}; };

View file

@ -180,7 +180,7 @@ impl NativeAgent {
fs: Arc<dyn Fs>, fs: Arc<dyn Fs>,
cx: &mut AsyncApp, cx: &mut AsyncApp,
) -> Result<Entity<NativeAgent>> { ) -> Result<Entity<NativeAgent>> {
log::info!("Creating new NativeAgent"); log::debug!("Creating new NativeAgent");
let project_context = cx let project_context = cx
.update(|cx| Self::build_project_context(&project, prompt_store.as_ref(), cx))? .update(|cx| Self::build_project_context(&project, prompt_store.as_ref(), cx))?
@ -756,7 +756,7 @@ impl NativeAgentConnection {
} }
} }
log::info!("Response stream completed"); log::debug!("Response stream completed");
anyhow::Ok(acp::PromptResponse { anyhow::Ok(acp::PromptResponse {
stop_reason: acp::StopReason::EndTurn, stop_reason: acp::StopReason::EndTurn,
}) })
@ -781,7 +781,7 @@ impl AgentModelSelector for NativeAgentConnection {
model_id: acp_thread::AgentModelId, model_id: acp_thread::AgentModelId,
cx: &mut App, cx: &mut App,
) -> Task<Result<()>> { ) -> Task<Result<()>> {
log::info!("Setting model for session {}: {}", session_id, model_id); log::debug!("Setting model for session {}: {}", session_id, model_id);
let Some(thread) = self let Some(thread) = self
.0 .0
.read(cx) .read(cx)
@ -852,7 +852,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
cx: &mut App, cx: &mut App,
) -> Task<Result<Entity<acp_thread::AcpThread>>> { ) -> Task<Result<Entity<acp_thread::AcpThread>>> {
let agent = self.0.clone(); let agent = self.0.clone();
log::info!("Creating new thread for project at: {:?}", cwd); log::debug!("Creating new thread for project at: {:?}", cwd);
cx.spawn(async move |cx| { cx.spawn(async move |cx| {
log::debug!("Starting thread creation in async context"); log::debug!("Starting thread creation in async context");
@ -917,7 +917,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
.into_iter() .into_iter()
.map(Into::into) .map(Into::into)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
log::info!("Converted prompt to message: {} chars", content.len()); log::debug!("Converted prompt to message: {} chars", content.len());
log::debug!("Message id: {:?}", id); log::debug!("Message id: {:?}", id);
log::debug!("Message content: {:?}", content); log::debug!("Message content: {:?}", content);
@ -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

@ -266,8 +266,19 @@ impl ThreadsDatabase {
} }
pub fn new(executor: BackgroundExecutor) -> Result<Self> { pub fn new(executor: BackgroundExecutor) -> Result<Self> {
let connection = if *ZED_STATELESS || cfg!(any(feature = "test-support", test)) { let connection = if *ZED_STATELESS {
Connection::open_memory(Some("THREAD_FALLBACK_DB")) Connection::open_memory(Some("THREAD_FALLBACK_DB"))
} else if cfg!(any(feature = "test-support", test)) {
// rust stores the name of the test on the current thread.
// We use this to automatically create a database that will
// be shared within the test (for the test_retrieve_old_thread)
// but not with concurrent tests.
let thread = std::thread::current();
let test_name = thread.name();
Connection::open_memory(Some(&format!(
"THREAD_FALLBACK_{}",
test_name.unwrap_or_default()
)))
} else { } else {
let threads_dir = paths::data_dir().join("threads"); let threads_dir = paths::data_dir().join("threads");
std::fs::create_dir_all(&threads_dir)?; std::fs::create_dir_all(&threads_dir)?;

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 {
@ -44,7 +44,7 @@ impl AgentServer for NativeAgentServer {
project: &Entity<Project>, project: &Entity<Project>,
cx: &mut App, cx: &mut App,
) -> Task<Result<Rc<dyn acp_thread::AgentConnection>>> { ) -> Task<Result<Rc<dyn acp_thread::AgentConnection>>> {
log::info!( log::debug!(
"NativeAgentServer::connect called for path: {:?}", "NativeAgentServer::connect called for path: {:?}",
_root_dir _root_dir
); );
@ -63,7 +63,7 @@ impl AgentServer for NativeAgentServer {
// Create the connection wrapper // Create the connection wrapper
let connection = NativeAgentConnection(agent); let connection = NativeAgentConnection(agent);
log::info!("NativeAgentServer connection established successfully"); log::debug!("NativeAgentServer connection established successfully");
Ok(Rc::new(connection) as Rc<dyn acp_thread::AgentConnection>) Ok(Rc::new(connection) as Rc<dyn acp_thread::AgentConnection>)
}) })

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

@ -45,14 +45,15 @@ use schemars::{JsonSchema, Schema};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use settings::{Settings, update_settings_file}; use settings::{Settings, update_settings_file};
use smol::stream::StreamExt; use smol::stream::StreamExt;
use std::fmt::Write;
use std::{ use std::{
collections::BTreeMap, collections::BTreeMap,
ops::RangeInclusive,
path::Path, path::Path,
sync::Arc, sync::Arc,
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use std::{fmt::Write, ops::Range}; use util::{ResultExt, debug_panic, markdown::MarkdownCodeBlock};
use util::{ResultExt, markdown::MarkdownCodeBlock};
use uuid::Uuid; use uuid::Uuid;
const TOOL_CANCELED_MESSAGE: &str = "Tool canceled by user"; const TOOL_CANCELED_MESSAGE: &str = "Tool canceled by user";
@ -122,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(),
} }
} }
@ -187,6 +188,7 @@ impl UserMessage {
const OPEN_FILES_TAG: &str = "<files>"; const OPEN_FILES_TAG: &str = "<files>";
const OPEN_DIRECTORIES_TAG: &str = "<directories>"; const OPEN_DIRECTORIES_TAG: &str = "<directories>";
const OPEN_SYMBOLS_TAG: &str = "<symbols>"; const OPEN_SYMBOLS_TAG: &str = "<symbols>";
const OPEN_SELECTIONS_TAG: &str = "<selections>";
const OPEN_THREADS_TAG: &str = "<threads>"; const OPEN_THREADS_TAG: &str = "<threads>";
const OPEN_FETCH_TAG: &str = "<fetched_urls>"; const OPEN_FETCH_TAG: &str = "<fetched_urls>";
const OPEN_RULES_TAG: &str = const OPEN_RULES_TAG: &str =
@ -195,6 +197,7 @@ impl UserMessage {
let mut file_context = OPEN_FILES_TAG.to_string(); let mut file_context = OPEN_FILES_TAG.to_string();
let mut directory_context = OPEN_DIRECTORIES_TAG.to_string(); let mut directory_context = OPEN_DIRECTORIES_TAG.to_string();
let mut symbol_context = OPEN_SYMBOLS_TAG.to_string(); let mut symbol_context = OPEN_SYMBOLS_TAG.to_string();
let mut selection_context = OPEN_SELECTIONS_TAG.to_string();
let mut thread_context = OPEN_THREADS_TAG.to_string(); let mut thread_context = OPEN_THREADS_TAG.to_string();
let mut fetch_context = OPEN_FETCH_TAG.to_string(); let mut fetch_context = OPEN_FETCH_TAG.to_string();
let mut rules_context = OPEN_RULES_TAG.to_string(); let mut rules_context = OPEN_RULES_TAG.to_string();
@ -211,7 +214,7 @@ impl UserMessage {
match uri { match uri {
MentionUri::File { abs_path } => { MentionUri::File { abs_path } => {
write!( write!(
&mut symbol_context, &mut file_context,
"\n{}", "\n{}",
MarkdownCodeBlock { MarkdownCodeBlock {
tag: &codeblock_tag(abs_path, None), tag: &codeblock_tag(abs_path, None),
@ -220,17 +223,19 @@ impl UserMessage {
) )
.ok(); .ok();
} }
MentionUri::PastedImage => {
debug_panic!("pasted image URI should not be used in mention content")
}
MentionUri::Directory { .. } => { MentionUri::Directory { .. } => {
write!(&mut directory_context, "\n{}\n", content).ok(); write!(&mut directory_context, "\n{}\n", content).ok();
} }
MentionUri::Symbol { MentionUri::Symbol {
path, line_range, .. abs_path: path,
} line_range,
| MentionUri::Selection { ..
path, line_range, ..
} => { } => {
write!( write!(
&mut rules_context, &mut symbol_context,
"\n{}", "\n{}",
MarkdownCodeBlock { MarkdownCodeBlock {
tag: &codeblock_tag(path, Some(line_range)), tag: &codeblock_tag(path, Some(line_range)),
@ -239,6 +244,24 @@ impl UserMessage {
) )
.ok(); .ok();
} }
MentionUri::Selection {
abs_path: path,
line_range,
..
} => {
write!(
&mut selection_context,
"\n{}",
MarkdownCodeBlock {
tag: &codeblock_tag(
path.as_deref().unwrap_or("Untitled".as_ref()),
Some(line_range)
),
text: content
}
)
.ok();
}
MentionUri::Thread { .. } => { MentionUri::Thread { .. } => {
write!(&mut thread_context, "\n{}\n", content).ok(); write!(&mut thread_context, "\n{}\n", content).ok();
} }
@ -291,6 +314,13 @@ impl UserMessage {
.push(language_model::MessageContent::Text(symbol_context)); .push(language_model::MessageContent::Text(symbol_context));
} }
if selection_context.len() > OPEN_SELECTIONS_TAG.len() {
selection_context.push_str("</selections>\n");
message
.content
.push(language_model::MessageContent::Text(selection_context));
}
if thread_context.len() > OPEN_THREADS_TAG.len() { if thread_context.len() > OPEN_THREADS_TAG.len() {
thread_context.push_str("</threads>\n"); thread_context.push_str("</threads>\n");
message message
@ -326,7 +356,7 @@ impl UserMessage {
} }
} }
fn codeblock_tag(full_path: &Path, line_range: Option<&Range<u32>>) -> String { fn codeblock_tag(full_path: &Path, line_range: Option<&RangeInclusive<u32>>) -> String {
let mut result = String::new(); let mut result = String::new();
if let Some(extension) = full_path.extension().and_then(|ext| ext.to_str()) { if let Some(extension) = full_path.extension().and_then(|ext| ext.to_str()) {
@ -336,10 +366,10 @@ fn codeblock_tag(full_path: &Path, line_range: Option<&Range<u32>>) -> String {
let _ = write!(result, "{}", full_path.display()); let _ = write!(result, "{}", full_path.display());
if let Some(range) = line_range { if let Some(range) = line_range {
if range.start == range.end { if range.start() == range.end() {
let _ = write!(result, ":{}", range.start + 1); let _ = write!(result, ":{}", range.start() + 1);
} else { } else {
let _ = write!(result, ":{}-{}", range.start + 1, range.end + 1); let _ = write!(result, ":{}-{}", range.start() + 1, range.end() + 1);
} }
} }
@ -418,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
.content
.push(language_model::MessageContent::Thinking {
text: text.clone(), text: text.clone(),
signature: signature.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(tool_use) => {
if self.tool_results.contains_key(&tool_use.id) {
assistant_message
.content
.push(language_model::MessageContent::ToolUse(tool_use.clone()));
} }
AgentMessageContent::ToolUse(value) => {
language_model::MessageContent::ToolUse(value.clone())
} }
}; };
assistant_message.content.push(chunk);
} }
let mut user_message = LanguageModelRequestMessage { let mut user_message = LanguageModelRequestMessage {
@ -1046,15 +1085,10 @@ 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();
log::info!("Total messages in thread: {}", self.messages.len()); log::debug!("Total messages in thread: {}", self.messages.len());
self.run_turn(cx) self.run_turn(cx)
} }
@ -1072,7 +1106,7 @@ impl Thread {
{ {
let model = self.model().context("No language model configured")?; let model = self.model().context("No language model configured")?;
log::info!("Thread::send called with model: {:?}", model.name()); log::info!("Thread::send called with model: {}", model.name().0);
self.advance_prompt_id(); self.advance_prompt_id();
let content = content.into_iter().map(Into::into).collect::<Vec<_>>(); let content = content.into_iter().map(Into::into).collect::<Vec<_>>();
@ -1082,7 +1116,7 @@ impl Thread {
.push(Message::User(UserMessage { id, content })); .push(Message::User(UserMessage { id, content }));
cx.notify(); cx.notify();
log::info!("Total messages in thread: {}", self.messages.len()); log::debug!("Total messages in thread: {}", self.messages.len());
self.run_turn(cx) self.run_turn(cx)
} }
@ -1106,44 +1140,14 @@ impl Thread {
event_stream: event_stream.clone(), event_stream: event_stream.clone(),
tools: self.enabled_tools(profile, &model, cx), tools: self.enabled_tools(profile, &model, cx),
_task: cx.spawn(async move |this, cx| { _task: cx.spawn(async move |this, cx| {
log::info!("Starting agent turn execution"); log::debug!("Starting agent turn execution");
let turn_result: Result<()> = async { let turn_result = Self::run_turn_internal(&this, model, &event_stream, cx).await;
let mut intent = CompletionIntent::UserPrompt;
loop {
Self::stream_completion(&this, &model, intent, &event_stream, cx).await?;
let mut end_turn = true;
this.update(cx, |this, cx| {
// Generate title if needed.
if this.title.is_none() && this.pending_title_generation.is_none() {
this.generate_title(cx);
}
// End the turn if the model didn't use tools.
let message = this.pending_message.as_ref();
end_turn =
message.map_or(true, |message| message.tool_results.is_empty());
this.flush_pending_message(cx);
})?;
if this.read_with(cx, |this, _| this.tool_use_limit_reached)? {
log::info!("Tool use limit reached, completing turn");
return Err(language_model::ToolUseLimitReachedError.into());
} else if end_turn {
log::info!("No tool uses found, completing turn");
return Ok(());
} else {
intent = CompletionIntent::ToolResults;
}
}
}
.await;
_ = this.update(cx, |this, cx| this.flush_pending_message(cx)); _ = this.update(cx, |this, cx| this.flush_pending_message(cx));
match turn_result { match turn_result {
Ok(()) => { Ok(()) => {
log::info!("Turn execution completed"); log::debug!("Turn execution completed");
event_stream.send_stop(acp::StopReason::EndTurn); event_stream.send_stop(acp::StopReason::EndTurn);
} }
Err(error) => { Err(error) => {
@ -1169,20 +1173,18 @@ impl Thread {
Ok(events_rx) Ok(events_rx)
} }
async fn stream_completion( async fn run_turn_internal(
this: &WeakEntity<Self>, this: &WeakEntity<Self>,
model: &Arc<dyn LanguageModel>, model: Arc<dyn LanguageModel>,
completion_intent: CompletionIntent,
event_stream: &ThreadEventStream, event_stream: &ThreadEventStream,
cx: &mut AsyncApp, cx: &mut AsyncApp,
) -> Result<()> { ) -> Result<()> {
log::debug!("Stream completion started successfully"); let mut attempt = 0;
let request = this.update(cx, |this, cx| { let mut intent = CompletionIntent::UserPrompt;
this.build_completion_request(completion_intent, cx) loop {
})??; let request =
this.update(cx, |this, cx| this.build_completion_request(intent, cx))??;
let mut attempt = None;
'retry: loop {
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())?,
@ -1192,75 +1194,31 @@ impl Thread {
attempt attempt
); );
log::info!( log::debug!("Calling model.stream_completion, attempt {}", attempt);
"Calling model.stream_completion, attempt {}",
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 {
log::trace!("Received completion event: {:?}", event);
match event { match event {
Ok(event) => { Ok(event) => {
log::trace!("Received completion event: {:?}", event);
tool_results.extend(this.update(cx, |this, cx| { tool_results.extend(this.update(cx, |this, cx| {
this.handle_streamed_completion_event(event, event_stream, cx) this.handle_completion_event(event, event_stream, cx)
})??); })??);
} }
Err(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;
} }
} }
} }
let end_turn = tool_results.is_empty();
while let Some(tool_result) = tool_results.next().await { while let Some(tool_result) = tool_results.next().await {
log::info!("Tool finished {:?}", tool_result); log::debug!("Tool finished {:?}", tool_result);
event_stream.update_tool_call_fields( event_stream.update_tool_call_fields(
&tool_result.tool_use_id, &tool_result.tool_use_id,
@ -1281,31 +1239,83 @@ impl Thread {
})?; })?;
} }
this.update(cx, |this, cx| {
this.flush_pending_message(cx);
if this.title.is_none() && this.pending_title_generation.is_none() {
this.generate_title(cx);
}
})?;
if let Some(error) = error {
attempt += 1;
let retry =
this.update(cx, |this, _| this.handle_completion_error(error, attempt))??;
let timer = cx.background_executor().timer(retry.duration);
event_stream.send_retry(retry);
timer.await;
this.update(cx, |this, _cx| {
if let Some(Message::Agent(message)) = this.messages.last() {
if message.tool_results.is_empty() {
intent = CompletionIntent::UserPrompt;
this.messages.push(Message::Resume);
}
}
})?;
} else if this.read_with(cx, |this, _| this.tool_use_limit_reached)? {
return Err(language_model::ToolUseLimitReachedError.into());
} else if end_turn {
return Ok(()); return Ok(());
} else {
intent = CompletionIntent::ToolResults;
attempt = 0;
}
} }
} }
pub fn build_system_message(&self, cx: &App) -> LanguageModelRequestMessage { fn handle_completion_error(
log::debug!("Building system message"); &mut self,
let prompt = SystemPromptTemplate { error: LanguageModelCompletionError,
project: self.project_context.read(cx), attempt: u8,
available_tools: self.tools.keys().cloned().collect(), ) -> Result<acp_thread::RetryStatus> {
if self.completion_mode == CompletionMode::Normal {
return Err(anyhow!(error));
} }
.render(&self.templates)
.context("failed to build system prompt") let Some(strategy) = Self::retry_strategy_for(&error) else {
.expect("Invalid template"); return Err(anyhow!(error));
log::debug!("System message built"); };
LanguageModelRequestMessage {
role: Role::System, let max_attempts = match &strategy {
content: vec![prompt.into()], RetryStrategy::ExponentialBackoff { max_attempts, .. } => *max_attempts,
cache: true, RetryStrategy::Fixed { max_attempts, .. } => *max_attempts,
};
if attempt > max_attempts {
return Err(anyhow!(error));
} }
let delay = match &strategy {
RetryStrategy::ExponentialBackoff { initial_delay, .. } => {
let delay_secs = initial_delay.as_secs() * 2u64.pow((attempt - 1) as u32);
Duration::from_secs(delay_secs)
}
RetryStrategy::Fixed { delay, .. } => *delay,
};
log::debug!("Retry attempt {attempt} with delay {delay:?}");
Ok(acp_thread::RetryStatus {
last_error: error.to_string().into(),
attempt: attempt as usize,
max_attempts: max_attempts as usize,
started_at: Instant::now(),
duration: delay,
})
} }
/// A helper method that's called on every streamed completion event. /// A helper method that's called on every streamed completion event.
/// Returns an optional tool result task, which the main agentic loop will /// Returns an optional tool result task, which the main agentic loop will
/// send back to the model when it resolves. /// send back to the model when it resolves.
fn handle_streamed_completion_event( fn handle_completion_event(
&mut self, &mut self,
event: LanguageModelCompletionEvent, event: LanguageModelCompletionEvent,
event_stream: &ThreadEventStream, event_stream: &ThreadEventStream,
@ -1500,7 +1510,7 @@ impl Thread {
}); });
let supports_images = self.model().is_some_and(|model| model.supports_images()); let supports_images = self.model().is_some_and(|model| model.supports_images());
let tool_result = tool.run(tool_use.input, tool_event_stream, cx); let tool_result = tool.run(tool_use.input, tool_event_stream, cx);
log::info!("Running tool {}", tool_use.name); log::debug!("Running tool {}", tool_use.name);
Some(cx.foreground_executor().spawn(async move { Some(cx.foreground_executor().spawn(async move {
let tool_result = tool_result.await.and_then(|output| { let tool_result = tool_result.await.and_then(|output| {
if let LanguageModelToolResultContent::Image(_) = &output.llm_output if let LanguageModelToolResultContent::Image(_) = &output.llm_output
@ -1612,7 +1622,7 @@ impl Thread {
summary.extend(lines.next()); summary.extend(lines.next());
} }
log::info!("Setting summary: {}", summary); log::debug!("Setting summary: {}", summary);
let summary = SharedString::from(summary); let summary = SharedString::from(summary);
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
@ -1629,7 +1639,7 @@ impl Thread {
return; return;
}; };
log::info!( log::debug!(
"Generating title with model: {:?}", "Generating title with model: {:?}",
self.summarization_model.as_ref().map(|model| model.name()) self.summarization_model.as_ref().map(|model| model.name())
); );
@ -1715,6 +1725,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;
@ -1743,7 +1757,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() {
@ -1767,8 +1781,8 @@ impl Thread {
log::debug!("Completion mode: {:?}", self.completion_mode); log::debug!("Completion mode: {:?}", self.completion_mode);
let messages = self.build_request_messages(cx); let messages = self.build_request_messages(cx);
log::info!("Request will include {} messages", messages.len()); log::debug!("Request will include {} messages", messages.len());
log::info!("Request includes {} tools", tools.len()); log::debug!("Request includes {} tools", tools.len());
let request = LanguageModelRequest { let request = LanguageModelRequest {
thread_id: Some(self.id.to_string()), thread_id: Some(self.id.to_string()),
@ -1864,21 +1878,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

@ -247,9 +247,9 @@ impl ContextPickerCompletionProvider {
let abs_path = project.read(cx).absolute_path(&symbol.path, cx)?; let abs_path = project.read(cx).absolute_path(&symbol.path, cx)?;
let uri = MentionUri::Symbol { let uri = MentionUri::Symbol {
path: abs_path, abs_path,
name: symbol.name.clone(), name: symbol.name.clone(),
line_range: symbol.range.start.0.row..symbol.range.end.0.row, line_range: symbol.range.start.0.row..=symbol.range.end.0.row,
}; };
let new_text = format!("{} ", uri.as_link()); let new_text = format!("{} ", uri.as_link());
let new_text_len = new_text.len(); let new_text_len = new_text.len();

File diff suppressed because it is too large Load diff

View file

@ -277,6 +277,7 @@ pub struct AcpThreadView {
should_be_following: bool, should_be_following: 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],
} }
@ -389,6 +390,7 @@ impl AcpThreadView {
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 +602,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 +764,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 +796,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 +822,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 +841,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()),
@ -837,7 +860,7 @@ impl AcpThreadView {
let contents = self let contents = self
.message_editor .message_editor
.update(cx, |message_editor, cx| message_editor.contents(window, cx)); .update(cx, |message_editor, cx| message_editor.contents(cx));
self.send_impl(contents, window, cx) self.send_impl(contents, window, cx)
} }
@ -850,7 +873,7 @@ impl AcpThreadView {
let contents = self let contents = self
.message_editor .message_editor
.update(cx, |message_editor, cx| message_editor.contents(window, cx)); .update(cx, |message_editor, cx| message_editor.contents(cx));
cx.spawn_in(window, async move |this, cx| { cx.spawn_in(window, async move |this, cx| {
cancelled.await; cancelled.await;
@ -883,6 +906,15 @@ impl AcpThreadView {
}) })
.ok(); .ok();
} }
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?;
@ -903,6 +935,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
@ -967,20 +1000,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 = let contents = message_editor.update(cx, |message_editor, cx| message_editor.contents(cx));
message_editor.update(cx, |message_editor, cx| message_editor.contents(window, 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);
} }
@ -1295,11 +1332,24 @@ impl AcpThreadView {
None None
}; };
let has_checkpoint_button = message
.checkpoint
.as_ref()
.is_some_and(|checkpoint| checkpoint.show);
let agent_name = self.agent.name(); let agent_name = self.agent.name();
v_flex() v_flex()
.id(("user_message", entry_ix)) .id(("user_message", entry_ix))
.pt_2() .map(|this| {
if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() {
this.pt_4()
} else if rules_item.is_some() {
this.pt_3()
} else {
this.pt_2()
}
})
.pb_4() .pb_4()
.px_2() .px_2()
.gap_1p5() .gap_1p5()
@ -1308,6 +1358,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(
@ -1367,11 +1418,19 @@ 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(
if self.is_loading_contents {
div()
.id("loading-edited-message-content")
.tooltip(Tooltip::text("Loading Added Context…"))
.child(loading_contents_spinner(IconSize::XSmall))
.into_any_element()
} else {
IconButton::new("regenerate", IconName::Return) IconButton::new("regenerate", IconName::Return)
.icon_color(Color::Muted) .icon_color(Color::Muted)
.icon_size(IconSize::XSmall) .icon_size(IconSize::XSmall)
@ -1385,7 +1444,8 @@ impl AcpThreadView {
entry_ix, &editor, window, cx, entry_ix, &editor, window, cx,
); );
} }
})), })).into_any_element()
}
) )
) )
} else { } else {
@ -1398,7 +1458,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()
}) })
) )
@ -1476,9 +1536,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 {
@ -1709,6 +1767,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 { .. }
@ -1726,7 +1785,7 @@ impl AcpThreadView {
.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.,
@ -1886,7 +1945,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)
@ -2337,8 +2396,7 @@ impl AcpThreadView {
} else { } else {
this.expanded_tool_calls.insert(id.clone()); this.expanded_tool_calls.insert(id.clone());
} }
} }})),
})),
); );
let terminal_view = self let terminal_view = self
@ -2446,7 +2504,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)
@ -2462,8 +2519,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"))
@ -2477,7 +2533,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()
@ -2486,8 +2548,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"))
@ -3064,13 +3125,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| {
@ -3577,7 +3638,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))
@ -3696,6 +3764,7 @@ impl AcpThreadView {
.open_path(path, None, true, window, cx) .open_path(path, None, true, window, cx)
.detach_and_log_err(cx); .detach_and_log_err(cx);
} }
MentionUri::PastedImage => {}
MentionUri::Directory { abs_path } => { MentionUri::Directory { abs_path } => {
let project = workspace.project(); let project = workspace.project();
let Some(entry) = project.update(cx, |project, cx| { let Some(entry) = project.update(cx, |project, cx| {
@ -3710,9 +3779,14 @@ impl AcpThreadView {
}); });
} }
MentionUri::Symbol { MentionUri::Symbol {
path, line_range, .. abs_path: path,
line_range,
..
} }
| MentionUri::Selection { path, line_range } => { | MentionUri::Selection {
abs_path: Some(path),
line_range,
} => {
let project = workspace.project(); let project = workspace.project();
let Some((path, _)) = project.update(cx, |project, cx| { let Some((path, _)) = project.update(cx, |project, cx| {
let path = project.find_project_path(path, cx)?; let path = project.find_project_path(path, cx)?;
@ -3728,8 +3802,8 @@ impl AcpThreadView {
let Some(editor) = item.await?.downcast::<Editor>() else { let Some(editor) = item.await?.downcast::<Editor>() else {
return Ok(()); return Ok(());
}; };
let range = let range = Point::new(*line_range.start(), 0)
Point::new(line_range.start, 0)..Point::new(line_range.start, 0); ..Point::new(*line_range.start(), 0);
editor editor
.update_in(cx, |editor, window, cx| { .update_in(cx, |editor, window, cx| {
editor.change_selections( editor.change_selections(
@ -3744,6 +3818,7 @@ impl AcpThreadView {
}) })
.detach_and_log_err(cx); .detach_and_log_err(cx);
} }
MentionUri::Selection { abs_path: None, .. } => {}
MentionUri::Thread { id, name } => { MentionUri::Thread { id, name } => {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) { if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
panel.update(cx, |panel, cx| { panel.update(cx, |panel, cx| {
@ -3952,13 +4027,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 => {
@ -4162,13 +4237,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();
@ -4177,43 +4247,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);
})), })),
@ -4454,12 +4512,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))
} }
@ -4684,6 +4783,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 {
@ -5194,16 +5305,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};
@ -128,7 +129,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)
}); });
} }
}) })
@ -239,7 +240,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,
@ -247,23 +248,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),
} }
} }
} }
@ -517,7 +524,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(
@ -607,7 +614,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();
@ -1077,7 +1084,9 @@ 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({
let agent = agent.clone();
async move {
if let Some(serialized) = if let Some(serialized) =
serde_json::to_string(&LastUsedExternalAgent { agent }).log_err() serde_json::to_string(&LastUsedExternalAgent { agent }).log_err()
{ {
@ -1086,6 +1095,7 @@ impl AgentPanel {
.await .await
.log_err(); .log_err();
} }
}
}) })
.detach(); .detach();
@ -1110,7 +1120,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;
} }
@ -1839,14 +1851,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(
@ -1885,6 +1897,13 @@ impl AgentPanel {
window, window,
cx, cx,
), ),
AgentType::Custom { name, settings } => self.external_thread(
Some(crate::ExternalAgent::Custom { name, settings }),
None,
None,
window,
cx,
),
} }
} }
@ -2610,13 +2629,55 @@ impl AgentPanel {
} }
}), }),
) )
})
.when(cx.has_flag::<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

@ -18,7 +18,6 @@ collections.workspace = true
component.workspace = true component.workspace = true
ctor.workspace = true ctor.workspace = true
editor.workspace = true editor.workspace = true
futures.workspace = true
gpui.workspace = true gpui.workspace = true
indoc.workspace = true indoc.workspace = true
language.workspace = true language.workspace = true

View file

@ -13,7 +13,6 @@ use editor::{
DEFAULT_MULTIBUFFER_CONTEXT, Editor, EditorEvent, ExcerptRange, MultiBuffer, PathKey, DEFAULT_MULTIBUFFER_CONTEXT, Editor, EditorEvent, ExcerptRange, MultiBuffer, PathKey,
display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId}, display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId},
}; };
use futures::future::join_all;
use gpui::{ use gpui::{
AnyElement, AnyView, App, AsyncApp, Context, Entity, EventEmitter, FocusHandle, Focusable, AnyElement, AnyView, App, AsyncApp, Context, Entity, EventEmitter, FocusHandle, Focusable,
Global, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Global, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled,
@ -24,7 +23,6 @@ use language::{
}; };
use project::{ use project::{
DiagnosticSummary, Project, ProjectPath, DiagnosticSummary, Project, ProjectPath,
lsp_store::rust_analyzer_ext::{cancel_flycheck, run_flycheck},
project_settings::{DiagnosticSeverity, ProjectSettings}, project_settings::{DiagnosticSeverity, ProjectSettings},
}; };
use settings::Settings; use settings::Settings;
@ -79,17 +77,10 @@ pub(crate) struct ProjectDiagnosticsEditor {
paths_to_update: BTreeSet<ProjectPath>, paths_to_update: BTreeSet<ProjectPath>,
include_warnings: bool, include_warnings: bool,
update_excerpts_task: Option<Task<Result<()>>>, update_excerpts_task: Option<Task<Result<()>>>,
cargo_diagnostics_fetch: CargoDiagnosticsFetchState,
diagnostic_summary_update: Task<()>, diagnostic_summary_update: Task<()>,
_subscription: Subscription, _subscription: Subscription,
} }
struct CargoDiagnosticsFetchState {
fetch_task: Option<Task<()>>,
cancel_task: Option<Task<()>>,
diagnostic_sources: Arc<Vec<ProjectPath>>,
}
impl EventEmitter<EditorEvent> for ProjectDiagnosticsEditor {} impl EventEmitter<EditorEvent> for ProjectDiagnosticsEditor {}
const DIAGNOSTICS_UPDATE_DELAY: Duration = Duration::from_millis(50); const DIAGNOSTICS_UPDATE_DELAY: Duration = Duration::from_millis(50);
@ -260,11 +251,7 @@ impl ProjectDiagnosticsEditor {
) )
}); });
this.diagnostics.clear(); this.diagnostics.clear();
this.update_all_diagnostics(false, window, cx); this.update_all_excerpts(window, cx);
})
.detach();
cx.observe_release(&cx.entity(), |editor, _, cx| {
editor.stop_cargo_diagnostics_fetch(cx);
}) })
.detach(); .detach();
@ -281,15 +268,10 @@ impl ProjectDiagnosticsEditor {
editor, editor,
paths_to_update: Default::default(), paths_to_update: Default::default(),
update_excerpts_task: None, update_excerpts_task: None,
cargo_diagnostics_fetch: CargoDiagnosticsFetchState {
fetch_task: None,
cancel_task: None,
diagnostic_sources: Arc::new(Vec::new()),
},
diagnostic_summary_update: Task::ready(()), diagnostic_summary_update: Task::ready(()),
_subscription: project_event_subscription, _subscription: project_event_subscription,
}; };
this.update_all_diagnostics(true, window, cx); this.update_all_excerpts(window, cx);
this this
} }
@ -373,20 +355,10 @@ impl ProjectDiagnosticsEditor {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
let fetch_cargo_diagnostics = ProjectSettings::get_global(cx) if self.update_excerpts_task.is_some() {
.diagnostics
.fetch_cargo_diagnostics();
if fetch_cargo_diagnostics {
if self.cargo_diagnostics_fetch.fetch_task.is_some() {
self.stop_cargo_diagnostics_fetch(cx);
} else {
self.update_all_diagnostics(false, window, cx);
}
} else if self.update_excerpts_task.is_some() {
self.update_excerpts_task = None; self.update_excerpts_task = None;
} else { } else {
self.update_all_diagnostics(false, window, cx); self.update_all_excerpts(window, cx);
} }
cx.notify(); cx.notify();
} }
@ -404,73 +376,6 @@ impl ProjectDiagnosticsEditor {
} }
} }
fn update_all_diagnostics(
&mut self,
first_launch: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let cargo_diagnostics_sources = self.cargo_diagnostics_sources(cx);
if cargo_diagnostics_sources.is_empty() {
self.update_all_excerpts(window, cx);
} else if first_launch && !self.summary.is_empty() {
self.update_all_excerpts(window, cx);
} else {
self.fetch_cargo_diagnostics(Arc::new(cargo_diagnostics_sources), cx);
}
}
fn fetch_cargo_diagnostics(
&mut self,
diagnostics_sources: Arc<Vec<ProjectPath>>,
cx: &mut Context<Self>,
) {
let project = self.project.clone();
self.cargo_diagnostics_fetch.cancel_task = None;
self.cargo_diagnostics_fetch.fetch_task = None;
self.cargo_diagnostics_fetch.diagnostic_sources = diagnostics_sources.clone();
if self.cargo_diagnostics_fetch.diagnostic_sources.is_empty() {
return;
}
self.cargo_diagnostics_fetch.fetch_task = Some(cx.spawn(async move |editor, cx| {
let mut fetch_tasks = Vec::new();
for buffer_path in diagnostics_sources.iter().cloned() {
if cx
.update(|cx| {
fetch_tasks.push(run_flycheck(project.clone(), Some(buffer_path), cx));
})
.is_err()
{
break;
}
}
let _ = join_all(fetch_tasks).await;
editor
.update(cx, |editor, _| {
editor.cargo_diagnostics_fetch.fetch_task = None;
})
.ok();
}));
}
fn stop_cargo_diagnostics_fetch(&mut self, cx: &mut App) {
self.cargo_diagnostics_fetch.fetch_task = None;
let mut cancel_gasks = Vec::new();
for buffer_path in std::mem::take(&mut self.cargo_diagnostics_fetch.diagnostic_sources)
.iter()
.cloned()
{
cancel_gasks.push(cancel_flycheck(self.project.clone(), Some(buffer_path), cx));
}
self.cargo_diagnostics_fetch.cancel_task = Some(cx.background_spawn(async move {
let _ = join_all(cancel_gasks).await;
log::info!("Finished fetching cargo diagnostics");
}));
}
/// Enqueue an update of all excerpts. Updates all paths that either /// Enqueue an update of all excerpts. Updates all paths that either
/// currently have diagnostics or are currently present in this view. /// currently have diagnostics or are currently present in this view.
fn update_all_excerpts(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn update_all_excerpts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@ -695,30 +600,6 @@ impl ProjectDiagnosticsEditor {
}) })
}) })
} }
pub fn cargo_diagnostics_sources(&self, cx: &App) -> Vec<ProjectPath> {
let fetch_cargo_diagnostics = ProjectSettings::get_global(cx)
.diagnostics
.fetch_cargo_diagnostics();
if !fetch_cargo_diagnostics {
return Vec::new();
}
self.project
.read(cx)
.worktrees(cx)
.filter_map(|worktree| {
let _cargo_toml_entry = worktree.read(cx).entry_for_path("Cargo.toml")?;
let rust_file_entry = worktree.read(cx).entries(false, 0).find(|entry| {
entry
.path
.extension()
.and_then(|extension| extension.to_str())
== Some("rs")
})?;
self.project.read(cx).path_for_entry(rust_file_entry.id, cx)
})
.collect()
}
} }
impl Focusable for ProjectDiagnosticsEditor { impl Focusable for ProjectDiagnosticsEditor {

View file

@ -1,5 +1,3 @@
use std::sync::Arc;
use crate::{ProjectDiagnosticsEditor, ToggleDiagnosticsRefresh}; use crate::{ProjectDiagnosticsEditor, ToggleDiagnosticsRefresh};
use gpui::{Context, Entity, EventEmitter, ParentElement, Render, WeakEntity, Window}; use gpui::{Context, Entity, EventEmitter, ParentElement, Render, WeakEntity, Window};
use ui::prelude::*; use ui::prelude::*;
@ -15,26 +13,18 @@ impl Render for ToolbarControls {
let mut include_warnings = false; let mut include_warnings = false;
let mut has_stale_excerpts = false; let mut has_stale_excerpts = false;
let mut is_updating = false; let mut is_updating = false;
let cargo_diagnostics_sources = Arc::new(self.diagnostics().map_or(Vec::new(), |editor| {
editor.read(cx).cargo_diagnostics_sources(cx)
}));
let fetch_cargo_diagnostics = !cargo_diagnostics_sources.is_empty();
if let Some(editor) = self.diagnostics() { if let Some(editor) = self.diagnostics() {
let diagnostics = editor.read(cx); let diagnostics = editor.read(cx);
include_warnings = diagnostics.include_warnings; include_warnings = diagnostics.include_warnings;
has_stale_excerpts = !diagnostics.paths_to_update.is_empty(); has_stale_excerpts = !diagnostics.paths_to_update.is_empty();
is_updating = if fetch_cargo_diagnostics { is_updating = diagnostics.update_excerpts_task.is_some()
diagnostics.cargo_diagnostics_fetch.fetch_task.is_some()
} else {
diagnostics.update_excerpts_task.is_some()
|| diagnostics || diagnostics
.project .project
.read(cx) .read(cx)
.language_servers_running_disk_based_diagnostics(cx) .language_servers_running_disk_based_diagnostics(cx)
.next() .next()
.is_some() .is_some();
};
} }
let tooltip = if include_warnings { let tooltip = if include_warnings {
@ -64,7 +54,6 @@ impl Render for ToolbarControls {
.on_click(cx.listener(move |toolbar_controls, _, _, cx| { .on_click(cx.listener(move |toolbar_controls, _, _, cx| {
if let Some(diagnostics) = toolbar_controls.diagnostics() { if let Some(diagnostics) = toolbar_controls.diagnostics() {
diagnostics.update(cx, |diagnostics, cx| { diagnostics.update(cx, |diagnostics, cx| {
diagnostics.stop_cargo_diagnostics_fetch(cx);
diagnostics.update_excerpts_task = None; diagnostics.update_excerpts_task = None;
cx.notify(); cx.notify();
}); });
@ -76,7 +65,7 @@ impl Render for ToolbarControls {
IconButton::new("refresh-diagnostics", IconName::ArrowCircle) IconButton::new("refresh-diagnostics", IconName::ArrowCircle)
.icon_color(Color::Info) .icon_color(Color::Info)
.shape(IconButtonShape::Square) .shape(IconButtonShape::Square)
.disabled(!has_stale_excerpts && !fetch_cargo_diagnostics) .disabled(!has_stale_excerpts)
.tooltip(Tooltip::for_action_title( .tooltip(Tooltip::for_action_title(
"Refresh diagnostics", "Refresh diagnostics",
&ToggleDiagnosticsRefresh, &ToggleDiagnosticsRefresh,
@ -84,17 +73,8 @@ impl Render for ToolbarControls {
.on_click(cx.listener({ .on_click(cx.listener({
move |toolbar_controls, _, window, cx| { move |toolbar_controls, _, window, cx| {
if let Some(diagnostics) = toolbar_controls.diagnostics() { if let Some(diagnostics) = toolbar_controls.diagnostics() {
let cargo_diagnostics_sources =
Arc::clone(&cargo_diagnostics_sources);
diagnostics.update(cx, move |diagnostics, cx| { diagnostics.update(cx, move |diagnostics, cx| {
if fetch_cargo_diagnostics {
diagnostics.fetch_cargo_diagnostics(
cargo_diagnostics_sources,
cx,
);
} else {
diagnostics.update_all_excerpts(window, cx); diagnostics.update_all_excerpts(window, cx);
}
}); });
} }
} }

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,8 +3603,7 @@ 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())
@ -3710,14 +3709,23 @@ impl EditorElement {
.child( .child(
h_flex() h_flex()
.gap_2() .gap_2()
.child( .map(|path_header| {
Label::new( let filename = filename
filename
.map(SharedString::from) .map(SharedString::from)
.unwrap_or_else(|| "untitled".into()), .unwrap_or_else(|| "untitled".into());
)
.single_line() path_header
.when_some(file_status, |el, status| { .when(ItemSettings::get_global(cx).file_icons, |el| {
let path = path::Path::new(filename.as_str());
let icon = FileIcons::get_icon(path, cx)
.unwrap_or_default();
let icon =
Icon::from_path(icon).color(Color::Muted);
el.child(icon)
})
.child(Label::new(filename).single_line().when_some(
file_status,
|el, status| {
el.color(if status.is_conflicted() { el.color(if status.is_conflicted() {
Color::Conflict Color::Conflict
} else if status.is_modified() { } else if status.is_modified() {
@ -3727,9 +3735,12 @@ impl EditorElement {
} else { } else {
Color::Created Color::Created
}) })
.when(status.is_deleted(), |el| el.strikethrough()) .when(status.is_deleted(), |el| {
}), el.strikethrough()
) })
},
))
})
.when_some(parent_path, |then, path| { .when_some(parent_path, |then, path| {
then.child(div().child(path).text_color( then.child(div().child(path).text_color(
if file_status.is_some_and(FileStatus::is_deleted) { if file_status.is_some_and(FileStatus::is_deleted) {

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"

View file

@ -510,20 +510,6 @@ impl LspAdapter for RustLspAdapter {
} }
} }
let cargo_diagnostics_fetched_separately = ProjectSettings::get_global(cx)
.diagnostics
.fetch_cargo_diagnostics();
if cargo_diagnostics_fetched_separately {
let disable_check_on_save = json!({
"checkOnSave": false,
});
if let Some(initialization_options) = &mut original.initialization_options {
merge_json_value_into(disable_check_on_save, initialization_options);
} else {
original.initialization_options = Some(disable_check_on_save);
}
}
Ok(original) Ok(original)
} }
} }

View file

@ -237,6 +237,7 @@
"implements" "implements"
"interface" "interface"
"keyof" "keyof"
"module"
"namespace" "namespace"
"private" "private"
"protected" "protected"

View file

@ -248,6 +248,7 @@
"is" "is"
"keyof" "keyof"
"let" "let"
"module"
"namespace" "namespace"
"new" "new"
"of" "of"

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

@ -181,17 +181,6 @@ pub struct DiagnosticsSettings {
/// Settings for showing inline diagnostics. /// Settings for showing inline diagnostics.
pub inline: InlineDiagnosticsSettings, pub inline: InlineDiagnosticsSettings,
/// Configuration, related to Rust language diagnostics.
pub cargo: Option<CargoDiagnosticsSettings>,
}
impl DiagnosticsSettings {
pub fn fetch_cargo_diagnostics(&self) -> bool {
self.cargo
.as_ref()
.is_some_and(|cargo_diagnostics| cargo_diagnostics.fetch_cargo_diagnostics)
}
} }
#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema)] #[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema)]
@ -258,7 +247,6 @@ impl Default for DiagnosticsSettings {
include_warnings: true, include_warnings: true,
lsp_pull_diagnostics: LspPullDiagnosticsSettings::default(), lsp_pull_diagnostics: LspPullDiagnosticsSettings::default(),
inline: InlineDiagnosticsSettings::default(), inline: InlineDiagnosticsSettings::default(),
cargo: None,
} }
} }
} }
@ -292,16 +280,6 @@ impl Default for GlobalLspSettings {
} }
} }
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
pub struct CargoDiagnosticsSettings {
/// When enabled, Zed disables rust-analyzer's check on save and starts to query
/// Cargo diagnostics separately.
///
/// Default: false
#[serde(default)]
pub fetch_cargo_diagnostics: bool,
}
#[derive( #[derive(
Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize, JsonSchema, Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize, JsonSchema,
)] )]

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",
} }
``` ```

View file

@ -136,22 +136,7 @@ This is enabled by default and can be configured as
## Manual Cargo Diagnostics fetch ## Manual Cargo Diagnostics fetch
By default, rust-analyzer has `checkOnSave: true` enabled, which causes every buffer save to trigger a `cargo check --workspace --all-targets` command. By default, rust-analyzer has `checkOnSave: true` enabled, which causes every buffer save to trigger a `cargo check --workspace --all-targets` command.
For lager projects this might introduce excessive wait times, so a more fine-grained triggering could be enabled by altering the If disabled with `checkOnSave: false` (see the example of the server configuration json above), it's still possible to fetch the diagnostics manually, with the `editor: run/clear/cancel flycheck` commands in Rust files to refresh cargo diagnostics; the project diagnostics editor will also refresh cargo diagnostics with `editor: run flycheck` command when the setting is enabled.
```json
"diagnostics": {
"cargo": {
// When enabled, Zed disables rust-analyzer's check on save and starts to query
// Cargo diagnostics separately.
"fetch_cargo_diagnostics": false
}
}
```
default settings.
This will stop rust-analyzer from running `cargo check ...` on save, yet still allow to run
`editor: run/clear/cancel flycheck` commands in Rust files to refresh cargo diagnostics; the project diagnostics editor will also refresh cargo diagnostics with `editor: run flycheck` command when the setting is enabled.
## More server configuration ## More server configuration