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