Merge remote-tracking branch 'origin/main' into thread-view-ui

This commit is contained in:
Danilo Leal 2025-08-24 11:17:42 -03:00
commit 1e07aeb8a9
27 changed files with 508 additions and 215 deletions

2
Cargo.lock generated
View file

@ -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",

View file

@ -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(),

View file

@ -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()
}

View file

@ -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 {

View file

@ -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,

View file

@ -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,

View file

@ -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 {

View file

@ -0,0 +1,59 @@
use crate::{AgentServerCommand, AgentServerSettings};
use acp_thread::AgentConnection;
use anyhow::Result;
use gpui::{App, Entity, SharedString, Task};
use project::Project;
use std::{path::Path, rc::Rc};
use ui::IconName;
/// A generic agent server implementation for custom user-defined agents
pub struct CustomAgentServer {
name: SharedString,
command: AgentServerCommand,
}
impl CustomAgentServer {
pub fn new(name: SharedString, settings: &AgentServerSettings) -> Self {
Self {
name,
command: settings.command.clone(),
}
}
}
impl crate::AgentServer for CustomAgentServer {
fn name(&self) -> SharedString {
self.name.clone()
}
fn logo(&self) -> IconName {
IconName::Terminal
}
fn empty_state_headline(&self) -> SharedString {
"No conversations yet".into()
}
fn empty_state_message(&self) -> SharedString {
format!("Start a conversation with {}", self.name).into()
}
fn connect(
&self,
root_dir: &Path,
_project: &Entity<Project>,
cx: &mut App,
) -> Task<Result<Rc<dyn AgentConnection>>> {
let server_name = self.name();
let command = self.command.clone();
let root_dir = root_dir.to_path_buf();
cx.spawn(async move |mut cx| {
crate::acp::connect(server_name, command, &root_dir, &mut cx).await
})
}
fn into_any(self: Rc<Self>) -> Rc<dyn std::any::Any> {
self
}
}

View file

@ -1,17 +1,15 @@
use crate::AgentServer;
use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus};
use agent_client_protocol as acp;
use futures::{FutureExt, StreamExt, channel::mpsc, select};
use gpui::{AppContext, Entity, TestAppContext};
use indoc::indoc;
use project::{FakeFs, Project};
use std::{
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,
);

View file

@ -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 {

View file

@ -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)

View file

@ -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

View file

@ -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 {

View file

@ -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);
}
@ -823,6 +836,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 +894,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 +923,7 @@ impl AcpThreadView {
action_log.buffer_read(buffer, cx)
}
});
drop(guard);
thread.send(contents, cx)
})?;
send.await
@ -950,19 +978,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);
}
@ -1346,25 +1379,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 {
@ -1377,7 +1419,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()
})
)
@ -1694,7 +1736,9 @@ impl AcpThreadView {
let is_edit =
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() && !needs_confirmation;
let is_open = needs_confirmation || self.expanded_tool_calls.contains(&tool_call.id);
let gradient_overlay = |color: Hsla| {
@ -2165,6 +2209,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-{}",
@ -2298,21 +2344,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()
@ -3546,7 +3598,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))
@ -3915,13 +3974,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 => {
@ -4630,6 +4689,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 {
@ -5140,16 +5211,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(

View file

@ -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};
@ -128,7 +129,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)
});
}
})
@ -239,7 +240,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,
@ -247,23 +248,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),
}
}
}
@ -517,7 +524,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(
@ -607,7 +614,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();
@ -1077,14 +1084,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();
@ -1110,7 +1120,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;
}
@ -1839,14 +1851,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(
@ -1885,6 +1897,13 @@ impl AgentPanel {
window,
cx,
),
AgentType::Custom { name, settings } => self.external_thread(
Some(crate::ExternalAgent::Custom { name, settings }),
None,
None,
window,
cx,
),
}
}
@ -2610,13 +2629,55 @@ impl AgentPanel {
}
}),
)
})
.when(cx.has_flag::<GeminiAndNativeFeatureFlag>(), |mut menu| {
// Add custom agents from settings
let settings =
agent_servers::AllAgentServersSettings::get_global(cx);
for (agent_name, agent_settings) in &settings.custom {
menu = menu.item(
ContextMenuEntry::new(format!("New {} Thread", agent_name))
.icon(IconName::Terminal)
.icon_color(Color::Muted)
.handler({
let workspace = workspace.clone();
let agent_name = agent_name.clone();
let agent_settings = agent_settings.clone();
move |window, cx| {
if let Some(workspace) = workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
if let Some(panel) =
workspace.panel::<AgentPanel>(cx)
{
panel.update(cx, |panel, cx| {
panel.set_selected_agent(
AgentType::Custom {
name: agent_name
.clone(),
settings:
agent_settings
.clone(),
},
window,
cx,
);
});
}
});
}
}
}),
);
}
menu
});
menu
}))
}
});
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| {

View file

@ -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,
)),
}
}
}

View file

@ -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

View file

@ -25,7 +25,7 @@ use util::split_str_with_ranges;
/// Path used for unsaved buffer that contains style json. To support the json language server, this
/// 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,

View file

@ -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)]

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

@ -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

View file

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

View file

@ -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);

View file

@ -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;
});

View file

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