Detailed changes
@@ -369,7 +369,6 @@ dependencies = [
"fs",
"futures 0.3.31",
"fuzzy",
- "git_ui",
"gpui",
"gpui_tokio",
"html_to_markdown",
@@ -400,6 +399,7 @@ dependencies = [
"rand 0.9.2",
"recent_projects",
"release_channel",
+ "remote_connection",
"reqwest_client",
"rope",
"rules_library",
@@ -18,7 +18,6 @@ test-support = [
"acp_thread/test-support",
"eval_utils",
"gpui/test-support",
- "git_ui/test-support",
"language/test-support",
"reqwest_client",
"workspace/test-support",
@@ -59,7 +58,6 @@ feature_flags.workspace = true
file_icons.workspace = true
fs.workspace = true
futures.workspace = true
-git_ui.workspace = true
fuzzy.workspace = true
gpui.workspace = true
gpui_tokio.workspace = true
@@ -127,7 +125,6 @@ db = { workspace = true, features = ["test-support"] }
editor = { workspace = true, features = ["test-support"] }
eval_utils.workspace = true
gpui = { workspace = true, "features" = ["test-support"] }
-git_ui = { workspace = true, features = ["test-support"] }
indoc.workspace = true
language = { workspace = true, "features" = ["test-support"] }
languages = { workspace = true, features = ["test-support"] }
@@ -135,6 +132,7 @@ language_model = { workspace = true, "features" = ["test-support"] }
pretty_assertions.workspace = true
project = { workspace = true, features = ["test-support"] }
recent_projects = { workspace = true, features = ["test-support"] }
+remote_connection = { workspace = true, features = ["test-support"] }
title_bar = { workspace = true, features = ["test-support"] }
semver.workspace = true
reqwest_client.workspace = true
@@ -1,7 +1,6 @@
use std::{cell::RefCell, ops::Range, rc::Rc};
use super::thread_history::AcpThreadHistory;
-use crate::user_slash_command::{CommandLoadError, UserSlashCommand};
use acp_thread::{AcpThread, AgentThreadEntry};
use agent::ThreadStore;
use agent_client_protocol::{self as acp, ToolCallId};
@@ -31,8 +30,6 @@ pub struct EntryViewState {
entries: Vec<Entry>,
prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
- cached_user_commands: Rc<RefCell<HashMap<String, UserSlashCommand>>>,
- cached_user_command_errors: Rc<RefCell<Vec<CommandLoadError>>>,
agent_name: SharedString,
}
@@ -45,8 +42,6 @@ impl EntryViewState {
prompt_store: Option<Entity<PromptStore>>,
prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
- cached_user_commands: Rc<RefCell<HashMap<String, UserSlashCommand>>>,
- cached_user_command_errors: Rc<RefCell<Vec<CommandLoadError>>>,
agent_name: SharedString,
) -> Self {
Self {
@@ -58,8 +53,6 @@ impl EntryViewState {
entries: Vec::new(),
prompt_capabilities,
available_commands,
- cached_user_commands,
- cached_user_command_errors,
agent_name,
}
}
@@ -93,7 +86,7 @@ impl EntryViewState {
}
} else {
let message_editor = cx.new(|cx| {
- let mut editor = MessageEditor::new_with_cache(
+ let mut editor = MessageEditor::new(
self.workspace.clone(),
self.project.clone(),
self.thread_store.clone(),
@@ -101,8 +94,6 @@ impl EntryViewState {
self.prompt_store.clone(),
self.prompt_capabilities.clone(),
self.available_commands.clone(),
- self.cached_user_commands.clone(),
- self.cached_user_command_errors.clone(),
self.agent_name.clone(),
"Edit message οΌ @ to include context",
editor::EditorMode::AutoHeight {
@@ -478,8 +469,6 @@ mod tests {
None,
Default::default(),
Default::default(),
- Default::default(),
- Default::default(),
"Test Agent".into(),
)
});
@@ -9,7 +9,6 @@ use crate::{
mention_set::{
Mention, MentionImage, MentionSet, insert_crease_for_mention, paste_images_as_context,
},
- user_slash_command::{self, CommandLoadError, UserSlashCommand},
};
use acp_thread::{AgentSessionInfo, MentionUri};
use agent::ThreadStore;
@@ -22,7 +21,6 @@ use editor::{
MultiBufferSnapshot, ToOffset, actions::Paste, code_context_menus::CodeContextMenu,
scroll::Autoscroll,
};
-use feature_flags::{FeatureFlagAppExt as _, UserSlashCommandsFeatureFlag};
use futures::{FutureExt as _, future::join_all};
use gpui::{
AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageFormat,
@@ -40,25 +38,12 @@ use util::{ResultExt, debug_panic};
use workspace::{CollaboratorId, Workspace};
use zed_actions::agent::{Chat, PasteRaw};
-enum UserSlashCommands {
- Cached {
- commands: collections::HashMap<String, user_slash_command::UserSlashCommand>,
- errors: Vec<user_slash_command::CommandLoadError>,
- },
- FromFs {
- fs: Arc<dyn fs::Fs>,
- worktree_roots: Vec<std::path::PathBuf>,
- },
-}
-
pub struct MessageEditor {
mention_set: Entity<MentionSet>,
editor: Entity<Editor>,
workspace: WeakEntity<Workspace>,
prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
- cached_user_commands: Rc<RefCell<collections::HashMap<String, UserSlashCommand>>>,
- cached_user_command_errors: Rc<RefCell<Vec<CommandLoadError>>>,
agent_name: SharedString,
thread_store: Option<Entity<ThreadStore>>,
_subscriptions: Vec<Subscription>,
@@ -107,7 +92,6 @@ impl PromptCompletionProviderDelegate for Entity<MessageEditor> {
name: cmd.name.clone().into(),
description: cmd.description.clone().into(),
requires_argument: cmd.input.is_some(),
- source: crate::completion_provider::CommandSource::Server,
})
.collect()
}
@@ -115,27 +99,6 @@ impl PromptCompletionProviderDelegate for Entity<MessageEditor> {
fn confirm_command(&self, cx: &mut App) {
self.update(cx, |this, cx| this.send(cx));
}
-
- fn cached_user_commands(
- &self,
- cx: &App,
- ) -> Option<collections::HashMap<String, UserSlashCommand>> {
- let commands = self.read(cx).cached_user_commands.borrow();
- if commands.is_empty() {
- None
- } else {
- Some(commands.clone())
- }
- }
-
- fn cached_user_command_errors(&self, cx: &App) -> Option<Vec<CommandLoadError>> {
- let errors = self.read(cx).cached_user_command_errors.borrow();
- if errors.is_empty() {
- None
- } else {
- Some(errors.clone())
- }
- }
}
impl MessageEditor {
@@ -152,42 +115,6 @@ impl MessageEditor {
mode: EditorMode,
window: &mut Window,
cx: &mut Context<Self>,
- ) -> Self {
- let cached_user_commands = Rc::new(RefCell::new(collections::HashMap::default()));
- let cached_user_command_errors = Rc::new(RefCell::new(Vec::new()));
- Self::new_with_cache(
- workspace,
- project,
- thread_store,
- history,
- prompt_store,
- prompt_capabilities,
- available_commands,
- cached_user_commands,
- cached_user_command_errors,
- agent_name,
- placeholder,
- mode,
- window,
- cx,
- )
- }
-
- pub fn new_with_cache(
- workspace: WeakEntity<Workspace>,
- project: WeakEntity<Project>,
- thread_store: Option<Entity<ThreadStore>>,
- history: WeakEntity<AcpThreadHistory>,
- prompt_store: Option<Entity<PromptStore>>,
- prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
- available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
- cached_user_commands: Rc<RefCell<collections::HashMap<String, UserSlashCommand>>>,
- cached_user_command_errors: Rc<RefCell<Vec<CommandLoadError>>>,
- agent_name: SharedString,
- placeholder: &str,
- mode: EditorMode,
- window: &mut Window,
- cx: &mut Context<Self>,
) -> Self {
let language = Language::new(
language::LanguageConfig {
@@ -293,8 +220,6 @@ impl MessageEditor {
workspace,
prompt_capabilities,
available_commands,
- cached_user_commands,
- cached_user_command_errors,
agent_name,
thread_store,
_subscriptions: subscriptions,
@@ -306,14 +231,10 @@ impl MessageEditor {
&mut self,
prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
- cached_user_commands: Rc<RefCell<collections::HashMap<String, UserSlashCommand>>>,
- cached_user_command_errors: Rc<RefCell<Vec<CommandLoadError>>>,
_cx: &mut Context<Self>,
) {
self.prompt_capabilities = prompt_capabilities;
self.available_commands = available_commands;
- self.cached_user_commands = cached_user_commands;
- self.cached_user_command_errors = cached_user_command_errors;
}
fn command_hint(&self, snapshot: &MultiBufferSnapshot) -> Option<Inlay> {
@@ -477,48 +398,11 @@ impl MessageEditor {
&self,
full_mention_content: bool,
cx: &mut Context<Self>,
- ) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
- self.contents_with_cache(full_mention_content, None, None, cx)
- }
-
- pub fn contents_with_cache(
- &self,
- full_mention_content: bool,
- cached_user_commands: Option<
- collections::HashMap<String, user_slash_command::UserSlashCommand>,
- >,
- cached_user_command_errors: Option<Vec<user_slash_command::CommandLoadError>>,
- cx: &mut Context<Self>,
) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
let text = self.editor.read(cx).text(cx);
let available_commands = self.available_commands.borrow().clone();
let agent_name = self.agent_name.clone();
- let user_slash_commands = if !cx.has_flag::<UserSlashCommandsFeatureFlag>() {
- UserSlashCommands::Cached {
- commands: collections::HashMap::default(),
- errors: Vec::new(),
- }
- } else if let Some(cached) = cached_user_commands {
- UserSlashCommands::Cached {
- commands: cached,
- errors: cached_user_command_errors.unwrap_or_default(),
- }
- } else if let Some(workspace) = self.workspace.upgrade() {
- let fs = workspace.read(cx).project().read(cx).fs().clone();
- let worktree_roots: Vec<std::path::PathBuf> = workspace
- .read(cx)
- .visible_worktrees(cx)
- .map(|worktree| worktree.read(cx).abs_path().to_path_buf())
- .collect();
- UserSlashCommands::FromFs { fs, worktree_roots }
- } else {
- UserSlashCommands::Cached {
- commands: collections::HashMap::default(),
- errors: Vec::new(),
- }
- };
-
let contents = self
.mention_set
.update(cx, |store, cx| store.contents(full_mention_content, cx));
@@ -526,58 +410,7 @@ impl MessageEditor {
let supports_embedded_context = self.prompt_capabilities.borrow().embedded_context;
cx.spawn(async move |_, cx| {
- let (mut user_commands, mut user_command_errors) = match user_slash_commands {
- UserSlashCommands::Cached { commands, errors } => (commands, errors),
- UserSlashCommands::FromFs { fs, worktree_roots } => {
- let load_result =
- user_slash_command::load_all_commands_async(&fs, &worktree_roots).await;
-
- (
- user_slash_command::commands_to_map(&load_result.commands),
- load_result.errors,
- )
- }
- };
-
- let server_command_names = available_commands
- .iter()
- .map(|command| command.name.clone())
- .collect::<HashSet<_>>();
- user_slash_command::apply_server_command_conflicts_to_map(
- &mut user_commands,
- &mut user_command_errors,
- &server_command_names,
- );
-
- // Check if the user is trying to use an errored slash command.
- // If so, report the error to the user.
- if let Some(parsed) = user_slash_command::try_parse_user_command(&text) {
- for error in &user_command_errors {
- if let Some(error_cmd_name) = error.command_name() {
- if error_cmd_name == parsed.name {
- return Err(anyhow::anyhow!(
- "Failed to load /{}: {}",
- parsed.name,
- error.message
- ));
- }
- }
- }
- }
- // Errors for commands that don't match the user's input are silently ignored here,
- // since the user will see them via the error callout in the thread view.
-
- // Check if this is a user-defined slash command and expand it
- match user_slash_command::try_expand_from_commands(&text, &user_commands) {
- Ok(Some(expanded)) => return Ok((vec![expanded.into()], Vec::new())),
- Err(err) => return Err(err),
- Ok(None) => {} // Not a user command, continue with normal processing
- }
-
- if let Err(err) = Self::validate_slash_commands(&text, &available_commands, &agent_name)
- {
- return Err(err);
- }
+ Self::validate_slash_commands(&text, &available_commands, &agent_name)?;
let contents = contents.await?;
let mut all_tracked_buffers = Vec::new();
@@ -1497,7 +1330,7 @@ mod tests {
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
- MessageEditor::new_with_cache(
+ MessageEditor::new(
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
@@ -1505,8 +1338,6 @@ mod tests {
None,
Default::default(),
Default::default(),
- Default::default(),
- Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
@@ -1574,9 +1405,7 @@ mod tests {
});
let (content, _) = message_editor
- .update(cx, |message_editor, cx| {
- message_editor.contents_with_cache(false, None, None, cx)
- })
+ .update(cx, |message_editor, cx| message_editor.contents(false, cx))
.await
.unwrap();
@@ -1614,7 +1443,7 @@ mod tests {
let workspace_handle = workspace.downgrade();
let message_editor = workspace.update_in(cx, |_, window, cx| {
cx.new(|cx| {
- MessageEditor::new_with_cache(
+ MessageEditor::new(
workspace_handle.clone(),
project.downgrade(),
thread_store.clone(),
@@ -1622,8 +1451,6 @@ mod tests {
None,
prompt_capabilities.clone(),
available_commands.clone(),
- Default::default(),
- Default::default(),
"Claude Code".into(),
"Test",
EditorMode::AutoHeight {
@@ -1643,9 +1470,7 @@ mod tests {
});
let contents_result = message_editor
- .update(cx, |message_editor, cx| {
- message_editor.contents_with_cache(false, None, None, cx)
- })
+ .update(cx, |message_editor, cx| message_editor.contents(false, cx))
.await;
// Should fail because available_commands is empty (no commands supported)
@@ -1663,9 +1488,7 @@ mod tests {
});
let contents_result = message_editor
- .update(cx, |message_editor, cx| {
- message_editor.contents_with_cache(false, None, None, cx)
- })
+ .update(cx, |message_editor, cx| message_editor.contents(false, cx))
.await;
assert!(contents_result.is_err());
@@ -1680,9 +1503,7 @@ mod tests {
});
let contents_result = message_editor
- .update(cx, |message_editor, cx| {
- message_editor.contents_with_cache(false, None, None, cx)
- })
+ .update(cx, |message_editor, cx| message_editor.contents(false, cx))
.await;
// Should succeed because /help is in available_commands
@@ -1694,9 +1515,7 @@ mod tests {
});
let (content, _) = message_editor
- .update(cx, |message_editor, cx| {
- message_editor.contents_with_cache(false, None, None, cx)
- })
+ .update(cx, |message_editor, cx| message_editor.contents(false, cx))
.await
.unwrap();
@@ -1714,9 +1533,7 @@ mod tests {
// The @ mention functionality should not be affected
let (content, _) = message_editor
- .update(cx, |message_editor, cx| {
- message_editor.contents_with_cache(false, None, None, cx)
- })
+ .update(cx, |message_editor, cx| message_editor.contents(false, cx))
.await
.unwrap();
@@ -1789,7 +1606,7 @@ mod tests {
let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
let workspace_handle = cx.weak_entity();
let message_editor = cx.new(|cx| {
- MessageEditor::new_with_cache(
+ MessageEditor::new(
workspace_handle,
project.downgrade(),
thread_store.clone(),
@@ -1797,8 +1614,6 @@ mod tests {
None,
prompt_capabilities.clone(),
available_commands.clone(),
- Default::default(),
- Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
@@ -2015,7 +1830,7 @@ mod tests {
let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
let workspace_handle = cx.weak_entity();
let message_editor = cx.new(|cx| {
- MessageEditor::new_with_cache(
+ MessageEditor::new(
workspace_handle,
project.downgrade(),
Some(thread_store),
@@ -2023,8 +1838,6 @@ mod tests {
None,
prompt_capabilities.clone(),
Default::default(),
- Default::default(),
- Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
@@ -2510,7 +2323,7 @@ mod tests {
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
- let editor = MessageEditor::new_with_cache(
+ let editor = MessageEditor::new(
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
@@ -2518,8 +2331,6 @@ mod tests {
None,
Default::default(),
Default::default(),
- Default::default(),
- Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
@@ -2621,7 +2432,7 @@ mod tests {
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
- let mut editor = MessageEditor::new_with_cache(
+ let mut editor = MessageEditor::new(
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
@@ -2629,8 +2440,6 @@ mod tests {
None,
Default::default(),
Default::default(),
- Default::default(),
- Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
@@ -2703,7 +2512,7 @@ mod tests {
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
- let mut editor = MessageEditor::new_with_cache(
+ let mut editor = MessageEditor::new(
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
@@ -2711,8 +2520,6 @@ mod tests {
None,
Default::default(),
Default::default(),
- Default::default(),
- Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
@@ -2756,7 +2563,7 @@ mod tests {
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
- MessageEditor::new_with_cache(
+ MessageEditor::new(
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
@@ -2764,8 +2571,6 @@ mod tests {
None,
Default::default(),
Default::default(),
- Default::default(),
- Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
@@ -2812,7 +2617,7 @@ mod tests {
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
- MessageEditor::new_with_cache(
+ MessageEditor::new(
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
@@ -2820,8 +2625,6 @@ mod tests {
None,
Default::default(),
Default::default(),
- Default::default(),
- Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
@@ -2869,7 +2672,7 @@ mod tests {
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
- MessageEditor::new_with_cache(
+ MessageEditor::new(
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
@@ -2877,8 +2680,6 @@ mod tests {
None,
Default::default(),
Default::default(),
- Default::default(),
- Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
@@ -2899,9 +2700,7 @@ mod tests {
});
let (content, _) = message_editor
- .update(cx, |message_editor, cx| {
- message_editor.contents_with_cache(false, None, None, cx)
- })
+ .update(cx, |message_editor, cx| message_editor.contents(false, cx))
.await
.unwrap();
@@ -2938,7 +2737,7 @@ mod tests {
let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| {
let workspace_handle = cx.weak_entity();
let message_editor = cx.new(|cx| {
- MessageEditor::new_with_cache(
+ MessageEditor::new(
workspace_handle,
project.downgrade(),
thread_store.clone(),
@@ -2946,8 +2745,6 @@ mod tests {
None,
Default::default(),
Default::default(),
- Default::default(),
- Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
@@ -2982,9 +2779,7 @@ mod tests {
});
let content = message_editor
- .update(cx, |editor, cx| {
- editor.contents_with_cache(false, None, None, cx)
- })
+ .update(cx, |editor, cx| editor.contents(false, cx))
.await
.unwrap()
.0;
@@ -3011,9 +2806,7 @@ mod tests {
});
let content = message_editor
- .update(cx, |editor, cx| {
- editor.contents_with_cache(false, None, None, cx)
- })
+ .update(cx, |editor, cx| editor.contents(false, cx))
.await
.unwrap()
.0;
@@ -3104,7 +2897,7 @@ mod tests {
let message_editor = workspace.update_in(&mut cx, |workspace, window, cx| {
let workspace_handle = cx.weak_entity();
let message_editor = cx.new(|cx| {
- MessageEditor::new_with_cache(
+ MessageEditor::new(
workspace_handle,
project.downgrade(),
thread_store.clone(),
@@ -3112,8 +2905,6 @@ mod tests {
None,
Default::default(),
Default::default(),
- Default::default(),
- Default::default(),
"Test Agent".into(),
"Test",
EditorMode::full(),
@@ -3221,7 +3012,7 @@ mod tests {
let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
let workspace_handle = cx.weak_entity();
let message_editor = cx.new(|cx| {
- MessageEditor::new_with_cache(
+ MessageEditor::new(
workspace_handle,
project.downgrade(),
Some(thread_store),
@@ -3229,8 +3020,6 @@ mod tests {
None,
Default::default(),
Default::default(),
- Default::default(),
- Default::default(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
@@ -22,7 +22,7 @@ use editor::{
};
use feature_flags::{
AgentSharingFeatureFlag, AgentV2FeatureFlag, CloudThinkingToggleFeatureFlag,
- FeatureFlagAppExt as _, UserSlashCommandsFeatureFlag,
+ FeatureFlagAppExt as _,
};
use file_icons::FileIcons;
use fs::Fs;
@@ -57,9 +57,7 @@ use ui::{
};
use util::defer;
use util::{ResultExt, size::format_file_size, time::duration_alt_display};
-use workspace::{
- CollaboratorId, NewTerminal, OpenOptions, Toast, Workspace, notifications::NotificationId,
-};
+use workspace::{CollaboratorId, NewTerminal, Toast, Workspace, notifications::NotificationId};
use zed_actions::agent::{Chat, ToggleModelSelector};
use zed_actions::assistant::OpenRulesLibrary;
@@ -73,9 +71,6 @@ use crate::acp::message_editor::{MessageEditor, MessageEditorEvent};
use crate::agent_diff::AgentDiff;
use crate::profile_selector::{ProfileProvider, ProfileSelector};
use crate::ui::{AgentNotification, AgentNotificationEvent};
-use crate::user_slash_command::{
- self, CommandLoadError, SlashCommandRegistry, SlashCommandRegistryEvent, UserSlashCommand,
-};
use crate::{
AgentDiffPane, AgentPanel, AllowAlways, AllowOnce, AuthorizeToolCall, ClearMessageQueue,
CycleFavoriteModels, CycleModeSelector, EditFirstQueuedMessage, ExpandMessageEditor,
@@ -102,7 +97,7 @@ enum ThreadFeedback {
}
#[derive(Debug)]
-enum ThreadError {
+pub(crate) enum ThreadError {
PaymentRequired,
Refusal,
AuthenticationRequired(SharedString),
@@ -113,7 +108,7 @@ enum ThreadError {
}
impl ThreadError {
- fn from_err(error: anyhow::Error, agent: &Rc<dyn AgentServer>) -> Self {
+ fn from_err(error: anyhow::Error, agent_name: &str) -> Self {
if error.is::<language_model::PaymentRequiredError>() {
Self::PaymentRequired
} else if let Some(acp_error) = error.downcast_ref::<acp::Error>()
@@ -129,7 +124,7 @@ impl ThreadError {
.map(|acp_error| SharedString::from(acp_error.code.to_string()));
// TODO: we should have Gemini return better errors here.
- if agent.clone().downcast::<agent_servers::Gemini>().is_some()
+ if agent_name == "Gemini CLI"
&& message.contains("Could not load the default credentials")
|| message.contains("API key not valid")
|| message.contains("Request had invalid authentication credentials")
@@ -164,166 +159,6 @@ impl ProfileProvider for Entity<agent::Thread> {
}
}
-#[derive(Default)]
-struct ThreadFeedbackState {
- feedback: Option<ThreadFeedback>,
- comments_editor: Option<Entity<Editor>>,
-}
-
-impl ThreadFeedbackState {
- pub fn submit(
- &mut self,
- thread: Entity<AcpThread>,
- feedback: ThreadFeedback,
- window: &mut Window,
- cx: &mut App,
- ) {
- let Some(telemetry) = thread.read(cx).connection().telemetry() else {
- return;
- };
-
- if self.feedback == Some(feedback) {
- return;
- }
-
- self.feedback = Some(feedback);
- match feedback {
- ThreadFeedback::Positive => {
- self.comments_editor = None;
- }
- ThreadFeedback::Negative => {
- self.comments_editor = Some(Self::build_feedback_comments_editor(window, cx));
- }
- }
- let session_id = thread.read(cx).session_id().clone();
- let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
- let task = telemetry.thread_data(&session_id, cx);
- let rating = match feedback {
- ThreadFeedback::Positive => "positive",
- ThreadFeedback::Negative => "negative",
- };
- cx.background_spawn(async move {
- let thread = task.await?;
- telemetry::event!(
- "Agent Thread Rated",
- agent = agent_telemetry_id,
- session_id = session_id,
- rating = rating,
- thread = thread
- );
- anyhow::Ok(())
- })
- .detach_and_log_err(cx);
- }
-
- pub fn submit_comments(&mut self, thread: Entity<AcpThread>, cx: &mut App) {
- let Some(telemetry) = thread.read(cx).connection().telemetry() else {
- return;
- };
-
- let Some(comments) = self
- .comments_editor
- .as_ref()
- .map(|editor| editor.read(cx).text(cx))
- .filter(|text| !text.trim().is_empty())
- else {
- return;
- };
-
- self.comments_editor.take();
-
- let session_id = thread.read(cx).session_id().clone();
- let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
- let task = telemetry.thread_data(&session_id, cx);
- cx.background_spawn(async move {
- let thread = task.await?;
- telemetry::event!(
- "Agent Thread Feedback Comments",
- agent = agent_telemetry_id,
- session_id = session_id,
- comments = comments,
- thread = thread
- );
- anyhow::Ok(())
- })
- .detach_and_log_err(cx);
- }
-
- pub fn clear(&mut self) {
- *self = Self::default()
- }
-
- pub fn dismiss_comments(&mut self) {
- self.comments_editor.take();
- }
-
- fn build_feedback_comments_editor(window: &mut Window, cx: &mut App) -> Entity<Editor> {
- let buffer = cx.new(|cx| {
- let empty_string = String::new();
- MultiBuffer::singleton(cx.new(|cx| Buffer::local(empty_string, cx)), cx)
- });
-
- let editor = cx.new(|cx| {
- let mut editor = Editor::new(
- editor::EditorMode::AutoHeight {
- min_lines: 1,
- max_lines: Some(4),
- },
- buffer,
- None,
- window,
- cx,
- );
- editor.set_placeholder_text(
- "What went wrong? Share your feedback so we can improve.",
- window,
- cx,
- );
- editor
- });
-
- editor.read(cx).focus_handle(cx).focus(window, cx);
- editor
- }
-}
-
-#[derive(Default, Clone, Copy)]
-struct DiffStats {
- lines_added: u32,
- lines_removed: u32,
-}
-
-impl DiffStats {
- fn single_file(buffer: &Buffer, diff: &BufferDiff, cx: &App) -> Self {
- let mut stats = DiffStats::default();
- let diff_snapshot = diff.snapshot(cx);
- let buffer_snapshot = buffer.snapshot();
- let base_text = diff_snapshot.base_text();
-
- for hunk in diff_snapshot.hunks(&buffer_snapshot) {
- let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row);
- stats.lines_added += added_rows;
-
- let base_start = hunk.diff_base_byte_range.start.to_point(base_text).row;
- let base_end = hunk.diff_base_byte_range.end.to_point(base_text).row;
- let removed_rows = base_end.saturating_sub(base_start);
- stats.lines_removed += removed_rows;
- }
-
- stats
- }
-
- fn all_files(changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>, cx: &App) -> Self {
- let mut total = DiffStats::default();
- for (buffer, diff) in changed_buffers {
- let stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx);
- total.lines_added += stats.lines_added;
- total.lines_removed += stats.lines_removed;
- }
- total
- }
-}
-
pub struct AcpServerView {
agent: Rc<dyn AgentServer>,
agent_server_store: Entity<AgentServerStore>,
@@ -333,33 +168,18 @@ pub struct AcpServerView {
prompt_store: Option<Entity<PromptStore>>,
server_state: ServerState,
login: Option<task::SpawnInTerminal>, // is some <=> Active | Unauthenticated
- recent_history_entries: Vec<AgentSessionInfo>,
history: Entity<AcpThreadHistory>,
- _history_subscription: Subscription,
- hovered_recent_history_item: Option<usize>,
- message_editor: Entity<MessageEditor>,
focus_handle: FocusHandle,
notifications: Vec<WindowHandle<AgentNotification>>,
notification_subscriptions: HashMap<WindowHandle<AgentNotification>, Vec<Subscription>>,
- slash_command_registry: Option<Entity<SlashCommandRegistry>>,
auth_task: Option<Task<()>>,
_subscriptions: Vec<Subscription>,
- show_codex_windows_warning: bool,
- in_flight_prompt: Option<Vec<acp::ContentBlock>>,
- add_context_menu_handle: PopoverMenuHandle<ContextMenu>,
}
impl AcpServerView {
- pub fn as_active_thread(&self) -> Option<&AcpThreadView> {
+ pub fn as_active_thread(&self) -> Option<Entity<AcpThreadView>> {
match &self.server_state {
- ServerState::Connected(connected) => Some(&connected.current),
- _ => None,
- }
- }
-
- pub fn as_active_thread_mut(&mut self) -> Option<&mut AcpThreadView> {
- match &mut self.server_state {
- ServerState::Connected(connected) => Some(&mut connected.current),
+ ServerState::Connected(connected) => Some(connected.current.clone()),
_ => None,
}
}
@@ -389,7 +209,7 @@ enum ServerState {
// hashmap of threads, current becomes session_id
pub struct ConnectedServerState {
auth_state: AuthState,
- current: AcpThreadView,
+ current: Entity<AcpThreadView>,
connection: Rc<dyn AgentConnection>,
}
@@ -416,8 +236,8 @@ struct LoadingView {
}
impl ConnectedServerState {
- pub fn has_thread_error(&self) -> bool {
- self.current.thread_error.is_some()
+ pub fn has_thread_error(&self, cx: &App) -> bool {
+ self.current.read(cx).thread_error.is_some()
}
}
@@ -436,58 +256,11 @@ impl AcpServerView {
) -> Self {
let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
let available_commands = Rc::new(RefCell::new(vec![]));
- let cached_user_commands = Rc::new(RefCell::new(collections::HashMap::default()));
- let cached_user_command_errors = Rc::new(RefCell::new(Vec::new()));
let agent_server_store = project.read(cx).agent_server_store().clone();
- let agent_display_name = agent_server_store
- .read(cx)
- .agent_display_name(&ExternalAgentServerName(agent.name()))
- .unwrap_or_else(|| agent.name());
-
- let placeholder = placeholder_text(agent_display_name.as_ref(), false);
-
- let message_editor = cx.new(|cx| {
- let mut editor = MessageEditor::new_with_cache(
- workspace.clone(),
- project.downgrade(),
- thread_store.clone(),
- history.downgrade(),
- prompt_store.clone(),
- prompt_capabilities.clone(),
- available_commands.clone(),
- cached_user_commands.clone(),
- cached_user_command_errors.clone(),
- agent.name(),
- &placeholder,
- editor::EditorMode::AutoHeight {
- min_lines: AgentSettings::get_global(cx).message_editor_min_lines,
- max_lines: Some(AgentSettings::get_global(cx).set_message_editor_max_lines()),
- },
- window,
- cx,
- );
- if let Some(content) = initial_content {
- match content {
- ExternalAgentInitialContent::ThreadSummary(entry) => {
- editor.insert_thread_summary(entry, window, cx);
- }
- ExternalAgentInitialContent::Text(prompt) => {
- editor.set_message(
- vec![acp::ContentBlock::Text(acp::TextContent::new(prompt))],
- window,
- cx,
- );
- }
- }
- }
- editor
- });
-
let subscriptions = vec![
cx.observe_global_in::<SettingsStore>(window, Self::agent_ui_font_size_changed),
cx.observe_global_in::<AgentFontSize>(window, Self::agent_ui_font_size_changed),
- cx.subscribe_in(&message_editor, window, Self::handle_message_editor_event),
cx.subscribe_in(
&agent_server_store,
window,
@@ -506,125 +279,64 @@ impl AcpServerView {
})
.detach();
- let show_codex_windows_warning = cfg!(windows)
- && project.read(cx).is_local()
- && agent.clone().downcast::<agent_servers::Codex>().is_some();
-
- // Create SlashCommandRegistry to cache user-defined slash commands and watch for changes
- let slash_command_registry = if cx.has_flag::<UserSlashCommandsFeatureFlag>() {
- let fs = project.read(cx).fs().clone();
- let worktree_roots: Vec<std::path::PathBuf> = project
- .read(cx)
- .visible_worktrees(cx)
- .map(|worktree| worktree.read(cx).abs_path().to_path_buf())
- .collect();
- let registry = cx.new(|cx| SlashCommandRegistry::new(fs, worktree_roots, cx));
-
- // Subscribe to registry changes to update error display and cached commands
- cx.subscribe(®istry, move |this, registry, event, cx| match event {
- SlashCommandRegistryEvent::CommandsChanged => {
- this.refresh_cached_user_commands_from_registry(®istry, cx);
- }
- })
- .detach();
-
- // Initialize cached commands and errors from registry
- let mut commands = registry.read(cx).commands().clone();
- let mut errors = registry.read(cx).errors().to_vec();
- let server_command_names = available_commands
- .borrow()
- .iter()
- .map(|command| command.name.clone())
- .collect::<HashSet<_>>();
- user_slash_command::apply_server_command_conflicts_to_map(
- &mut commands,
- &mut errors,
- &server_command_names,
- );
- *cached_user_commands.borrow_mut() = commands;
- *cached_user_command_errors.borrow_mut() = errors;
-
- Some(registry)
- } else {
- None
- };
-
- let recent_history_entries = history.read(cx).get_recent_sessions(3);
- let history_subscription = cx.observe(&history, |this, history, cx| {
- this.update_recent_history_from_cache(&history, cx);
- });
+ let workspace_for_state = workspace.clone();
+ let project_for_state = project.clone();
Self {
agent: agent.clone(),
agent_server_store,
- workspace: workspace.clone(),
- project: project.clone(),
+ workspace,
+ project,
thread_store,
prompt_store,
server_state: Self::initial_state(
agent.clone(),
resume_thread,
- workspace.clone(),
- project.clone(),
+ workspace_for_state,
+ project_for_state,
prompt_capabilities,
available_commands,
- cached_user_commands,
- cached_user_command_errors,
+ initial_content,
window,
cx,
),
login: None,
- message_editor,
notifications: Vec::new(),
notification_subscriptions: HashMap::default(),
- slash_command_registry,
auth_task: None,
- recent_history_entries,
history,
- _history_subscription: history_subscription,
- hovered_recent_history_item: None,
_subscriptions: subscriptions,
focus_handle: cx.focus_handle(),
- show_codex_windows_warning,
- in_flight_prompt: None,
- add_context_menu_handle: PopoverMenuHandle::default(),
}
}
fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
let available_commands = Rc::new(RefCell::new(vec![]));
- let cached_user_commands = Rc::new(RefCell::new(collections::HashMap::default()));
- let cached_user_command_errors = Rc::new(RefCell::new(Vec::new()));
let resume_thread_metadata = self
.as_active_thread()
- .and_then(|thread| thread.resume_thread_metadata.clone());
-
- self.message_editor.update(cx, |editor, cx| {
- editor.set_command_state(
- prompt_capabilities.clone(),
- available_commands.clone(),
- cached_user_commands.clone(),
- cached_user_command_errors.clone(),
- cx,
- );
- });
+ .and_then(|thread| thread.read(cx).resume_thread_metadata.clone());
self.server_state = Self::initial_state(
self.agent.clone(),
resume_thread_metadata,
self.workspace.clone(),
self.project.clone(),
- prompt_capabilities,
- available_commands,
- cached_user_commands,
- cached_user_command_errors,
+ prompt_capabilities.clone(),
+ available_commands.clone(),
+ None,
window,
cx,
);
- self.refresh_cached_user_commands(cx);
- self.recent_history_entries.clear();
+
+ if let Some(connected) = self.as_connected() {
+ connected.current.update(cx, |this, cx| {
+ this.message_editor.update(cx, |editor, cx| {
+ editor.set_command_state(prompt_capabilities, available_commands, cx);
+ });
+ });
+ }
cx.notify();
}
@@ -635,8 +347,7 @@ impl AcpServerView {
project: Entity<Project>,
prompt_capabilities: Rc<RefCell<PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
- cached_user_commands: Rc<RefCell<HashMap<String, UserSlashCommand>>>,
- cached_user_command_errors: Rc<RefCell<Vec<CommandLoadError>>>,
+ initial_content: Option<ExternalAgentInitialContent>,
window: &mut Window,
cx: &mut Context<Self>,
) -> ServerState {
@@ -688,8 +399,8 @@ impl AcpServerView {
this.update_in(cx, |this, window, cx| {
if err.downcast_ref::<LoadError>().is_some() {
this.handle_load_error(err, window, cx);
- } else {
- this.handle_thread_error(err, cx);
+ } else if let Some(active) = this.as_active_thread() {
+ active.update(cx, |active, cx| active.handle_any_thread_error(err, cx));
}
cx.notify();
})
@@ -772,8 +483,6 @@ impl AcpServerView {
this.prompt_store.clone(),
prompt_capabilities.clone(),
available_commands.clone(),
- cached_user_commands.clone(),
- cached_user_command_errors.clone(),
this.agent.name(),
)
});
@@ -861,11 +570,11 @@ impl AcpServerView {
let mut subscriptions = vec![
cx.subscribe_in(&thread, window, Self::handle_thread_event),
cx.observe(&action_log, |_, _, cx| cx.notify()),
- cx.subscribe_in(
- &entry_view_state,
- window,
- Self::handle_entry_view_event,
- ),
+ // cx.subscribe_in(
+ // &entry_view_state,
+ // window,
+ // Self::handle_entry_view_event,
+ // ),
];
let title_editor =
@@ -900,11 +609,20 @@ impl AcpServerView {
})
});
- this.server_state = ServerState::Connected(ConnectedServerState {
- connection,
- auth_state: AuthState::Ok,
- current: AcpThreadView::new(
+ let agent_display_name = this
+ .agent_server_store
+ .read(cx)
+ .agent_display_name(&ExternalAgentServerName(agent.name()))
+ .unwrap_or_else(|| agent.name());
+
+ let weak = cx.weak_entity();
+ let current = cx.new(|cx| {
+ AcpThreadView::new(
thread,
+ this.login.clone(),
+ weak,
+ agent.name(),
+ agent_display_name,
workspace.clone(),
entry_view_state,
title_editor,
@@ -915,19 +633,33 @@ impl AcpServerView {
list_state,
prompt_capabilities,
available_commands,
- cached_user_commands,
- cached_user_command_errors,
resumed_without_history,
resume_thread.clone(),
+ project.downgrade(),
+ this.thread_store.clone(),
+ this.history.clone(),
+ this.prompt_store.clone(),
+ initial_content,
subscriptions,
+ window,
cx,
- ),
+ )
});
if this.focus_handle.contains_focused(window, cx) {
- this.message_editor.focus_handle(cx).focus(window, cx);
+ current
+ .read(cx)
+ .message_editor
+ .focus_handle(cx)
+ .focus(window, cx);
}
+ this.server_state = ServerState::Connected(ConnectedServerState {
+ connection,
+ auth_state: AuthState::Ok,
+ current,
+ });
+
cx.notify();
}
Err(err) => {
@@ -942,8 +674,10 @@ impl AcpServerView {
while let Ok(new_version) = new_version_available_rx.recv().await {
if let Some(new_version) = new_version {
this.update(cx, |this, cx| {
- if let Some(thread) = this.as_active_thread_mut() {
- thread.new_server_version_available = Some(new_version.into());
+ if let Some(thread) = this.as_active_thread() {
+ thread.update(cx, |thread, _cx| {
+ thread.new_server_version_available = Some(new_version.into());
+ });
}
cx.notify();
})
@@ -1028,9 +762,15 @@ impl AcpServerView {
description,
_subscription: subscription,
};
- }
- if this.message_editor.focus_handle(cx).is_focused(window) {
- this.focus_handle.focus(window, cx)
+ if connected
+ .current
+ .read(cx)
+ .message_editor
+ .focus_handle(cx)
+ .is_focused(window)
+ {
+ this.focus_handle.focus(window, cx)
+ }
}
cx.notify();
})
@@ -1043,15 +783,26 @@ impl AcpServerView {
window: &mut Window,
cx: &mut Context<Self>,
) {
+ match &self.server_state {
+ ServerState::Connected(connected) => {
+ if connected
+ .current
+ .read(cx)
+ .message_editor
+ .focus_handle(cx)
+ .is_focused(window)
+ {
+ self.focus_handle.focus(window, cx)
+ }
+ }
+ _ => {}
+ }
if let Some(load_err) = err.downcast_ref::<LoadError>() {
self.server_state = ServerState::LoadError(load_err.clone());
} else {
self.server_state =
ServerState::LoadError(LoadError::Other(format!("{:#}", err).into()))
}
- if self.message_editor.focus_handle(cx).is_focused(window) {
- self.focus_handle.focus(window, cx)
- }
cx.notify();
}
@@ -1069,14 +820,15 @@ impl AcpServerView {
ServerState::Loading(_) => false,
ServerState::LoadError(_) => true,
ServerState::Connected(connected) => {
- connected.auth_state.is_ok() && connected.has_thread_error()
+ connected.auth_state.is_ok() && connected.has_thread_error(cx)
}
};
if should_retry {
- if let Some(active) = self.as_active_thread_mut() {
- active.thread_error = None;
- active.thread_error_markdown = None;
+ if let Some(active) = self.as_active_thread() {
+ active.update(cx, |active, cx| {
+ active.clear_thread_error(cx);
+ });
}
self.reset(window, cx);
}
@@ -1102,529 +854,111 @@ impl AcpServerView {
}
pub fn cancel_generation(&mut self, cx: &mut Context<Self>) {
- if let Some(active) = self.as_active_thread_mut() {
- active.cancel_generation(cx);
- }
- }
-
- fn share_thread(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
- let Some(thread) = self.as_native_thread(cx) else {
- return;
- };
-
- let client = self.project.read(cx).client();
- let workspace = self.workspace.clone();
- let session_id = thread.read(cx).id().to_string();
-
- let load_task = thread.read(cx).to_db(cx);
-
- cx.spawn(async move |_this, cx| {
- let db_thread = load_task.await;
-
- let shared_thread = SharedThread::from_db_thread(&db_thread);
- let thread_data = shared_thread.to_bytes()?;
- let title = shared_thread.title.to_string();
-
- client
- .request(proto::ShareAgentThread {
- session_id: session_id.clone(),
- title,
- thread_data,
- })
- .await?;
-
- let share_url = client::zed_urls::shared_agent_thread_url(&session_id);
-
- cx.update(|cx| {
- if let Some(workspace) = workspace.upgrade() {
- workspace.update(cx, |workspace, cx| {
- struct ThreadSharedToast;
- workspace.show_toast(
- Toast::new(
- NotificationId::unique::<ThreadSharedToast>(),
- "Thread shared!",
- )
- .on_click(
- "Copy URL",
- move |_window, cx| {
- cx.write_to_clipboard(ClipboardItem::new_string(
- share_url.clone(),
- ));
- },
- ),
- cx,
- );
- });
- }
+ if let Some(active) = self.as_active_thread() {
+ active.update(cx, |active, cx| {
+ active.cancel_generation(cx);
});
-
- anyhow::Ok(())
- })
- .detach_and_log_err(cx);
- }
-
- fn sync_thread(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- if !self.is_imported_thread(cx) {
- return;
}
-
- let Some(active) = self.as_active_thread() else {
- return;
- };
-
- let Some(session_list) = self
- .as_native_connection(cx)
- .and_then(|connection| connection.session_list(cx))
- .and_then(|list| list.downcast::<NativeAgentSessionList>())
- else {
- return;
- };
- let thread_store = session_list.thread_store().clone();
-
- let client = self.project.read(cx).client();
- let session_id = active.thread.read(cx).session_id().clone();
-
- cx.spawn_in(window, async move |this, cx| {
- let response = client
- .request(proto::GetSharedAgentThread {
- session_id: session_id.to_string(),
- })
- .await?;
-
- let shared_thread = SharedThread::from_bytes(&response.thread_data)?;
-
- let db_thread = shared_thread.to_db_thread();
-
- thread_store
- .update(&mut cx.clone(), |store, cx| {
- store.save_thread(session_id.clone(), db_thread, cx)
- })
- .await?;
-
- let thread_metadata = AgentSessionInfo {
- session_id,
- cwd: None,
- title: Some(format!("π {}", response.title).into()),
- updated_at: Some(chrono::Utc::now()),
- meta: None,
- };
-
- this.update_in(cx, |this, window, cx| {
- if let Some(active) = this.as_active_thread_mut() {
- active.resume_thread_metadata = Some(thread_metadata);
- }
- this.reset(window, cx);
- })?;
-
- this.update_in(cx, |this, _window, cx| {
- if let Some(workspace) = this.workspace.upgrade() {
- workspace.update(cx, |workspace, cx| {
- struct ThreadSyncedToast;
- workspace.show_toast(
- Toast::new(
- NotificationId::unique::<ThreadSyncedToast>(),
- "Thread synced with latest version",
- )
- .autohide(),
- cx,
- );
- });
- }
- })?;
-
- anyhow::Ok(())
- })
- .detach_and_log_err(cx);
}
- pub fn expand_message_editor(
+ pub fn handle_title_editor_event(
&mut self,
- _: &ExpandMessageEditor,
- _window: &mut Window,
+ title_editor: &Entity<Editor>,
+ event: &EditorEvent,
+ window: &mut Window,
cx: &mut Context<Self>,
) {
- let editor = self.message_editor.clone();
- if let Some(active) = self.as_active_thread_mut() {
- active.expand_message_editor(editor, cx);
+ if let Some(active) = self.as_active_thread() {
+ active.update(cx, |active, cx| {
+ active.handle_title_editor_event(title_editor, event, window, cx);
+ });
}
}
- fn set_editor_is_expanded(&mut self, is_expanded: bool, cx: &mut Context<Self>) {
- let editor = self.message_editor.clone();
- if let Some(active) = self.as_active_thread_mut() {
- active.set_editor_is_expanded(is_expanded, editor, cx);
- }
+ pub fn is_loading(&self) -> bool {
+ matches!(self.server_state, ServerState::Loading { .. })
}
- pub fn handle_title_editor_event(
- &mut self,
- title_editor: &Entity<Editor>,
- event: &EditorEvent,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- if let Some(active) = self.as_active_thread_mut() {
- active.handle_title_editor_event(title_editor, event, window, cx);
+ fn update_turn_tokens(&mut self, cx: &mut Context<Self>) {
+ if let Some(active) = self.as_active_thread() {
+ active.update(cx, |active, cx| {
+ active.update_turn_tokens(cx);
+ });
}
}
- pub fn handle_message_editor_event(
+ fn send_queued_message_at_index(
&mut self,
- _: &Entity<MessageEditor>,
- event: &MessageEditorEvent,
+ index: usize,
+ is_send_now: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
- match event {
- MessageEditorEvent::Send => self.send(window, cx),
- MessageEditorEvent::SendImmediately => self.interrupt_and_send(window, cx),
- MessageEditorEvent::Cancel => self.cancel_generation(cx),
- MessageEditorEvent::Focus => {
- self.cancel_editing(&Default::default(), window, cx);
- }
- MessageEditorEvent::LostFocus => {}
+ if let Some(active) = self.as_active_thread() {
+ active.update(cx, |active, cx| {
+ active.send_queued_message_at_index(index, is_send_now, window, cx);
+ });
}
}
- pub fn handle_entry_view_event(
+ fn handle_thread_event(
&mut self,
- _: &Entity<EntryViewState>,
- event: &EntryViewEvent,
+ thread: &Entity<AcpThread>,
+ event: &AcpThreadEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
- match &event.view_event {
- ViewEvent::NewDiff(tool_call_id) => {
- if AgentSettings::get_global(cx).expand_edit_card {
- if let Some(active) = self.as_active_thread_mut() {
- active.expanded_tool_calls.insert(tool_call_id.clone());
- }
+ match event {
+ AcpThreadEvent::NewEntry => {
+ let len = thread.read(cx).entries().len();
+ let index = len - 1;
+ if let Some(active) = self.as_active_thread() {
+ let entry_view_state = active.read(cx).entry_view_state.clone();
+ let list_state = active.read(cx).list_state.clone();
+ entry_view_state.update(cx, |view_state, cx| {
+ view_state.sync_entry(index, thread, window, cx);
+ list_state.splice_focusable(
+ index..index,
+ [view_state
+ .entry(index)
+ .and_then(|entry| entry.focus_handle(cx))],
+ );
+ });
}
}
- ViewEvent::NewTerminal(tool_call_id) => {
- if AgentSettings::get_global(cx).expand_terminal_card {
- if let Some(active) = self.as_active_thread_mut() {
- active.expanded_tool_calls.insert(tool_call_id.clone());
- }
+ AcpThreadEvent::EntryUpdated(index) => {
+ if let Some(entry_view_state) = self
+ .as_active_thread()
+ .map(|active| active.read(cx).entry_view_state.clone())
+ {
+ entry_view_state.update(cx, |view_state, cx| {
+ view_state.sync_entry(*index, thread, window, cx)
+ });
}
}
- ViewEvent::TerminalMovedToBackground(tool_call_id) => {
- if let Some(active) = self.as_active_thread_mut() {
- active.expanded_tool_calls.remove(tool_call_id);
+ AcpThreadEvent::EntriesRemoved(range) => {
+ if let Some(active) = self.as_active_thread() {
+ let entry_view_state = active.read(cx).entry_view_state.clone();
+ let list_state = active.read(cx).list_state.clone();
+ entry_view_state.update(cx, |view_state, _cx| view_state.remove(range.clone()));
+ list_state.splice(range.clone(), 0);
}
}
- ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Focus) => {
- if let Some(active) = self.as_active_thread()
- && let Some(AgentThreadEntry::UserMessage(user_message)) =
- active.thread.read(cx).entries().get(event.entry_index)
- && user_message.id.is_some()
- {
- if let Some(active) = self.as_active_thread_mut() {
- active.editing_message = Some(event.entry_index);
- }
- cx.notify();
- }
- }
- ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::LostFocus) => {
- if let Some(active) = self.as_active_thread()
- && let Some(AgentThreadEntry::UserMessage(user_message)) =
- active.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) {
- if let Some(active) = self.as_active_thread_mut() {
- active.editing_message = None;
- }
- cx.notify();
- }
- }
- }
- ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::SendImmediately) => {}
- ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::Send) => {
- self.regenerate(event.entry_index, editor.clone(), window, cx);
- }
- ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Cancel) => {
- self.cancel_editing(&Default::default(), window, cx);
- }
- }
- }
-
- pub fn is_loading(&self) -> bool {
- matches!(self.server_state, ServerState::Loading { .. })
- }
-
- fn retry_generation(&mut self, cx: &mut Context<Self>) {
- if let Some(active) = self.as_active_thread_mut() {
- active.retry_generation(cx);
- };
- }
-
- fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- let message_editor = self.message_editor.clone();
- let login = self.login.clone();
- let agent_name = self.agent.name();
-
- if let Some(active) = self.as_active_thread_mut() {
- active.send(message_editor, agent_name, login, window, cx);
- }
- }
-
- fn interrupt_and_send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- let message_editor = self.message_editor.clone();
- if let Some(active) = self.as_active_thread_mut() {
- active.interrupt_and_send(message_editor, window, cx);
- };
- }
-
- fn start_turn(&mut self, cx: &mut Context<Self>) -> usize {
- self.as_active_thread_mut()
- .map(|active| active.start_turn(cx))
- .unwrap_or(0)
- }
-
- fn stop_turn(&mut self, generation: usize) {
- if let Some(active) = self.as_active_thread_mut() {
- active.stop_turn(generation);
- }
- }
-
- fn update_turn_tokens(&mut self, cx: &App) {
- if let Some(active) = self.as_active_thread_mut() {
- active.update_turn_tokens(cx);
- }
- }
-
- fn send_impl(
- &mut self,
- message_editor: Entity<MessageEditor>,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- let full_mention_content = self.as_native_thread(cx).is_some_and(|thread| {
- // Include full contents when using minimal profile
- let thread = thread.read(cx);
- AgentSettings::get_global(cx)
- .profiles
- .get(thread.profile())
- .is_some_and(|profile| profile.tools.is_empty())
- });
-
- let cached_commands = self.cached_slash_commands(cx);
- let cached_errors = self.cached_slash_command_errors(cx);
- let contents = message_editor.update(cx, |message_editor, cx| {
- message_editor.contents_with_cache(
- full_mention_content,
- Some(cached_commands),
- Some(cached_errors),
- cx,
- )
- });
-
- if let Some(thread) = self.as_active_thread_mut() {
- thread.thread_error.take();
- thread.thread_feedback.clear();
- thread.editing_message.take();
-
- if thread.should_be_following {
- let _ = self.workspace.update(cx, |workspace, cx| {
- workspace.follow(CollaboratorId::Agent, window, cx);
- });
- }
- }
-
- let contents_task = cx.spawn_in(window, async move |this, cx| {
- let (contents, tracked_buffers) = contents.await?;
-
- if contents.is_empty() {
- return Ok(None);
- }
-
- this.update_in(cx, |this, window, cx| {
- this.message_editor.update(cx, |message_editor, cx| {
- message_editor.clear(window, cx);
- });
- })?;
-
- Ok(Some((contents, tracked_buffers)))
- });
-
- self.send_content(contents_task, window, cx);
- }
-
- fn send_content(
- &mut self,
- contents_task: Task<anyhow::Result<Option<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>>>,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- if let Some(active) = self.as_active_thread_mut() {
- active.send_content(contents_task, window, cx);
- };
- }
-
- fn send_queued_message_at_index(
- &mut self,
- index: usize,
- is_send_now: bool,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- if let Some(active) = self.as_active_thread_mut() {
- active.send_queued_message_at_index(index, is_send_now, window, cx);
- }
- }
-
- fn cancel_editing(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
- let focus_handle = self.focus_handle(cx);
- if let Some(active) = self.as_active_thread_mut() {
- active.cancel_editing(focus_handle, window, cx);
- }
- }
-
- fn regenerate(
- &mut self,
- entry_ix: usize,
- message_editor: Entity<MessageEditor>,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- if let Some(active) = self.as_active_thread_mut() {
- active.regenerate(entry_ix, message_editor, window, cx);
- }
- }
-
- fn open_edited_buffer(
- &mut self,
- buffer: &Entity<Buffer>,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- if let Some(active) = self.as_active_thread_mut() {
- active.open_edited_buffer(buffer, window, cx);
- };
- }
-
- fn handle_open_rules(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
- if let Some(active) = self.as_active_thread_mut() {
- active.handle_open_rules(window, cx);
- }
- }
-
- fn handle_thread_error(&mut self, error: anyhow::Error, cx: &mut Context<Self>) {
- let error = ThreadError::from_err(error, &self.agent);
- self.emit_thread_error_telemetry(&error, cx);
- if let Some(thread) = self.as_active_thread_mut() {
- thread.thread_error = Some(error);
- }
- cx.notify();
- }
-
- fn emit_thread_error_telemetry(&self, error: &ThreadError, cx: &mut Context<Self>) {
- let (error_kind, acp_error_code, message): (&str, Option<SharedString>, SharedString) =
- match error {
- ThreadError::PaymentRequired => (
- "payment_required",
- None,
- "You reached your free usage limit. Upgrade to Zed Pro for more prompts."
- .into(),
- ),
- ThreadError::Refusal => {
- let model_or_agent_name = self.current_model_name(cx);
- let message = format!(
- "{} refused to respond to this prompt. This can happen when a model believes the prompt violates its content policy or safety guidelines, so rephrasing it can sometimes address the issue.",
- model_or_agent_name
- );
- ("refusal", None, message.into())
- }
- ThreadError::AuthenticationRequired(message) => {
- ("authentication_required", None, message.clone())
- }
- ThreadError::Other {
- acp_error_code,
- message,
- } => ("other", acp_error_code.clone(), message.clone()),
- };
-
- let (agent_telemetry_id, session_id) = self
- .as_active_thread()
- .map(|r| {
- let thread = r.thread.read(cx);
- (
- thread.connection().telemetry_id(),
- thread.session_id().clone(),
- )
- })
- .unzip();
-
- telemetry::event!(
- "Agent Panel Error Shown",
- agent = agent_telemetry_id,
- session_id = session_id,
- kind = error_kind,
- acp_error_code = acp_error_code,
- message = message,
- );
- }
-
- fn clear_thread_error(&mut self, cx: &mut Context<Self>) {
- if let Some(active) = self.as_active_thread_mut() {
- active.clear_thread_error(cx);
- }
- }
-
- fn handle_thread_event(
- &mut self,
- thread: &Entity<AcpThread>,
- event: &AcpThreadEvent,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- match event {
- AcpThreadEvent::NewEntry => {
- let len = thread.read(cx).entries().len();
- let index = len - 1;
- if let Some(active) = self.as_active_thread_mut() {
- active.entry_view_state.update(cx, |view_state, cx| {
- view_state.sync_entry(index, thread, window, cx);
- active.list_state.splice_focusable(
- index..index,
- [view_state
- .entry(index)
- .and_then(|entry| entry.focus_handle(cx))],
- );
- });
- }
- }
- AcpThreadEvent::EntryUpdated(index) => {
- if let Some(entry_view_state) = self
- .as_active_thread()
- .map(|active| &active.entry_view_state)
- .cloned()
- {
- entry_view_state.update(cx, |view_state, cx| {
- view_state.sync_entry(*index, thread, window, cx)
- });
- }
- }
- AcpThreadEvent::EntriesRemoved(range) => {
- if let Some(active) = self.as_active_thread_mut() {
- active
- .entry_view_state
- .update(cx, |view_state, _cx| view_state.remove(range.clone()));
- active.list_state.splice(range.clone(), 0);
- }
- }
- AcpThreadEvent::ToolAuthorizationRequired => {
- self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx);
+ AcpThreadEvent::ToolAuthorizationRequired => {
+ self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx);
}
AcpThreadEvent::Retry(retry) => {
- if let Some(active) = self.as_active_thread_mut() {
- active.thread_retry_status = Some(retry.clone());
+ if let Some(active) = self.as_active_thread() {
+ active.update(cx, |active, _cx| {
+ active.thread_retry_status = Some(retry.clone());
+ });
}
}
AcpThreadEvent::Stopped => {
- if let Some(active) = self.as_active_thread_mut() {
- active.thread_retry_status.take();
+ if let Some(active) = self.as_active_thread() {
+ active.update(cx, |active, _cx| {
+ active.thread_retry_status.take();
+ });
}
let used_tools = thread.read(cx).used_tools_since_last_user_message();
self.notify_with_sound(
@@ -1,8 +1,173 @@
+use gpui::List;
+
use super::*;
+#[derive(Default)]
+struct ThreadFeedbackState {
+ feedback: Option<ThreadFeedback>,
+ comments_editor: Option<Entity<Editor>>,
+}
+
+impl ThreadFeedbackState {
+ pub fn submit(
+ &mut self,
+ thread: Entity<AcpThread>,
+ feedback: ThreadFeedback,
+ window: &mut Window,
+ cx: &mut App,
+ ) {
+ let Some(telemetry) = thread.read(cx).connection().telemetry() else {
+ return;
+ };
+
+ if self.feedback == Some(feedback) {
+ return;
+ }
+
+ self.feedback = Some(feedback);
+ match feedback {
+ ThreadFeedback::Positive => {
+ self.comments_editor = None;
+ }
+ ThreadFeedback::Negative => {
+ self.comments_editor = Some(Self::build_feedback_comments_editor(window, cx));
+ }
+ }
+ let session_id = thread.read(cx).session_id().clone();
+ let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
+ let task = telemetry.thread_data(&session_id, cx);
+ let rating = match feedback {
+ ThreadFeedback::Positive => "positive",
+ ThreadFeedback::Negative => "negative",
+ };
+ cx.background_spawn(async move {
+ let thread = task.await?;
+ telemetry::event!(
+ "Agent Thread Rated",
+ agent = agent_telemetry_id,
+ session_id = session_id,
+ rating = rating,
+ thread = thread
+ );
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ }
+
+ pub fn submit_comments(&mut self, thread: Entity<AcpThread>, cx: &mut App) {
+ let Some(telemetry) = thread.read(cx).connection().telemetry() else {
+ return;
+ };
+
+ let Some(comments) = self
+ .comments_editor
+ .as_ref()
+ .map(|editor| editor.read(cx).text(cx))
+ .filter(|text| !text.trim().is_empty())
+ else {
+ return;
+ };
+
+ self.comments_editor.take();
+
+ let session_id = thread.read(cx).session_id().clone();
+ let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
+ let task = telemetry.thread_data(&session_id, cx);
+ cx.background_spawn(async move {
+ let thread = task.await?;
+ telemetry::event!(
+ "Agent Thread Feedback Comments",
+ agent = agent_telemetry_id,
+ session_id = session_id,
+ comments = comments,
+ thread = thread
+ );
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ }
+
+ pub fn clear(&mut self) {
+ *self = Self::default()
+ }
+
+ pub fn dismiss_comments(&mut self) {
+ self.comments_editor.take();
+ }
+
+ fn build_feedback_comments_editor(window: &mut Window, cx: &mut App) -> Entity<Editor> {
+ let buffer = cx.new(|cx| {
+ let empty_string = String::new();
+ MultiBuffer::singleton(cx.new(|cx| Buffer::local(empty_string, cx)), cx)
+ });
+
+ let editor = cx.new(|cx| {
+ let mut editor = Editor::new(
+ editor::EditorMode::AutoHeight {
+ min_lines: 1,
+ max_lines: Some(4),
+ },
+ buffer,
+ None,
+ window,
+ cx,
+ );
+ editor.set_placeholder_text(
+ "What went wrong? Share your feedback so we can improve.",
+ window,
+ cx,
+ );
+ editor
+ });
+
+ editor.read(cx).focus_handle(cx).focus(window, cx);
+ editor
+ }
+}
+
+#[derive(Default, Clone, Copy)]
+struct DiffStats {
+ lines_added: u32,
+ lines_removed: u32,
+}
+
+impl DiffStats {
+ fn single_file(buffer: &Buffer, diff: &BufferDiff, cx: &App) -> Self {
+ let mut stats = DiffStats::default();
+ let diff_snapshot = diff.snapshot(cx);
+ let buffer_snapshot = buffer.snapshot();
+ let base_text = diff_snapshot.base_text();
+
+ for hunk in diff_snapshot.hunks(&buffer_snapshot) {
+ let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row);
+ stats.lines_added += added_rows;
+
+ let base_start = hunk.diff_base_byte_range.start.to_point(base_text).row;
+ let base_end = hunk.diff_base_byte_range.end.to_point(base_text).row;
+ let removed_rows = base_end.saturating_sub(base_start);
+ stats.lines_removed += removed_rows;
+ }
+
+ stats
+ }
+
+ fn all_files(changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>, cx: &App) -> Self {
+ let mut total = DiffStats::default();
+ for (buffer, diff) in changed_buffers {
+ let stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx);
+ total.lines_added += stats.lines_added;
+ total.lines_removed += stats.lines_removed;
+ }
+ total
+ }
+}
+
pub struct AcpThreadView {
pub id: acp::SessionId,
+ pub login: Option<task::SpawnInTerminal>, // is some <=> Active | Unauthenticated
pub thread: Entity<AcpThread>,
+ pub server_view: WeakEntity<AcpServerView>,
+ pub agent_name: SharedString,
pub workspace: WeakEntity<Workspace>,
pub entry_view_state: Entity<EntryViewState>,
pub title_editor: Option<Entity<Editor>>,
@@ -15,12 +180,10 @@ pub struct AcpThreadView {
pub(super) thread_error: Option<ThreadError>,
pub thread_error_markdown: Option<Entity<Markdown>>,
pub token_limit_callout_dismissed: bool,
- pub(super) thread_feedback: ThreadFeedbackState,
+ thread_feedback: ThreadFeedbackState,
pub list_state: ListState,
pub prompt_capabilities: Rc<RefCell<PromptCapabilities>>,
pub available_commands: Rc<RefCell<Vec<agent_client_protocol::AvailableCommand>>>,
- pub cached_user_commands: Rc<RefCell<HashMap<String, UserSlashCommand>>>,
- pub cached_user_command_errors: Rc<RefCell<Vec<CommandLoadError>>>,
/// Tracks which tool calls have their content/output expanded.
/// Used for showing/hiding tool call results, terminal output, etc.
pub expanded_tool_calls: HashSet<agent_client_protocol::ToolCallId>,
@@ -39,7 +202,6 @@ pub struct AcpThreadView {
pub queued_message_editor_subscriptions: Vec<Subscription>,
pub last_synced_queue_length: usize,
pub turn_fields: TurnFields,
- pub command_load_errors_dismissed: bool,
pub discarded_partial_edits: HashSet<agent_client_protocol::ToolCallId>,
pub is_loading_contents: bool,
pub new_server_version_available: Option<SharedString>,
@@ -54,7 +216,21 @@ pub struct AcpThreadView {
pub user_interrupted_generation: bool,
pub can_fast_track_queue: bool,
pub hovered_edited_file_buttons: Option<usize>,
+ pub in_flight_prompt: Option<Vec<acp::ContentBlock>>,
pub _subscriptions: Vec<Subscription>,
+ pub message_editor: Entity<MessageEditor>,
+ pub add_context_menu_handle: PopoverMenuHandle<ContextMenu>,
+ pub project: WeakEntity<Project>,
+ pub recent_history_entries: Vec<AgentSessionInfo>,
+ pub hovered_recent_history_item: Option<usize>,
+ pub show_codex_windows_warning: bool,
+ pub history: Entity<AcpThreadHistory>,
+ pub _history_subscription: Subscription,
+}
+impl Focusable for AcpThreadView {
+ fn focus_handle(&self, cx: &App) -> FocusHandle {
+ self.active_editor(cx).focus_handle(cx)
+ }
}
#[derive(Default)]
@@ -70,6 +246,10 @@ pub struct TurnFields {
impl AcpThreadView {
pub fn new(
thread: Entity<AcpThread>,
+ login: Option<task::SpawnInTerminal>,
+ server_view: WeakEntity<AcpServerView>,
+ agent_name: SharedString,
+ agent_display_name: SharedString,
workspace: WeakEntity<Workspace>,
entry_view_state: Entity<EntryViewState>,
title_editor: Option<Entity<Editor>>,
@@ -80,17 +260,84 @@ impl AcpThreadView {
list_state: ListState,
prompt_capabilities: Rc<RefCell<PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<agent_client_protocol::AvailableCommand>>>,
- cached_user_commands: Rc<RefCell<HashMap<String, UserSlashCommand>>>,
- cached_user_command_errors: Rc<RefCell<Vec<CommandLoadError>>>,
resumed_without_history: bool,
resume_thread_metadata: Option<AgentSessionInfo>,
- subscriptions: Vec<Subscription>,
- cx: &App,
+ project: WeakEntity<Project>,
+ thread_store: Option<Entity<ThreadStore>>,
+ history: Entity<AcpThreadHistory>,
+ prompt_store: Option<Entity<PromptStore>>,
+ initial_content: Option<ExternalAgentInitialContent>,
+ mut subscriptions: Vec<Subscription>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
) -> Self {
let id = thread.read(cx).session_id().clone();
+
+ let placeholder = placeholder_text(agent_display_name.as_ref(), false);
+
+ let history_subscription = cx.observe(&history, |this, history, cx| {
+ this.update_recent_history_from_cache(&history, cx);
+ });
+
+ let message_editor = cx.new(|cx| {
+ let mut editor = MessageEditor::new(
+ workspace.clone(),
+ project.clone(),
+ thread_store,
+ history.downgrade(),
+ prompt_store,
+ prompt_capabilities.clone(),
+ available_commands.clone(),
+ agent_name.clone(),
+ &placeholder,
+ editor::EditorMode::AutoHeight {
+ min_lines: AgentSettings::get_global(cx).message_editor_min_lines,
+ max_lines: Some(AgentSettings::get_global(cx).set_message_editor_max_lines()),
+ },
+ window,
+ cx,
+ );
+ if let Some(content) = initial_content {
+ match content {
+ ExternalAgentInitialContent::ThreadSummary(entry) => {
+ editor.insert_thread_summary(entry, window, cx);
+ }
+ ExternalAgentInitialContent::Text(prompt) => {
+ editor.set_message(
+ vec![acp::ContentBlock::Text(acp::TextContent::new(prompt))],
+ window,
+ cx,
+ );
+ }
+ }
+ }
+ editor
+ });
+
+ let show_codex_windows_warning = cfg!(windows)
+ && project.upgrade().is_some_and(|p| p.read(cx).is_local())
+ && agent_name == "Codex";
+
+ subscriptions.push(cx.subscribe_in(
+ &entry_view_state,
+ window,
+ Self::handle_entry_view_event,
+ ));
+
+ subscriptions.push(cx.subscribe_in(
+ &message_editor,
+ window,
+ Self::handle_message_editor_event,
+ ));
+
+ let recent_history_entries = history.read(cx).get_recent_sessions(3);
+
Self {
id,
thread,
+ login,
+ server_view,
+ agent_name,
workspace,
entry_view_state,
title_editor,
@@ -101,11 +348,8 @@ impl AcpThreadView {
list_state,
prompt_capabilities,
available_commands,
- cached_user_commands,
- cached_user_command_errors,
resumed_without_history,
resume_thread_metadata,
- command_load_errors_dismissed: false,
_subscriptions: subscriptions,
permission_dropdown_handle: PopoverMenuHandle::default(),
thread_retry_status: None,
@@ -138,6 +382,33 @@ impl AcpThreadView {
user_interrupted_generation: false,
can_fast_track_queue: false,
hovered_edited_file_buttons: None,
+ in_flight_prompt: None,
+ message_editor,
+ add_context_menu_handle: PopoverMenuHandle::default(),
+ project,
+ recent_history_entries,
+ hovered_recent_history_item: None,
+ history,
+ _history_subscription: history_subscription,
+ show_codex_windows_warning,
+ }
+ }
+
+ pub fn handle_message_editor_event(
+ &mut self,
+ _editor: &Entity<MessageEditor>,
+ event: &MessageEditorEvent,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ match event {
+ MessageEditorEvent::Send => self.send(window, cx),
+ MessageEditorEvent::SendImmediately => self.interrupt_and_send(window, cx),
+ MessageEditorEvent::Cancel => self.cancel_generation(cx),
+ MessageEditorEvent::Focus => {
+ self.cancel_editing(&Default::default(), window, cx);
+ }
+ MessageEditorEvent::LostFocus => {}
}
}
@@ -170,6 +441,23 @@ impl AcpThreadView {
}
}
+ /// Returns the currently active editor, either for a message that is being
+ /// edited or the editor for a new message.
+ pub(crate) fn active_editor(&self, cx: &App) -> Entity<MessageEditor> {
+ if let Some(index) = self.editing_message
+ && let Some(editor) = self
+ .entry_view_state
+ .read(cx)
+ .entry(index)
+ .and_then(|entry| entry.message_editor())
+ .cloned()
+ {
+ editor
+ } else {
+ self.message_editor.clone()
+ }
+ }
+
pub fn has_queued_messages(&self) -> bool {
!self.local_queued_messages.is_empty()
}
@@ -181,9 +469,62 @@ impl AcpThreadView {
thread.read(cx).is_imported()
}
+ // events
+
+ pub fn handle_entry_view_event(
+ &mut self,
+ _: &Entity<EntryViewState>,
+ event: &EntryViewEvent,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ match &event.view_event {
+ ViewEvent::NewDiff(tool_call_id) => {
+ if AgentSettings::get_global(cx).expand_edit_card {
+ self.expanded_tool_calls.insert(tool_call_id.clone());
+ }
+ }
+ ViewEvent::NewTerminal(tool_call_id) => {
+ if AgentSettings::get_global(cx).expand_terminal_card {
+ self.expanded_tool_calls.insert(tool_call_id.clone());
+ }
+ }
+ ViewEvent::TerminalMovedToBackground(tool_call_id) => {
+ self.expanded_tool_calls.remove(tool_call_id);
+ }
+ ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Focus) => {
+ if let Some(AgentThreadEntry::UserMessage(user_message)) =
+ self.thread.read(cx).entries().get(event.entry_index)
+ && user_message.id.is_some()
+ {
+ self.editing_message = Some(event.entry_index);
+ cx.notify();
+ }
+ }
+ ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::LostFocus) => {
+ if let Some(AgentThreadEntry::UserMessage(user_message)) =
+ self.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::SendImmediately) => {}
+ ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::Send) => {
+ self.regenerate(event.entry_index, editor.clone(), window, cx);
+ }
+ ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Cancel) => {
+ self.cancel_editing(&Default::default(), window, cx);
+ }
+ }
+ }
+
// turns
- pub fn start_turn(&mut self, cx: &mut Context<AcpServerView>) -> usize {
+ pub fn start_turn(&mut self, cx: &mut Context<Self>) -> usize {
self.turn_fields.turn_generation += 1;
let generation = self.turn_fields.turn_generation;
self.turn_fields.turn_started_at = Some(Instant::now());
@@ -224,20 +565,14 @@ impl AcpThreadView {
// sending
- pub fn send(
- &mut self,
- message_editor: Entity<MessageEditor>,
- agent_name: SharedString,
- login: Option<task::SpawnInTerminal>,
- window: &mut Window,
- cx: &mut Context<AcpServerView>,
- ) {
+ pub fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let thread = &self.thread;
if self.is_loading_contents {
return;
}
+ let message_editor = self.message_editor.clone();
let is_editor_empty = message_editor.read(cx).is_empty(cx);
let is_generating = thread.read(cx).status() != ThreadStatus::Idle;
@@ -261,7 +596,7 @@ impl AcpThreadView {
let text = text.trim();
if text == "/login" || text == "/logout" {
let connection = thread.read(cx).connection().clone();
- let can_login = !connection.auth_methods().is_empty() || login.is_some();
+ let can_login = !connection.auth_methods().is_empty() || self.login.is_some();
// Does the agent have a specific logout command? Prefer that in case they need to reset internal state.
let logout_supported = text == "/logout"
&& self
@@ -272,15 +607,18 @@ impl AcpThreadView {
if can_login && !logout_supported {
message_editor.update(cx, |editor, cx| editor.clear(window, cx));
- let this = cx.weak_entity();
- window.defer(cx, |window, cx| {
- AcpServerView::handle_auth_required(
- this,
- AuthRequired::new(),
- agent_name,
- window,
- cx,
- );
+ window.defer(cx, {
+ let agent_name = self.agent_name.clone();
+ let server_view = self.server_view.clone();
+ move |window, cx| {
+ AcpServerView::handle_auth_required(
+ server_view.clone(),
+ AuthRequired::new(),
+ agent_name,
+ window,
+ cx,
+ );
+ }
});
cx.notify();
return;
@@ -294,7 +632,7 @@ impl AcpThreadView {
&mut self,
message_editor: Entity<MessageEditor>,
window: &mut Window,
- cx: &mut Context<AcpServerView>,
+ cx: &mut Context<Self>,
) {
let full_mention_content = self.as_native_thread(cx).is_some_and(|thread| {
// Include full contents when using minimal profile
@@ -305,15 +643,8 @@ impl AcpThreadView {
.is_some_and(|profile| profile.tools.is_empty())
});
- let cached_commands = &self.cached_user_commands;
- let cached_errors = &self.cached_user_command_errors;
let contents = message_editor.update(cx, |message_editor, cx| {
- message_editor.contents_with_cache(
- full_mention_content,
- Some(cached_commands.borrow().clone()),
- Some(cached_errors.borrow().clone()),
- cx,
- )
+ message_editor.contents(full_mention_content, cx)
});
self.thread_error.take();
@@ -351,7 +682,7 @@ impl AcpThreadView {
&mut self,
contents_task: Task<anyhow::Result<Option<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>>>,
window: &mut Window,
- cx: &mut Context<AcpServerView>,
+ cx: &mut Context<Self>,
) {
let session_id = self.thread.read(cx).session_id().clone();
let agent_telemetry_id = self.thread.read(cx).connection().telemetry_id();
@@ -363,9 +694,7 @@ impl AcpThreadView {
let mode_id = self.current_mode_id(cx);
let guard = cx.new(|_| ());
cx.observe_release(&guard, |this, _guard, cx| {
- if let Some(thread) = this.as_active_thread_mut() {
- thread.is_loading_contents = false;
- }
+ this.is_loading_contents = false;
cx.notify();
})
.detach();
@@ -375,14 +704,17 @@ impl AcpThreadView {
return Ok(());
};
- let generation = this.update_in(cx, |this, _window, cx| {
- this.in_flight_prompt = Some(contents.clone());
+ let generation = this.update(cx, |this, cx| {
let generation = this.start_turn(cx);
- this.set_editor_is_expanded(false, cx);
- this.scroll_to_bottom(cx);
+ this.in_flight_prompt = Some(contents.clone());
generation
})?;
+ this.update_in(cx, |this, _window, cx| {
+ this.set_editor_is_expanded(false, cx);
+ })?;
+ let _ = this.update(cx, |this, cx| this.scroll_to_bottom(cx));
+
let _stop_turn = defer({
let this = this.clone();
let mut cx = cx.clone();
@@ -417,7 +749,7 @@ impl AcpThreadView {
let turn_time_ms = turn_start_time.elapsed().as_millis();
drop(_stop_turn);
let status = if res.is_ok() {
- this.update(cx, |this, _| this.in_flight_prompt.take()).ok();
+ let _ = this.update(cx, |this, _| this.in_flight_prompt.take());
"success"
} else {
"failure"
@@ -437,7 +769,7 @@ impl AcpThreadView {
cx.spawn(async move |this, cx| {
if let Err(err) = task.await {
this.update(cx, |this, cx| {
- this.handle_thread_error(err, cx);
+ this.handle_any_thread_error(err, cx);
})
.ok();
} else {
@@ -448,9 +780,7 @@ impl AcpThreadView {
workspace.is_being_followed(CollaboratorId::Agent)
})
.unwrap_or_default();
- if let Some(thread) = this.as_active_thread_mut() {
- thread.should_be_following = should_be_following;
- }
+ this.should_be_following = should_be_following;
})
.ok();
}
@@ -458,30 +788,27 @@ impl AcpThreadView {
.detach();
}
- pub fn interrupt_and_send(
- &mut self,
- message_editor: Entity<MessageEditor>,
- window: &mut Window,
- cx: &mut Context<AcpServerView>,
- ) {
+ pub fn interrupt_and_send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let thread = &self.thread;
if self.is_loading_contents {
return;
}
+ let message_editor = self.message_editor.clone();
if thread.read(cx).status() == ThreadStatus::Idle {
self.send_impl(message_editor, window, cx);
return;
}
- self.stop_current_and_send_new_message(window, cx);
+ self.stop_current_and_send_new_message(message_editor, window, cx);
}
- pub fn stop_current_and_send_new_message(
+ fn stop_current_and_send_new_message(
&mut self,
+ message_editor: Entity<MessageEditor>,
window: &mut Window,
- cx: &mut Context<AcpServerView>,
+ cx: &mut Context<Self>,
) {
let thread = self.thread.clone();
self.skip_queue_processing_count = 0;
@@ -493,23 +820,73 @@ impl AcpThreadView {
cancelled.await;
this.update_in(cx, |this, window, cx| {
- this.send_impl(this.message_editor.clone(), window, cx);
+ this.send_impl(message_editor, window, cx);
})
.ok();
})
.detach();
}
+ pub(crate) fn handle_any_thread_error(&mut self, error: anyhow::Error, cx: &mut Context<Self>) {
+ let error = ThreadError::from_err(error, &self.agent_name);
+ self.handle_thread_error(error, cx);
+ }
+
+ pub(crate) fn handle_thread_error(&mut self, error: ThreadError, cx: &mut Context<Self>) {
+ self.emit_thread_error_telemetry(&error, cx);
+ self.thread_error = Some(error);
+ cx.notify();
+ }
+
+ fn emit_thread_error_telemetry(&self, error: &ThreadError, cx: &mut Context<Self>) {
+ let (error_kind, acp_error_code, message): (&str, Option<SharedString>, SharedString) =
+ match error {
+ ThreadError::PaymentRequired => (
+ "payment_required",
+ None,
+ "You reached your free usage limit. Upgrade to Zed Pro for more prompts."
+ .into(),
+ ),
+ ThreadError::Refusal => {
+ let model_or_agent_name = self.current_model_name(cx);
+ let message = format!(
+ "{} refused to respond to this prompt. This can happen when a model believes the prompt violates its content policy or safety guidelines, so rephrasing it can sometimes address the issue.",
+ model_or_agent_name
+ );
+ ("refusal", None, message.into())
+ }
+ ThreadError::AuthenticationRequired(message) => {
+ ("authentication_required", None, message.clone())
+ }
+ ThreadError::Other {
+ acp_error_code,
+ message,
+ } => ("other", acp_error_code.clone(), message.clone()),
+ };
+
+ let agent_telemetry_id = self.thread.read(cx).connection().telemetry_id();
+ let session_id = self.thread.read(cx).session_id().clone();
+
+ telemetry::event!(
+ "Agent Panel Error Shown",
+ agent = agent_telemetry_id,
+ session_id = session_id,
+ kind = error_kind,
+ acp_error_code = acp_error_code,
+ message = message,
+ );
+ }
+
// generation
- pub fn cancel_generation(&mut self, cx: &mut Context<AcpServerView>) {
+ pub fn cancel_generation(&mut self, cx: &mut Context<Self>) {
self.thread_retry_status.take();
self.thread_error.take();
self.user_interrupted_generation = true;
self._cancel_task = Some(self.thread.update(cx, |thread, cx| thread.cancel(cx)));
}
- pub fn retry_generation(&mut self, cx: &mut Context<AcpServerView>) {
+ pub fn retry_generation(&mut self, cx: &mut Context<Self>) {
self.thread_error.take();
let thread = &self.thread;
@@ -523,7 +900,7 @@ impl AcpThreadView {
this.update(cx, |this, cx| {
if let Err(err) = result {
- this.handle_thread_error(err, cx);
+ this.handle_any_thread_error(err, cx);
}
})
})
@@ -535,7 +912,7 @@ impl AcpThreadView {
entry_ix: usize,
message_editor: Entity<MessageEditor>,
window: &mut Window,
- cx: &mut Context<AcpServerView>,
+ cx: &mut Context<Self>,
) {
if self.is_loading_contents {
return;
@@ -574,9 +951,9 @@ impl AcpThreadView {
thread
.update(cx, |thread, cx| thread.rewind(user_message_id, cx))
.await?;
- this.update_in(cx, |this, window, cx| {
- this.send_impl(message_editor, window, cx);
- this.focus_handle(cx).focus(window, cx);
+ this.update_in(cx, |thread, window, cx| {
+ thread.send_impl(message_editor, window, cx);
+ thread.focus_handle(cx).focus(window, cx);
})?;
anyhow::Ok(())
})
@@ -585,11 +962,11 @@ impl AcpThreadView {
// message queueing
- pub fn queue_message(
+ fn queue_message(
&mut self,
message_editor: Entity<MessageEditor>,
window: &mut Window,
- cx: &mut Context<AcpServerView>,
+ cx: &mut Context<Self>,
) {
let is_idle = self.thread.read(cx).status() == acp_thread::ThreadStatus::Idle;
@@ -606,15 +983,8 @@ impl AcpThreadView {
.is_some_and(|profile| profile.tools.is_empty())
});
- let cached_commands = self.cached_user_commands.borrow().clone();
- let cached_errors = self.cached_user_command_errors.borrow().clone();
let contents = message_editor.update(cx, |message_editor, cx| {
- message_editor.contents_with_cache(
- full_mention_content,
- Some(cached_commands),
- Some(cached_errors),
- cx,
- )
+ message_editor.contents(full_mention_content, cx)
});
cx.spawn_in(window, async move |this, cx| {
@@ -626,8 +996,7 @@ impl AcpThreadView {
this.update_in(cx, |this, window, cx| {
this.add_to_queue(content, tracked_buffers, cx);
- // Enable fast-track: user can press Enter again to send this queued message immediately
- this.set_can_fast_track_queue(true);
+ this.can_fast_track_queue = true;
message_editor.update(cx, |message_editor, cx| {
message_editor.clear(window, cx);
});
@@ -638,10 +1007,23 @@ impl AcpThreadView {
.detach_and_log_err(cx);
}
+ pub fn add_to_queue(
+ &mut self,
+ content: Vec<acp::ContentBlock>,
+ tracked_buffers: Vec<Entity<Buffer>>,
+ cx: &mut Context<Self>,
+ ) {
+ self.local_queued_messages.push(QueuedMessage {
+ content,
+ tracked_buffers,
+ });
+ self.sync_queue_flag_to_native_thread(cx);
+ }
+
pub fn remove_from_queue(
&mut self,
index: usize,
- cx: &mut Context<AcpServerView>,
+ cx: &mut Context<Self>,
) -> Option<QueuedMessage> {
if index < self.local_queued_messages.len() {
let removed = self.local_queued_messages.remove(index);
@@ -652,7 +1034,7 @@ impl AcpThreadView {
}
}
- pub fn sync_queue_flag_to_native_thread(&self, cx: &mut Context<AcpServerView>) {
+ pub fn sync_queue_flag_to_native_thread(&self, cx: &mut Context<Self>) {
if let Some(native_thread) = self.as_native_thread(cx) {
let has_queued = self.has_queued_messages();
native_thread.update(cx, |thread, _| {
@@ -666,7 +1048,7 @@ impl AcpThreadView {
index: usize,
is_send_now: bool,
window: &mut Window,
- cx: &mut Context<AcpServerView>,
+ cx: &mut Context<Self>,
) {
let Some(queued) = self.remove_from_queue(index, cx) else {
return;
@@ -709,22 +1091,18 @@ impl AcpThreadView {
pub fn expand_message_editor(
&mut self,
- message_editor: Entity<MessageEditor>,
- cx: &mut Context<AcpServerView>,
+ _: &ExpandMessageEditor,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
) {
- self.set_editor_is_expanded(!self.editor_expanded, message_editor, cx);
+ self.set_editor_is_expanded(!self.editor_expanded, cx);
cx.stop_propagation();
cx.notify();
}
- pub fn set_editor_is_expanded(
- &mut self,
- is_expanded: bool,
- message_editor: Entity<MessageEditor>,
- cx: &mut Context<AcpServerView>,
- ) {
+ pub fn set_editor_is_expanded(&mut self, is_expanded: bool, cx: &mut Context<Self>) {
self.editor_expanded = is_expanded;
- message_editor.update(cx, |editor, cx| {
+ self.message_editor.update(cx, |editor, cx| {
if is_expanded {
editor.set_mode(
EditorMode::Full {
@@ -753,7 +1131,7 @@ impl AcpThreadView {
title_editor: &Entity<Editor>,
event: &EditorEvent,
window: &mut Window,
- cx: &mut Context<AcpServerView>,
+ cx: &mut Context<Self>,
) {
let thread = &self.thread;
@@ -777,12 +1155,7 @@ impl AcpThreadView {
}
}
- pub fn cancel_editing(
- &mut self,
- focus_handle: FocusHandle,
- window: &mut Window,
- cx: &mut Context<AcpServerView>,
- ) {
+ pub fn cancel_editing(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
if let Some(index) = self.editing_message.take()
&& let Some(editor) = &self
.entry_view_state
@@ -803,7 +1176,6 @@ impl AcpThreadView {
}
})
};
- focus_handle.focus(window, cx);
cx.notify();
}
@@ -815,7 +1187,7 @@ impl AcpThreadView {
option_id: acp::PermissionOptionId,
option_kind: acp::PermissionOptionKind,
window: &mut Window,
- cx: &mut Context<AcpServerView>,
+ cx: &mut Context<Self>,
) {
let thread = &self.thread;
let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
@@ -840,11 +1212,23 @@ impl AcpThreadView {
cx.notify();
}
+ pub fn allow_always(&mut self, _: &AllowAlways, window: &mut Window, cx: &mut Context<Self>) {
+ self.authorize_pending_tool_call(acp::PermissionOptionKind::AllowAlways, window, cx);
+ }
+
+ pub fn allow_once(&mut self, _: &AllowOnce, window: &mut Window, cx: &mut Context<Self>) {
+ self.authorize_pending_with_granularity(true, window, cx);
+ }
+
+ pub fn reject_once(&mut self, _: &RejectOnce, window: &mut Window, cx: &mut Context<Self>) {
+ self.authorize_pending_with_granularity(false, window, cx);
+ }
+
pub fn authorize_pending_tool_call(
&mut self,
kind: acp::PermissionOptionKind,
window: &mut Window,
- cx: &mut Context<AcpServerView>,
+ cx: &mut Context<Self>,
) -> Option<()> {
let thread = self.thread.read(cx);
let tool_call = thread.first_tool_awaiting_confirmation()?;
@@ -864,10 +1248,30 @@ impl AcpThreadView {
Some(())
}
+ fn handle_authorize_tool_call(
+ &mut self,
+ action: &AuthorizeToolCall,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let tool_call_id = acp::ToolCallId::new(action.tool_call_id.clone());
+ let option_id = acp::PermissionOptionId::new(action.option_id.clone());
+ let option_kind = match action.option_kind.as_str() {
+ "AllowOnce" => acp::PermissionOptionKind::AllowOnce,
+ "AllowAlways" => acp::PermissionOptionKind::AllowAlways,
+ "RejectOnce" => acp::PermissionOptionKind::RejectOnce,
+ "RejectAlways" => acp::PermissionOptionKind::RejectAlways,
+ _ => acp::PermissionOptionKind::AllowOnce,
+ };
+
+ self.authorize_tool_call(tool_call_id, option_id, option_kind, window, cx);
+ }
+
pub fn handle_select_permission_granularity(
&mut self,
action: &SelectPermissionGranularity,
- cx: &mut Context<AcpServerView>,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
) {
let tool_call_id = acp::ToolCallId::new(action.tool_call_id.clone());
self.selected_permission_granularity
@@ -876,9 +1280,57 @@ impl AcpThreadView {
cx.notify();
}
+ fn authorize_pending_with_granularity(
+ &mut self,
+ is_allow: bool,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Option<()> {
+ let thread = self.thread.read(cx);
+ let tool_call = thread.first_tool_awaiting_confirmation()?;
+ let ToolCallStatus::WaitingForConfirmation { options, .. } = &tool_call.status else {
+ return None;
+ };
+ let tool_call_id = tool_call.id.clone();
+
+ let PermissionOptions::Dropdown(choices) = options else {
+ let kind = if is_allow {
+ acp::PermissionOptionKind::AllowOnce
+ } else {
+ acp::PermissionOptionKind::RejectOnce
+ };
+ return self.authorize_pending_tool_call(kind, window, cx);
+ };
+
+ // Get selected index, defaulting to last option ("Only this time")
+ let selected_index = self
+ .selected_permission_granularity
+ .get(&tool_call_id)
+ .copied()
+ .unwrap_or_else(|| choices.len().saturating_sub(1));
+
+ let selected_choice = choices.get(selected_index).or(choices.last())?;
+
+ let selected_option = if is_allow {
+ &selected_choice.allow
+ } else {
+ &selected_choice.deny
+ };
+
+ self.authorize_tool_call(
+ tool_call_id,
+ selected_option.option_id.clone(),
+ selected_option.kind,
+ window,
+ cx,
+ );
+
+ Some(())
+ }
+
// edits
- pub fn keep_all(&mut self, cx: &mut Context<AcpServerView>) {
+ pub fn keep_all(&mut self, _: &KeepAll, _window: &mut Window, cx: &mut Context<Self>) {
let thread = &self.thread;
let telemetry = ActionLogTelemetry::from(thread.read(cx));
let action_log = thread.read(cx).action_log().clone();
@@ -158,7 +158,7 @@ pub fn init(cx: &mut App) {
thread_view
.read(cx)
.as_active_thread()
- .map(|r| r.thread.clone())
+ .map(|r| r.read(cx).thread.clone())
});
if let Some(thread) = thread {
@@ -916,12 +916,18 @@ impl AgentPanel {
}
fn expand_message_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- if let Some(thread_view) = self.active_thread_view() {
- thread_view.update(cx, |view, cx| {
- view.expand_message_editor(&ExpandMessageEditor, window, cx);
- view.focus_handle(cx).focus(window, cx);
- });
- }
+ let Some(thread_view) = self.active_thread_view() else {
+ return;
+ };
+
+ let Some(active_thread) = thread_view.read(cx).as_active_thread() else {
+ return;
+ };
+
+ active_thread.update(cx, |active_thread, cx| {
+ active_thread.expand_message_editor(&ExpandMessageEditor, window, cx);
+ active_thread.focus_handle(cx).focus(window, cx);
+ })
}
fn history_kind_for_selected_agent(&self, cx: &App) -> Option<HistoryKind> {
@@ -1185,22 +1191,15 @@ impl AgentPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
- let Some(workspace) = self.workspace.upgrade() else {
- return;
- };
-
- match &self.active_view {
- ActiveView::AgentThread { thread_view } => {
- thread_view
- .update(cx, |thread_view, cx| {
- thread_view.open_thread_as_markdown(workspace, window, cx)
- })
+ if let Some(workspace) = self.workspace.upgrade()
+ && let Some(thread_view) = self.active_thread_view()
+ && let Some(active_thread) = thread_view.read(cx).as_active_thread()
+ {
+ active_thread.update(cx, |thread, cx| {
+ thread
+ .open_thread_as_markdown(workspace, window, cx)
.detach_and_log_err(cx);
- }
- ActiveView::Uninitialized
- | ActiveView::TextThread { .. }
- | ActiveView::History { .. }
- | ActiveView::Configuration => {}
+ });
}
}
@@ -1421,7 +1420,7 @@ impl AgentPanel {
ActiveView::AgentThread { thread_view, .. } => thread_view
.read(cx)
.as_active_thread()
- .map(|r| r.thread.clone()),
+ .map(|r| r.read(cx).thread.clone()),
_ => None,
}
}
@@ -1849,7 +1848,7 @@ impl AgentPanel {
if let Some(title_editor) = thread_view
.read(cx)
.as_active_thread()
- .and_then(|ready| ready.title_editor.clone())
+ .and_then(|r| r.read(cx).title_editor.clone())
{
let container = div()
.w_full()
@@ -21,7 +21,6 @@ mod terminal_inline_assistant;
mod text_thread_editor;
mod text_thread_history;
mod ui;
-mod user_slash_command;
use std::rc::Rc;
use std::sync::Arc;
@@ -5,14 +5,11 @@ use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use crate::acp::AcpThreadHistory;
-use crate::user_slash_command::{self, CommandLoadError, UserSlashCommand};
use acp_thread::{AgentSessionInfo, MentionUri};
use anyhow::Result;
-use collections::{HashMap, HashSet};
use editor::{
CompletionProvider, Editor, ExcerptId, code_context_menus::COMPLETION_MENU_MAX_WIDTH,
};
-use feature_flags::{FeatureFlagAppExt as _, UserSlashCommandsFeatureFlag};
use futures::FutureExt as _;
use fuzzy::{PathMatch, StringMatch, StringMatchCandidate};
use gpui::{App, BackgroundExecutor, Entity, SharedString, Task, WeakEntity};
@@ -192,18 +189,6 @@ pub struct AvailableCommand {
pub name: Arc<str>,
pub description: Arc<str>,
pub requires_argument: bool,
- pub source: CommandSource,
-}
-
-/// The source of a slash command, used to differentiate UI behavior.
-#[derive(Debug, Clone, PartialEq)]
-pub enum CommandSource {
- /// Command provided by the ACP server
- Server,
- /// User-defined command from a markdown file
- UserDefined { template: Arc<str> },
- /// User-defined command that failed to load
- UserDefinedError { error_message: Arc<str> },
}
pub trait PromptCompletionProviderDelegate: Send + Sync + 'static {
@@ -215,18 +200,6 @@ pub trait PromptCompletionProviderDelegate: Send + Sync + 'static {
fn available_commands(&self, cx: &App) -> Vec<AvailableCommand>;
fn confirm_command(&self, cx: &mut App);
-
- /// Returns cached user-defined slash commands, if available.
- /// Default implementation returns None, meaning commands will be loaded from disk.
- fn cached_user_commands(&self, _cx: &App) -> Option<HashMap<String, UserSlashCommand>> {
- None
- }
-
- /// Returns cached errors from loading user-defined slash commands, if available.
- /// Default implementation returns None.
- fn cached_user_command_errors(&self, _cx: &App) -> Option<Vec<CommandLoadError>> {
- None
- }
}
pub struct PromptCompletionProvider<T: PromptCompletionProviderDelegate> {
@@ -803,111 +776,11 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
fn search_slash_commands(&self, query: String, cx: &mut App) -> Task<Vec<AvailableCommand>> {
let commands = self.source.available_commands(cx);
- let server_command_names = commands
- .iter()
- .map(|command| command.name.as_ref().to_string())
- .collect::<HashSet<_>>();
-
- // Try to use cached user commands and errors first
- let cached_user_commands = if cx.has_flag::<UserSlashCommandsFeatureFlag>() {
- self.source.cached_user_commands(cx)
- } else {
- None
- };
-
- let cached_user_command_errors = if cx.has_flag::<UserSlashCommandsFeatureFlag>() {
- self.source.cached_user_command_errors(cx)
- } else {
- None
- };
-
- // Get fs and worktree roots for async command loading (only if not cached)
- let (fs, worktree_roots) =
- if cached_user_commands.is_none() && cx.has_flag::<UserSlashCommandsFeatureFlag>() {
- let workspace = self.workspace.upgrade();
- let fs = workspace
- .as_ref()
- .map(|w| w.read(cx).project().read(cx).fs().clone());
- let roots: Vec<std::path::PathBuf> = workspace
- .map(|workspace| {
- workspace
- .read(cx)
- .visible_worktrees(cx)
- .map(|worktree| worktree.read(cx).abs_path().to_path_buf())
- .collect()
- })
- .unwrap_or_default();
- (fs, roots)
- } else {
- (None, Vec::new())
- };
+ if commands.is_empty() {
+ return Task::ready(Vec::new());
+ }
cx.spawn(async move |cx| {
- let mut commands = commands;
-
- // Use cached commands/errors if available, otherwise load from disk
- let (mut user_commands, mut user_command_errors): (
- Vec<UserSlashCommand>,
- Vec<CommandLoadError>,
- ) = if let Some(cached) = cached_user_commands {
- let errors = cached_user_command_errors.unwrap_or_default();
- (cached.into_values().collect(), errors)
- } else if let Some(fs) = fs {
- let load_result =
- crate::user_slash_command::load_all_commands_async(&fs, &worktree_roots).await;
-
- (load_result.commands, load_result.errors)
- } else {
- (Vec::new(), Vec::new())
- };
-
- user_slash_command::apply_server_command_conflicts(
- &mut user_commands,
- &mut user_command_errors,
- &server_command_names,
- );
-
- let conflicting_names: HashSet<String> = user_command_errors
- .iter()
- .filter_map(|error| error.command_name())
- .filter(|name| server_command_names.contains(name))
- .collect();
-
- if !conflicting_names.is_empty() {
- commands.retain(|command| !conflicting_names.contains(command.name.as_ref()));
- }
-
- for cmd in user_commands {
- commands.push(AvailableCommand {
- name: cmd.name.clone(),
- description: cmd.description().into(),
- requires_argument: cmd.requires_arguments(),
- source: CommandSource::UserDefined {
- template: cmd.template.clone(),
- },
- });
- }
-
- // Add errored commands so they show up in autocomplete with error indication.
- // Errors for commands that don't match the query will be silently ignored here
- // since the user will see them via the error callout in the thread view.
- for error in user_command_errors {
- if let Some(name) = error.command_name() {
- commands.push(AvailableCommand {
- name: name.into(),
- description: "".into(),
- requires_argument: false,
- source: CommandSource::UserDefinedError {
- error_message: error.message.into(),
- },
- });
- }
- }
-
- if commands.is_empty() {
- return Vec::new();
- }
-
let candidates = commands
.iter()
.enumerate()
@@ -1264,20 +1137,7 @@ impl<T: PromptCompletionProviderDelegate> CompletionProvider for PromptCompletio
.await
.into_iter()
.map(|command| {
- let is_error =
- matches!(command.source, CommandSource::UserDefinedError { .. });
-
- // For errored commands, show the name with "(load error)" suffix
- let label_text = if is_error {
- format!("{} (load error)", command.name)
- } else {
- command.name.to_string()
- };
-
- // For errored commands, we don't want to insert anything useful
- let new_text = if is_error {
- format!("/{}", command.name)
- } else if let Some(argument) = argument.as_ref() {
+ let new_text = if let Some(argument) = argument.as_ref() {
format!("/{} {}", command.name, argument)
} else {
format!("/{} ", command.name)
@@ -1286,72 +1146,21 @@ impl<T: PromptCompletionProviderDelegate> CompletionProvider for PromptCompletio
let is_missing_argument =
command.requires_argument && argument.is_none();
- // For errored commands, use a deprecated-style label to indicate the error
- let label = if is_error {
- // Create a label where the command name portion has a highlight
- // that will be rendered with strikethrough by the completion menu
- // (similar to deprecated LSP completions)
- CodeLabel::plain(label_text, None)
- } else {
- CodeLabel::plain(label_text, None)
- };
-
- // For errored commands, show the error message in documentation
- let documentation =
- if let CommandSource::UserDefinedError { error_message } =
- &command.source
- {
- Some(CompletionDocumentation::MultiLinePlainText(
- error_message.to_string().into(),
- ))
- } else if !command.description.is_empty() {
- Some(CompletionDocumentation::MultiLinePlainText(
- command.description.to_string().into(),
- ))
- } else {
- None
- };
-
- // For errored commands, use a red X icon
- let icon_path = if is_error {
- Some(IconName::XCircle.path().into())
- } else {
- None
- };
-
Completion {
replace_range: source_range.clone(),
new_text,
- label,
- documentation,
- source: if is_error {
- // Use a custom source that marks this as deprecated/errored
- // so the completion menu renders it with strikethrough
- project::CompletionSource::Lsp {
- insert_range: None,
- server_id: language::LanguageServerId(0),
- lsp_completion: Box::new(lsp::CompletionItem {
- label: command.name.to_string(),
- deprecated: Some(true),
- ..Default::default()
- }),
- lsp_defaults: None,
- resolved: true,
- }
- } else {
- project::CompletionSource::Custom
- },
- icon_path,
+ label: CodeLabel::plain(command.name.to_string(), None),
+ documentation: Some(CompletionDocumentation::MultiLinePlainText(
+ command.description.into(),
+ )),
+ source: project::CompletionSource::Custom,
+ icon_path: None,
match_start: None,
snippet_deduplication_key: None,
insert_text_mode: None,
confirm: Some(Arc::new({
let source = source.clone();
move |intent, _window, cx| {
- // Don't confirm errored commands
- if is_error {
- return false;
- }
if !is_missing_argument {
cx.defer({
let source = source.clone();
@@ -1,5 +1,4 @@
use crate::{
- agent_panel::AgentType,
language_model_selector::{LanguageModelSelector, language_model_selector},
ui::ModelSelectorTooltip,
};
@@ -11,7 +10,7 @@ use collections::{BTreeSet, HashMap, HashSet, hash_map};
use editor::{
Anchor, Editor, EditorEvent, MenuEditPredictionsPolicy, MultiBuffer, MultiBufferOffset,
MultiBufferSnapshot, RowExt, ToOffset as _, ToPoint as _,
- actions::{MoveToEndOfLine, Newline, SendReviewToAgent, ShowCompletions},
+ actions::{MoveToEndOfLine, Newline, ShowCompletions},
display_map::{
BlockPlacement, BlockProperties, BlockStyle, Crease, CreaseMetadata, CustomBlockId, FoldId,
RenderBlock, ToDisplayPoint,
@@ -228,8 +227,7 @@ impl TextThreadEditor {
.register_action(TextThreadEditor::quote_selection)
.register_action(TextThreadEditor::insert_selection)
.register_action(TextThreadEditor::copy_code)
- .register_action(TextThreadEditor::handle_insert_dragged_files)
- .register_action(TextThreadEditor::handle_send_review_to_agent);
+ .register_action(TextThreadEditor::handle_insert_dragged_files);
},
)
.detach();
@@ -1521,159 +1519,6 @@ impl TextThreadEditor {
}
}
- /// Handles the SendReviewToAgent action from the ProjectDiff toolbar.
- /// Collects ALL stored review comments from ALL hunks and sends them
- /// to the Agent panel as creases.
- pub fn handle_send_review_to_agent(
- workspace: &mut Workspace,
- _: &SendReviewToAgent,
- window: &mut Window,
- cx: &mut Context<Workspace>,
- ) {
- use editor::{DiffHunkKey, StoredReviewComment};
- use git_ui::project_diff::ProjectDiff;
-
- // Find the ProjectDiff item
- let Some(project_diff) = workspace.items_of_type::<ProjectDiff>(cx).next() else {
- workspace.show_toast(
- Toast::new(
- NotificationId::unique::<SendReviewToAgent>(),
- "No Project Diff panel found. Open it first to add review comments.",
- ),
- cx,
- );
- return;
- };
-
- // Get the buffer reference first (before taking comments)
- let buffer = project_diff.update(cx, |project_diff, cx| {
- project_diff
- .editor()
- .read(cx)
- .rhs_editor()
- .read(cx)
- .buffer()
- .clone()
- });
-
- // Extract all stored comments from all hunks
- let all_comments: Vec<(DiffHunkKey, Vec<StoredReviewComment>)> =
- project_diff.update(cx, |project_diff, cx| {
- let editor = project_diff.editor().read(cx).rhs_editor().clone();
- editor.update(cx, |editor, cx| editor.take_all_review_comments(cx))
- });
-
- // Flatten: we have Vec<(DiffHunkKey, Vec<StoredReviewComment>)>
- // Convert to Vec<StoredReviewComment> for processing
- let comments: Vec<StoredReviewComment> = all_comments
- .into_iter()
- .flat_map(|(_, comments)| comments)
- .collect();
-
- if comments.is_empty() {
- workspace.show_toast(
- Toast::new(
- NotificationId::unique::<SendReviewToAgent>(),
- "No review comments to send. Add comments using the + button in the diff view.",
- ),
- cx,
- );
- return;
- }
-
- // Get or create the agent panel
- let Some(panel) = workspace.panel::<crate::AgentPanel>(cx) else {
- workspace.show_toast(
- Toast::new(
- NotificationId::unique::<SendReviewToAgent>(),
- "Agent panel is not available.",
- ),
- cx,
- );
- return;
- };
-
- // Create a new thread if there isn't an active one (synchronous call)
- let has_active_thread = panel.read(cx).active_thread_view().is_some();
- if !has_active_thread {
- panel.update(cx, |panel, cx| {
- panel.new_agent_thread(AgentType::NativeAgent, window, cx);
- });
- }
-
- // Focus the agent panel
- workspace.focus_panel::<crate::AgentPanel>(window, cx);
-
- // Defer inserting creases until after the current update cycle completes,
- // allowing the newly created thread (if any) to fully initialize.
- cx.defer_in(window, move |workspace, window, cx| {
- let Some(panel) = workspace.panel::<crate::AgentPanel>(cx) else {
- workspace.show_toast(
- Toast::new(
- NotificationId::unique::<SendReviewToAgent>(),
- "Agent panel closed unexpectedly.",
- ),
- cx,
- );
- return;
- };
-
- let thread_view = panel.read(cx).active_thread_view().cloned();
- let Some(thread_view) = thread_view else {
- workspace.show_toast(
- Toast::new(
- NotificationId::unique::<SendReviewToAgent>(),
- "No active thread view available after creating thread.",
- ),
- cx,
- );
- return;
- };
-
- // Build creases for all comments, grouping by code snippet
- // so each snippet appears once with all its comments
- let snapshot = buffer.read(cx).snapshot(cx);
-
- // Group comments by their point range (code snippet)
- let mut comments_by_range: std::collections::BTreeMap<
- (rope::Point, rope::Point),
- Vec<String>,
- > = std::collections::BTreeMap::new();
-
- for comment in comments {
- let start = comment.range.start.to_point(&snapshot);
- let end = comment.range.end.to_point(&snapshot);
- comments_by_range
- .entry((start, end))
- .or_default()
- .push(comment.comment);
- }
-
- // Build one crease per unique code snippet with all its comments
- let mut all_creases = Vec::new();
- for ((start, end), comment_texts) in comments_by_range {
- let point_range = start..end;
-
- let mut creases =
- selections_creases(vec![point_range.clone()], snapshot.clone(), cx);
-
- // Append all comments after the code snippet
- for (code_text, crease_title) in &mut creases {
- let comments_section = comment_texts.join("\n\n");
- *code_text = format!("{}\n\n{}", code_text, comments_section);
- *crease_title = format!("Review: {}", crease_title);
- }
-
- all_creases.extend(creases);
- }
-
- // Insert all creases into the message editor
- thread_view.update(cx, |thread_view, cx| {
- thread_view.insert_code_crease(all_creases, window, cx);
- });
- });
- }
-
pub fn quote_ranges(
&mut self,
ranges: Vec<Range<Point>>,
@@ -1,2118 +0,0 @@
-use anyhow::{Result, anyhow};
-use collections::{HashMap, HashSet};
-use fs::Fs;
-use futures::StreamExt;
-use gpui::{Context, EventEmitter, Task};
-use std::borrow::Cow;
-use std::path::{Path, PathBuf};
-use std::sync::Arc;
-use std::time::Duration;
-
-/// An error that occurred while loading a command file.
-#[derive(Debug, Clone)]
-pub struct CommandLoadError {
- /// The path to the file that failed to load
- pub path: PathBuf,
- /// The base path of the commands directory (used to derive command name)
- pub base_path: PathBuf,
- /// A description of the error
- pub message: String,
-}
-
-impl CommandLoadError {
- /// Derives the command name from the file path, similar to how successful commands are named.
- /// Returns None if the command name cannot be determined (e.g., for directory errors).
- pub fn command_name(&self) -> Option<String> {
- let base_name = self.path.file_stem()?.to_string_lossy().into_owned();
-
- // Only derive command name for .md files
- if self.path.extension().is_none_or(|ext| ext != "md") {
- return None;
- }
-
- let namespace = self
- .path
- .parent()
- .and_then(|parent| parent.strip_prefix(&self.base_path).ok())
- .filter(|rel| !rel.as_os_str().is_empty())
- .map(|rel| {
- rel.to_string_lossy()
- .replace(std::path::MAIN_SEPARATOR, "/")
- });
-
- let name = match &namespace {
- Some(namespace) => format!("{}:{}", namespace.replace('/', ":"), base_name),
- None => base_name,
- };
-
- Some(name)
- }
-}
-
-impl std::fmt::Display for CommandLoadError {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- write!(
- f,
- "Failed to load {}: {}",
- self.path.display(),
- self.message
- )
- }
-}
-
-/// Result of loading commands, including any errors encountered.
-#[derive(Debug, Default, Clone)]
-pub struct CommandLoadResult {
- /// Successfully loaded commands
- pub commands: Vec<UserSlashCommand>,
- /// Errors encountered while loading commands
- pub errors: Vec<CommandLoadError>,
-}
-
-/// The scope of a user-defined slash command.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum CommandScope {
- /// Project-specific command from .zed/commands/
- Project,
- /// User-wide command from config_dir()/commands/
- User,
-}
-
-/// A user-defined slash command loaded from a markdown file.
-#[derive(Debug, Clone, PartialEq)]
-pub struct UserSlashCommand {
- /// The command name for invocation.
- /// For commands in subdirectories, this is prefixed: "namespace:name" (e.g., "frontend:component")
- /// For commands in the root, this is just the filename without .md extension.
- pub name: Arc<str>,
- /// The template content from the file
- pub template: Arc<str>,
- /// The namespace (subdirectory path, if any), used for description display
- pub namespace: Option<Arc<str>>,
- /// The full path to the command file
- pub path: PathBuf,
- /// Whether this is a project or user command
- pub scope: CommandScope,
-}
-
-impl UserSlashCommand {
- /// Returns a description string for display in completions.
- pub fn description(&self) -> String {
- String::new()
- }
-
- /// Returns true if this command has any placeholders ($1, $2, etc. or $ARGUMENTS)
- pub fn requires_arguments(&self) -> bool {
- has_placeholders(&self.template)
- }
-}
-
-fn command_base_path(command: &UserSlashCommand) -> PathBuf {
- let mut base_path = command.path.clone();
- base_path.pop();
- if let Some(namespace) = &command.namespace {
- for segment in namespace.split('/') {
- if segment.is_empty() {
- continue;
- }
- if !base_path.pop() {
- break;
- }
- }
- }
- base_path
-}
-
-impl CommandLoadError {
- pub fn from_command(command: &UserSlashCommand, message: String) -> Self {
- Self {
- path: command.path.clone(),
- base_path: command_base_path(command),
- message,
- }
- }
-}
-
-/// Parsed user command from input text
-#[derive(Debug, Clone, PartialEq)]
-pub struct ParsedUserCommand<'a> {
- pub name: &'a str,
- pub raw_arguments: &'a str,
-}
-
-/// Returns the path to the user commands directory.
-pub fn user_commands_dir() -> PathBuf {
- paths::config_dir().join("commands")
-}
-
-/// Returns the path to the project commands directory for a given worktree root.
-pub fn project_commands_dir(worktree_root: &Path) -> PathBuf {
- worktree_root.join(".zed").join("commands")
-}
-
-/// Events emitted by SlashCommandRegistry
-#[derive(Debug, Clone)]
-#[allow(dead_code)] // Infrastructure for future caching implementation
-pub enum SlashCommandRegistryEvent {
- /// Commands have been reloaded
- CommandsChanged,
-}
-
-/// A registry that caches user-defined slash commands and watches for changes.
-/// Currently used in tests; will be integrated into the UI layer for caching.
-#[allow(dead_code)]
-pub struct SlashCommandRegistry {
- fs: Arc<dyn Fs>,
- commands: HashMap<String, UserSlashCommand>,
- errors: Vec<CommandLoadError>,
- worktree_roots: Vec<PathBuf>,
- _watch_task: Option<Task<()>>,
-}
-
-impl EventEmitter<SlashCommandRegistryEvent> for SlashCommandRegistry {}
-
-#[allow(dead_code)]
-impl SlashCommandRegistry {
- /// Creates a new registry and starts loading commands.
- pub fn new(fs: Arc<dyn Fs>, worktree_roots: Vec<PathBuf>, cx: &mut Context<Self>) -> Self {
- let mut this = Self {
- fs,
- commands: HashMap::default(),
- errors: Vec::new(),
- worktree_roots,
- _watch_task: None,
- };
-
- this.start_watching(cx);
- this.reload(cx);
-
- this
- }
-
- /// Returns all loaded commands.
- pub fn commands(&self) -> &HashMap<String, UserSlashCommand> {
- &self.commands
- }
-
- /// Returns any errors from the last load.
- pub fn errors(&self) -> &[CommandLoadError] {
- &self.errors
- }
-
- /// Updates the worktree roots and reloads commands.
- pub fn set_worktree_roots(&mut self, roots: Vec<PathBuf>, cx: &mut Context<Self>) {
- if self.worktree_roots != roots {
- self.worktree_roots = roots;
- self.start_watching(cx);
- self.reload(cx);
- }
- }
-
- /// Manually triggers a reload of all commands.
- pub fn reload(&mut self, cx: &mut Context<Self>) {
- let fs = self.fs.clone();
- let worktree_roots = self.worktree_roots.clone();
-
- cx.spawn(async move |this, cx| {
- let result = load_all_commands_async(&fs, &worktree_roots).await;
- this.update(cx, |this, cx| {
- this.commands = commands_to_map(&result.commands);
- this.errors = result.errors;
- cx.emit(SlashCommandRegistryEvent::CommandsChanged);
- })
- })
- .detach_and_log_err(cx);
- }
-
- fn start_watching(&mut self, cx: &mut Context<Self>) {
- let fs = self.fs.clone();
- let worktree_roots = self.worktree_roots.clone();
-
- let task = cx.spawn(async move |this, cx| {
- let user_dir = user_commands_dir();
- let mut dirs_to_watch = vec![user_dir];
- for root in &worktree_roots {
- dirs_to_watch.push(project_commands_dir(root));
- }
-
- let mut watch_streams = Vec::new();
- for dir in &dirs_to_watch {
- let (stream, _watcher) = fs.watch(dir, Duration::from_millis(100)).await;
- watch_streams.push(stream);
- }
-
- let mut combined = futures::stream::select_all(watch_streams);
-
- while let Some(events) = combined.next().await {
- let should_reload = events.iter().any(|event| {
- event.path.extension().is_some_and(|ext| ext == "md")
- || event.kind == Some(fs::PathEventKind::Created)
- || event.kind == Some(fs::PathEventKind::Removed)
- });
-
- if should_reload {
- let result = load_all_commands_async(&fs, &worktree_roots).await;
- let _ = this.update(cx, |this, cx| {
- this.commands = commands_to_map(&result.commands);
- this.errors = result.errors;
- cx.emit(SlashCommandRegistryEvent::CommandsChanged);
- });
- }
- }
- });
-
- self._watch_task = Some(task);
- }
-}
-
-/// Loads all commands (both project and user) for given worktree roots asynchronously.
-pub async fn load_all_commands_async(
- fs: &Arc<dyn Fs>,
- worktree_roots: &[PathBuf],
-) -> CommandLoadResult {
- let mut result = CommandLoadResult::default();
- let mut seen_commands: HashMap<String, PathBuf> = HashMap::default();
-
- // Load project commands first
- for root in worktree_roots {
- let commands_path = project_commands_dir(root);
- let project_result =
- load_commands_from_path_async(fs, &commands_path, CommandScope::Project).await;
- result.errors.extend(project_result.errors);
- for cmd in project_result.commands {
- if let Some(existing_path) = seen_commands.get(&*cmd.name) {
- result.errors.push(CommandLoadError {
- path: cmd.path.clone(),
- base_path: commands_path.clone(),
- message: format!(
- "Command '{}' is ambiguous: also defined at {}",
- cmd.name,
- existing_path.display()
- ),
- });
- } else {
- seen_commands.insert(cmd.name.to_string(), cmd.path.clone());
- result.commands.push(cmd);
- }
- }
- }
-
- // Load user commands
- let user_commands_path = user_commands_dir();
- let user_result =
- load_commands_from_path_async(fs, &user_commands_path, CommandScope::User).await;
- result.errors.extend(user_result.errors);
- for cmd in user_result.commands {
- if let Some(existing_path) = seen_commands.get(&*cmd.name) {
- result.errors.push(CommandLoadError {
- path: cmd.path.clone(),
- base_path: user_commands_path.clone(),
- message: format!(
- "Command '{}' is ambiguous: also defined at {}",
- cmd.name,
- existing_path.display()
- ),
- });
- } else {
- seen_commands.insert(cmd.name.to_string(), cmd.path.clone());
- result.commands.push(cmd);
- }
- }
-
- result
-}
-
-async fn load_commands_from_path_async(
- fs: &Arc<dyn Fs>,
- commands_path: &Path,
- scope: CommandScope,
-) -> CommandLoadResult {
- let mut result = CommandLoadResult::default();
-
- if !fs.is_dir(commands_path).await {
- return result;
- }
-
- load_commands_from_dir_async(fs, commands_path, commands_path, scope, &mut result).await;
- result
-}
-
-fn load_commands_from_dir_async<'a>(
- fs: &'a Arc<dyn Fs>,
- base_path: &'a Path,
- current_path: &'a Path,
- scope: CommandScope,
- result: &'a mut CommandLoadResult,
-) -> futures::future::BoxFuture<'a, ()> {
- Box::pin(async move {
- let entries = match fs.read_dir(current_path).await {
- Ok(entries) => entries,
- Err(e) => {
- result.errors.push(CommandLoadError {
- path: current_path.to_path_buf(),
- base_path: base_path.to_path_buf(),
- message: format!("Failed to read directory: {}", e),
- });
- return;
- }
- };
-
- let entries: Vec<_> = entries.collect().await;
-
- for entry in entries {
- let path = match entry {
- Ok(path) => path,
- Err(e) => {
- result.errors.push(CommandLoadError {
- path: current_path.to_path_buf(),
- base_path: base_path.to_path_buf(),
- message: format!("Failed to read directory entry: {}", e),
- });
- continue;
- }
- };
-
- if fs.is_dir(&path).await {
- load_commands_from_dir_async(fs, base_path, &path, scope, result).await;
- } else if path.extension().is_some_and(|ext| ext == "md") {
- match load_command_file_async(fs, base_path, &path, scope).await {
- Ok(Some(command)) => result.commands.push(command),
- Ok(None) => {} // Empty file, skip silently
- Err(e) => {
- result.errors.push(CommandLoadError {
- path: path.clone(),
- base_path: base_path.to_path_buf(),
- message: e.to_string(),
- });
- }
- }
- }
- }
- })
-}
-
-async fn load_command_file_async(
- fs: &Arc<dyn Fs>,
- base_path: &Path,
- file_path: &Path,
- scope: CommandScope,
-) -> Result<Option<UserSlashCommand>> {
- let base_name = match file_path.file_stem() {
- Some(stem) => stem.to_string_lossy().into_owned(),
- None => return Ok(None),
- };
-
- let template = fs.load(file_path).await?;
- if template.is_empty() {
- return Ok(None);
- }
- if template.trim().is_empty() {
- return Err(anyhow!("Command file contains only whitespace"));
- }
-
- let namespace = file_path
- .parent()
- .and_then(|parent| parent.strip_prefix(base_path).ok())
- .filter(|rel| !rel.as_os_str().is_empty())
- .map(|rel| {
- rel.to_string_lossy()
- .replace(std::path::MAIN_SEPARATOR, "/")
- });
-
- // Build the full command name: "namespace:basename" or just "basename"
- let name = match &namespace {
- Some(namespace) => format!("{}:{}", namespace.replace('/', ":"), base_name),
- None => base_name,
- };
-
- Ok(Some(UserSlashCommand {
- name: name.into(),
- template: template.into(),
- namespace: namespace.map(|s| s.into()),
- path: file_path.to_path_buf(),
- scope,
- }))
-}
-
-/// Converts a list of UserSlashCommand to a HashMap for quick lookup.
-/// The key is the command name.
-pub fn commands_to_map(commands: &[UserSlashCommand]) -> HashMap<String, UserSlashCommand> {
- let mut map = HashMap::default();
- for cmd in commands {
- map.insert(cmd.name.to_string(), cmd.clone());
- }
- map
-}
-
-fn has_error_for_command(errors: &[CommandLoadError], name: &str) -> bool {
- errors
- .iter()
- .any(|error| error.command_name().as_deref() == Some(name))
-}
-
-fn server_conflict_message(name: &str) -> String {
- format!(
- "Command '{}' conflicts with server-provided /{}",
- name, name
- )
-}
-
-pub fn apply_server_command_conflicts(
- commands: &mut Vec<UserSlashCommand>,
- errors: &mut Vec<CommandLoadError>,
- server_command_names: &HashSet<String>,
-) {
- commands.retain(|command| {
- if server_command_names.contains(command.name.as_ref()) {
- if !has_error_for_command(errors, command.name.as_ref()) {
- errors.push(CommandLoadError::from_command(
- command,
- server_conflict_message(command.name.as_ref()),
- ));
- }
- false
- } else {
- true
- }
- });
-}
-
-pub fn apply_server_command_conflicts_to_map(
- commands: &mut HashMap<String, UserSlashCommand>,
- errors: &mut Vec<CommandLoadError>,
- server_command_names: &HashSet<String>,
-) {
- commands.retain(|name, command| {
- if server_command_names.contains(name) {
- if !has_error_for_command(errors, name) {
- errors.push(CommandLoadError::from_command(
- command,
- server_conflict_message(name),
- ));
- }
- false
- } else {
- true
- }
- });
-}
-
-/// Parses a line of input to extract a user command invocation.
-/// Returns None if the line doesn't start with a slash command.
-pub fn try_parse_user_command(line: &str) -> Option<ParsedUserCommand<'_>> {
- let line = line.trim_start();
- if !line.starts_with('/') {
- return None;
- }
-
- let after_slash = &line[1..];
- let (name, raw_arguments) = if let Some(space_idx) = after_slash.find(char::is_whitespace) {
- let name = &after_slash[..space_idx];
- let rest = &after_slash[space_idx..].trim_start();
- (name, *rest)
- } else {
- (after_slash, "")
- };
-
- if name.is_empty() {
- return None;
- }
-
- Some(ParsedUserCommand {
- name,
- raw_arguments,
- })
-}
-
-/// Parses command arguments, supporting quoted strings.
-/// - Unquoted arguments are space-separated
-/// - Quoted arguments can contain spaces: "multi word arg"
-/// - Escape sequences: \" for literal quote, \\ for backslash, \n for newline
-pub fn parse_arguments(input: &str) -> Result<Vec<Cow<'_, str>>> {
- let mut arguments = Vec::new();
- let mut chars = input.char_indices().peekable();
-
- while let Some((start_idx, c)) = chars.next() {
- if c.is_whitespace() {
- continue;
- }
-
- if c == '"' {
- let mut result = String::new();
- let mut closed = false;
-
- while let Some((_, ch)) = chars.next() {
- if ch == '\\' {
- if let Some((_, next_ch)) = chars.next() {
- match next_ch {
- '"' => result.push('"'),
- '\\' => result.push('\\'),
- 'n' => result.push('\n'),
- other => {
- return Err(anyhow!("Unknown escape sequence: \\{}", other));
- }
- }
- } else {
- return Err(anyhow!("Unexpected end of input after backslash"));
- }
- } else if ch == '"' {
- closed = true;
- break;
- } else {
- result.push(ch);
- }
- }
-
- if !closed {
- return Err(anyhow!("Unclosed quote in command arguments"));
- }
-
- arguments.push(Cow::Owned(result));
- } else {
- let mut end_idx = start_idx + c.len_utf8();
- while let Some(&(idx, ch)) = chars.peek() {
- if ch.is_whitespace() {
- break;
- }
- if ch == '"' {
- return Err(anyhow!("Quote in middle of unquoted argument"));
- }
- end_idx = idx + ch.len_utf8();
- chars.next();
- }
-
- arguments.push(Cow::Borrowed(&input[start_idx..end_idx]));
- }
- }
-
- Ok(arguments)
-}
-
-/// Checks if a template has any placeholders ($1, $2, etc. or $ARGUMENTS)
-pub fn has_placeholders(template: &str) -> bool {
- count_positional_placeholders(template) > 0 || template.contains("$ARGUMENTS")
-}
-
-/// Counts the highest positional placeholder number in the template.
-/// For example, "$1 and $3" returns 3.
-pub fn count_positional_placeholders(template: &str) -> usize {
- let mut max_placeholder = 0;
- let mut chars = template.chars().peekable();
-
- while let Some(c) = chars.next() {
- if c == '\\' {
- chars.next();
- continue;
- }
- if c == '$' {
- let mut num_str = String::new();
- while let Some(&next_c) = chars.peek() {
- if next_c.is_ascii_digit() {
- num_str.push(next_c);
- chars.next();
- } else {
- break;
- }
- }
- if !num_str.is_empty() {
- if let Ok(n) = num_str.parse::<usize>() {
- max_placeholder = max_placeholder.max(n);
- }
- }
- }
- }
-
- max_placeholder
-}
-
-/// Validates that arguments match the template's placeholders.
-/// Templates can use $ARGUMENTS (all args as one string) or $1, $2, etc. (positional).
-pub fn validate_arguments(
- command_name: &str,
- template: &str,
- arguments: &[Cow<'_, str>],
-) -> Result<()> {
- if template.is_empty() {
- return Err(anyhow!("Template cannot be empty"));
- }
-
- let has_arguments_placeholder = template.contains("$ARGUMENTS");
- let positional_count = count_positional_placeholders(template);
-
- if has_arguments_placeholder {
- // $ARGUMENTS accepts any number of arguments (including zero)
- // But if there are also positional placeholders, validate those
- if positional_count > 0 && arguments.len() < positional_count {
- return Err(anyhow!(
- "The /{} command requires {} positional {}, but only {} {} provided",
- command_name,
- positional_count,
- if positional_count == 1 {
- "argument"
- } else {
- "arguments"
- },
- arguments.len(),
- if arguments.len() == 1 { "was" } else { "were" }
- ));
- }
- return Ok(());
- }
-
- if positional_count == 0 && !arguments.is_empty() {
- return Err(anyhow!(
- "The /{} command accepts no arguments, but {} {} provided",
- command_name,
- arguments.len(),
- if arguments.len() == 1 { "was" } else { "were" }
- ));
- }
-
- if arguments.len() < positional_count {
- return Err(anyhow!(
- "The /{} command requires {} {}, but only {} {} provided",
- command_name,
- positional_count,
- if positional_count == 1 {
- "argument"
- } else {
- "arguments"
- },
- arguments.len(),
- if arguments.len() == 1 { "was" } else { "were" }
- ));
- }
-
- if arguments.len() > positional_count {
- return Err(anyhow!(
- "The /{} command accepts {} {}, but {} {} provided",
- command_name,
- positional_count,
- if positional_count == 1 {
- "argument"
- } else {
- "arguments"
- },
- arguments.len(),
- if arguments.len() == 1 { "was" } else { "were" }
- ));
- }
-
- Ok(())
-}
-
-/// Expands a template by substituting placeholders with arguments.
-/// - $ARGUMENTS is replaced with all arguments as a single string
-/// - $1, $2, etc. are replaced with positional arguments
-/// - \$ produces literal $, \" produces literal ", \n produces newline
-pub fn expand_template(
- template: &str,
- arguments: &[Cow<'_, str>],
- raw_arguments: &str,
-) -> Result<String> {
- let mut result = String::with_capacity(template.len());
- let mut chars = template.char_indices().peekable();
-
- while let Some((_, c)) = chars.next() {
- if c == '\\' {
- if let Some((_, next_c)) = chars.next() {
- match next_c {
- '$' => result.push('$'),
- '"' => result.push('"'),
- '\\' => result.push('\\'),
- 'n' => result.push('\n'),
- other => {
- return Err(anyhow!("Unknown escape sequence: \\{}", other));
- }
- }
- }
- } else if c == '$' {
- // Check for $ARGUMENTS first
- let remaining: String = chars.clone().map(|(_, c)| c).collect();
- if remaining.starts_with("ARGUMENTS") {
- result.push_str(raw_arguments);
- // Skip "ARGUMENTS"
- for _ in 0..9 {
- chars.next();
- }
- } else {
- // Check for positional placeholder $N
- let mut num_str = String::new();
- while let Some(&(_, next_c)) = chars.peek() {
- if next_c.is_ascii_digit() {
- num_str.push(next_c);
- chars.next();
- } else {
- break;
- }
- }
- if !num_str.is_empty() {
- let n: usize = num_str.parse()?;
- if n == 0 {
- return Err(anyhow!(
- "Placeholder $0 is invalid; placeholders start at $1"
- ));
- }
- if let Some(arg) = arguments.get(n - 1) {
- result.push_str(arg);
- } else {
- return Err(anyhow!("Missing argument for placeholder ${}", n));
- }
- } else {
- result.push('$');
- }
- }
- } else {
- result.push(c);
- }
- }
-
- Ok(result)
-}
-
-/// Expands a user slash command, validating arguments and performing substitution.
-pub fn expand_user_slash_command(
- command_name: &str,
- template: &str,
- arguments: &[Cow<'_, str>],
- raw_arguments: &str,
-) -> Result<String> {
- validate_arguments(command_name, template, arguments)?;
- expand_template(template, arguments, raw_arguments)
-}
-
-/// Attempts to expand a user slash command from input text.
-/// Returns Ok(None) if the input is not a user command or the command doesn't exist.
-/// Returns Err if the command exists but expansion fails (e.g., missing arguments).
-pub fn try_expand_from_commands(
- line: &str,
- commands: &HashMap<String, UserSlashCommand>,
-) -> Result<Option<String>> {
- let Some(parsed) = try_parse_user_command(line) else {
- return Ok(None);
- };
-
- let Some(command) = commands.get(parsed.name) else {
- return Ok(None);
- };
-
- let arguments = parse_arguments(parsed.raw_arguments)?;
- let expanded = expand_user_slash_command(
- parsed.name,
- &command.template,
- &arguments,
- parsed.raw_arguments,
- )?;
- Ok(Some(expanded))
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use fs::{FakeFs, Fs, RemoveOptions};
- use gpui::{AppContext as _, TestAppContext};
- use serde_json::json;
- use std::sync::Arc;
- use text::Rope;
- use util::path;
-
- // ==================== Parsing Tests ====================
-
- #[test]
- fn test_try_parse_user_command() {
- assert_eq!(
- try_parse_user_command("/review"),
- Some(ParsedUserCommand {
- name: "review",
- raw_arguments: ""
- })
- );
-
- assert_eq!(
- try_parse_user_command("/review arg1 arg2"),
- Some(ParsedUserCommand {
- name: "review",
- raw_arguments: "arg1 arg2"
- })
- );
-
- assert_eq!(
- try_parse_user_command("/cmd \"multi word\" simple"),
- Some(ParsedUserCommand {
- name: "cmd",
- raw_arguments: "\"multi word\" simple"
- })
- );
-
- assert_eq!(try_parse_user_command("not a command"), None);
- assert_eq!(try_parse_user_command(""), None);
- assert_eq!(try_parse_user_command("/"), None);
- }
-
- #[test]
- fn test_parse_arguments_simple_unquoted() {
- let args = parse_arguments("foo bar").unwrap();
- assert_eq!(args, vec!["foo", "bar"]);
- }
-
- #[test]
- fn test_parse_arguments_quoted() {
- let args = parse_arguments("\"foo bar\"").unwrap();
- assert_eq!(args, vec!["foo bar"]);
- }
-
- #[test]
- fn test_parse_arguments_mixed() {
- let args = parse_arguments("\"foo bar\" baz \"qux\"").unwrap();
- assert_eq!(args, vec!["foo bar", "baz", "qux"]);
- }
-
- #[test]
- fn test_parse_arguments_escaped_quotes() {
- let args = parse_arguments("\"foo \\\"bar\\\" baz\"").unwrap();
- assert_eq!(args, vec!["foo \"bar\" baz"]);
- }
-
- #[test]
- fn test_parse_arguments_escaped_backslash() {
- let args = parse_arguments("\"foo\\\\bar\"").unwrap();
- assert_eq!(args, vec!["foo\\bar"]);
- }
-
- #[test]
- fn test_parse_arguments_unclosed_quote_error() {
- let result = parse_arguments("\"foo");
- assert!(result.is_err());
- assert!(result.unwrap_err().to_string().contains("Unclosed quote"));
- }
-
- #[test]
- fn test_parse_arguments_quote_in_middle_error() {
- let result = parse_arguments("foo\"bar");
- assert!(result.is_err());
- assert!(result.unwrap_err().to_string().contains("Quote in middle"));
- }
-
- #[test]
- fn test_parse_arguments_unknown_escape_error() {
- let result = parse_arguments("\"\\x\"");
- assert!(result.is_err());
- assert!(result.unwrap_err().to_string().contains("Unknown escape"));
- }
-
- #[test]
- fn test_parse_arguments_newline_escape() {
- let args = parse_arguments("\"line1\\nline2\"").unwrap();
- assert_eq!(args, vec!["line1\nline2"]);
- }
-
- // ==================== Placeholder Tests ====================
-
- #[test]
- fn test_count_positional_placeholders() {
- assert_eq!(count_positional_placeholders("Hello $1"), 1);
- assert_eq!(count_positional_placeholders("$1 and $2"), 2);
- assert_eq!(count_positional_placeholders("$1 $1"), 1);
- assert_eq!(count_positional_placeholders("$2 then $1"), 2);
- assert_eq!(count_positional_placeholders("no placeholders"), 0);
- assert_eq!(count_positional_placeholders("\\$1 escaped"), 0);
- assert_eq!(count_positional_placeholders("$10 big number"), 10);
- }
-
- #[test]
- fn test_has_placeholders() {
- assert!(has_placeholders("Hello $1"));
- assert!(has_placeholders("$ARGUMENTS"));
- assert!(has_placeholders("prefix $ARGUMENTS suffix"));
- assert!(!has_placeholders("no placeholders"));
- assert!(!has_placeholders("\\$1 escaped"));
- }
-
- // ==================== Template Expansion Tests ====================
-
- #[test]
- fn test_expand_template_basic() {
- let args = vec![Cow::Borrowed("world")];
- let result = expand_template("Hello $1", &args, "world").unwrap();
- assert_eq!(result, "Hello world");
- }
-
- #[test]
- fn test_expand_template_multiple_placeholders() {
- let args = vec![Cow::Borrowed("a"), Cow::Borrowed("b")];
- let result = expand_template("$1 and $2", &args, "a b").unwrap();
- assert_eq!(result, "a and b");
- }
-
- #[test]
- fn test_expand_template_repeated_placeholder() {
- let args = vec![Cow::Borrowed("x")];
- let result = expand_template("$1 $1", &args, "x").unwrap();
- assert_eq!(result, "x x");
- }
-
- #[test]
- fn test_expand_template_out_of_order() {
- let args = vec![Cow::Borrowed("a"), Cow::Borrowed("b")];
- let result = expand_template("$2 then $1", &args, "a b").unwrap();
- assert_eq!(result, "b then a");
- }
-
- #[test]
- fn test_expand_template_escape_sequences() {
- let args: Vec<Cow<'_, str>> = vec![];
- assert_eq!(
- expand_template("line1\\nline2", &args, "").unwrap(),
- "line1\nline2"
- );
- assert_eq!(
- expand_template("cost is \\$1", &args, "").unwrap(),
- "cost is $1"
- );
- assert_eq!(
- expand_template("say \\\"hi\\\"", &args, "").unwrap(),
- "say \"hi\""
- );
- assert_eq!(
- expand_template("path\\\\file", &args, "").unwrap(),
- "path\\file"
- );
- }
-
- #[test]
- fn test_expand_template_arguments_placeholder() {
- let args = vec![Cow::Borrowed("foo"), Cow::Borrowed("bar")];
- let result = expand_template("All args: $ARGUMENTS", &args, "foo bar").unwrap();
- assert_eq!(result, "All args: foo bar");
- }
-
- #[test]
- fn test_expand_template_arguments_with_positional() {
- let args = vec![Cow::Borrowed("first"), Cow::Borrowed("second")];
- let result = expand_template("First: $1, All: $ARGUMENTS", &args, "first second").unwrap();
- assert_eq!(result, "First: first, All: first second");
- }
-
- #[test]
- fn test_expand_template_arguments_empty() {
- let args: Vec<Cow<'_, str>> = vec![];
- let result = expand_template("Args: $ARGUMENTS", &args, "").unwrap();
- assert_eq!(result, "Args: ");
- }
-
- // ==================== Validation Tests ====================
-
- #[test]
- fn test_validate_arguments_exact_match() {
- let args = vec![Cow::Borrowed("a"), Cow::Borrowed("b")];
- let result = validate_arguments("test", "$1 $2", &args);
- assert!(result.is_ok());
- }
-
- #[test]
- fn test_validate_arguments_missing_args() {
- let args = vec![Cow::Borrowed("a")];
- let result = validate_arguments("foo", "$1 $2", &args);
- assert!(result.is_err());
- let err = result.unwrap_err().to_string();
- assert!(err.contains("/foo"));
- assert!(err.contains("requires 2 arguments"));
- }
-
- #[test]
- fn test_validate_arguments_extra_args() {
- let args = vec![Cow::Borrowed("a"), Cow::Borrowed("b")];
- let result = validate_arguments("foo", "$1", &args);
- assert!(result.is_err());
- let err = result.unwrap_err().to_string();
- assert!(err.contains("accepts 1 argument"));
- }
-
- #[test]
- fn test_validate_arguments_no_placeholders() {
- // No args expected, none provided - OK
- let args: Vec<Cow<'_, str>> = vec![];
- assert!(validate_arguments("test", "no placeholders", &args).is_ok());
-
- // No args expected but some provided - Error
- let args = vec![Cow::Borrowed("unexpected")];
- let result = validate_arguments("test", "no placeholders", &args);
- assert!(result.is_err());
- assert!(
- result
- .unwrap_err()
- .to_string()
- .contains("accepts no arguments")
- );
- }
-
- #[test]
- fn test_validate_arguments_empty_template() {
- let args: Vec<Cow<'_, str>> = vec![];
- let result = validate_arguments("test", "", &args);
- assert!(result.is_err());
- assert!(result.unwrap_err().to_string().contains("cannot be empty"));
- }
-
- #[test]
- fn test_validate_arguments_with_arguments_placeholder() {
- // $ARGUMENTS accepts any number of arguments including zero
- let args: Vec<Cow<'_, str>> = vec![];
- assert!(validate_arguments("test", "Do: $ARGUMENTS", &args).is_ok());
-
- let args = vec![Cow::Borrowed("a"), Cow::Borrowed("b"), Cow::Borrowed("c")];
- assert!(validate_arguments("test", "Do: $ARGUMENTS", &args).is_ok());
- }
-
- #[test]
- fn test_validate_arguments_mixed_placeholders() {
- // Both $ARGUMENTS and positional - need at least the positional ones
- let args = vec![Cow::Borrowed("first")];
- assert!(validate_arguments("test", "$1 then $ARGUMENTS", &args).is_ok());
-
- let args: Vec<Cow<'_, str>> = vec![];
- assert!(validate_arguments("test", "$1 then $ARGUMENTS", &args).is_err());
- }
-
- // ==================== Integration Tests ====================
-
- #[test]
- fn test_expand_user_slash_command() {
- let result = expand_user_slash_command(
- "review",
- "Please review: $1",
- &[Cow::Borrowed("security")],
- "security",
- )
- .unwrap();
- assert_eq!(result, "Please review: security");
- }
-
- #[test]
- fn test_try_expand_from_commands() {
- let commands = vec![
- UserSlashCommand {
- name: "greet".into(),
- template: "Hello, world!".into(),
- namespace: None,
- path: PathBuf::from("/greet.md"),
- scope: CommandScope::User,
- },
- UserSlashCommand {
- name: "review".into(),
- template: "Review this for: $1".into(),
- namespace: None,
- path: PathBuf::from("/review.md"),
- scope: CommandScope::User,
- },
- UserSlashCommand {
- name: "search".into(),
- template: "Search: $ARGUMENTS".into(),
- namespace: None,
- path: PathBuf::from("/search.md"),
- scope: CommandScope::User,
- },
- ];
- let map = commands_to_map(&commands);
-
- // Command without arguments
- assert_eq!(
- try_expand_from_commands("/greet", &map).unwrap(),
- Some("Hello, world!".to_string())
- );
-
- // Command with positional argument
- assert_eq!(
- try_expand_from_commands("/review security", &map).unwrap(),
- Some("Review this for: security".to_string())
- );
-
- // Command with $ARGUMENTS
- assert_eq!(
- try_expand_from_commands("/search foo bar baz", &map).unwrap(),
- Some("Search: foo bar baz".to_string())
- );
-
- // Unknown command returns None
- assert_eq!(try_expand_from_commands("/unknown", &map).unwrap(), None);
-
- // Not a command returns None
- assert_eq!(try_expand_from_commands("just text", &map).unwrap(), None);
- }
-
- #[test]
- fn test_try_expand_from_commands_missing_args() {
- let commands = vec![UserSlashCommand {
- name: "review".into(),
- template: "Review: $1".into(),
- namespace: None,
- path: PathBuf::from("/review.md"),
- scope: CommandScope::User,
- }];
- let map = commands_to_map(&commands);
-
- let result = try_expand_from_commands("/review", &map);
- assert!(result.is_err());
- assert!(
- result
- .unwrap_err()
- .to_string()
- .contains("requires 1 argument")
- );
- }
-
- // ==================== Edge Case Tests ====================
-
- #[test]
- fn test_unicode_command_names() {
- // Test that unicode in command names works
- let result = try_parse_user_command("/ζ₯ζ¬θͺ arg1");
- assert!(result.is_some());
- let parsed = result.unwrap();
- assert_eq!(parsed.name, "ζ₯ζ¬θͺ");
- assert_eq!(parsed.raw_arguments, "arg1");
- }
-
- #[test]
- fn test_unicode_in_arguments() {
- let args = parse_arguments("\"γγγ«γ‘γ―\" δΈη").unwrap();
- assert_eq!(args, vec!["γγγ«γ‘γ―", "δΈη"]);
- }
-
- #[test]
- fn test_unicode_in_template() {
- let args = vec![Cow::Borrowed("εε")];
- let result = expand_template("γγγ«γ‘γ―γ$1γγοΌ", &args, "εε").unwrap();
- assert_eq!(result, "γγγ«γ‘γ―γεεγγοΌ");
- }
-
- #[test]
- fn test_command_name_with_emoji() {
- // Emoji can be multi-codepoint, test they're handled correctly
- let result = try_parse_user_command("/πdeploy fast");
- assert!(result.is_some());
- let parsed = result.unwrap();
- assert_eq!(parsed.name, "πdeploy");
- assert_eq!(parsed.raw_arguments, "fast");
-
- // Emoji in arguments
- let args = parse_arguments("π \"π party\"").unwrap();
- assert_eq!(args, vec!["π", "π party"]);
- }
-
- #[test]
- fn test_many_placeholders() {
- // Test template with many placeholders
- let template = "$1 $2 $3 $4 $5 $6 $7 $8 $9 $10";
- assert_eq!(count_positional_placeholders(template), 10);
-
- let args: Vec<Cow<'_, str>> = (1..=10).map(|i| Cow::Owned(i.to_string())).collect();
- let result = expand_template(template, &args, "1 2 3 4 5 6 7 8 9 10").unwrap();
- assert_eq!(result, "1 2 3 4 5 6 7 8 9 10");
- }
-
- #[test]
- fn test_placeholder_zero_is_invalid() {
- let args = vec![Cow::Borrowed("a")];
- let result = expand_template("$0", &args, "a");
- assert!(result.is_err());
- assert!(result.unwrap_err().to_string().contains("$0 is invalid"));
- }
-
- #[test]
- fn test_dollar_sign_without_number() {
- // Bare $ should be preserved
- let args: Vec<Cow<'_, str>> = vec![];
- let result = expand_template("cost is $", &args, "").unwrap();
- assert_eq!(result, "cost is $");
- }
-
- #[test]
- fn test_consecutive_whitespace_in_arguments() {
- let args = parse_arguments(" a b c ").unwrap();
- assert_eq!(args, vec!["a", "b", "c"]);
- }
-
- #[test]
- fn test_empty_input() {
- let args = parse_arguments("").unwrap();
- assert!(args.is_empty());
-
- let args = parse_arguments(" ").unwrap();
- assert!(args.is_empty());
- }
-
- #[test]
- fn test_command_load_error_command_name() {
- let error = CommandLoadError {
- path: PathBuf::from(path!("/commands/tools/git/commit.md")),
- base_path: PathBuf::from(path!("/commands")),
- message: "Failed".into(),
- };
- assert_eq!(error.command_name().as_deref(), Some("tools:git:commit"));
-
- let non_md_error = CommandLoadError {
- path: PathBuf::from(path!("/commands/readme.txt")),
- base_path: PathBuf::from(path!("/commands")),
- message: "Failed".into(),
- };
- assert_eq!(non_md_error.command_name(), None);
- }
-
- #[test]
- fn test_apply_server_command_conflicts() {
- let mut commands = vec![
- UserSlashCommand {
- name: "help".into(),
- template: "Help text".into(),
- namespace: None,
- path: PathBuf::from(path!("/commands/help.md")),
- scope: CommandScope::User,
- },
- UserSlashCommand {
- name: "review".into(),
- template: "Review $1".into(),
- namespace: None,
- path: PathBuf::from(path!("/commands/review.md")),
- scope: CommandScope::User,
- },
- ];
- let mut errors = Vec::new();
- let server_command_names = HashSet::from_iter(["help".to_string()]);
-
- apply_server_command_conflicts(&mut commands, &mut errors, &server_command_names);
-
- assert_eq!(commands.len(), 1);
- assert_eq!(commands[0].name.as_ref(), "review");
- assert_eq!(errors.len(), 1);
- assert_eq!(errors[0].command_name().as_deref(), Some("help"));
- assert!(errors[0].message.contains("conflicts"));
- }
-
- #[test]
- fn test_apply_server_command_conflicts_to_map() {
- let command = UserSlashCommand {
- name: "tools:git:commit".into(),
- template: "Commit".into(),
- namespace: Some("tools/git".into()),
- path: PathBuf::from(path!("/commands/tools/git/commit.md")),
- scope: CommandScope::User,
- };
- let mut commands = HashMap::default();
- commands.insert(command.name.to_string(), command.clone());
- let mut errors = Vec::new();
- let server_command_names = HashSet::from_iter([command.name.to_string()]);
-
- apply_server_command_conflicts_to_map(&mut commands, &mut errors, &server_command_names);
-
- assert!(commands.is_empty());
- assert_eq!(errors.len(), 1);
- assert_eq!(
- errors[0].command_name().as_deref(),
- Some("tools:git:commit")
- );
- }
-
- // ==================== Async File Loading Tests with FakeFs ====================
-
- #[gpui::test]
- async fn test_load_commands_from_empty_dir(cx: &mut TestAppContext) {
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree(path!("/commands"), json!({})).await;
- let fs: Arc<dyn Fs> = fs;
-
- let result =
- load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User)
- .await;
-
- assert!(result.commands.is_empty());
- assert!(result.errors.is_empty());
- }
-
- #[gpui::test]
- async fn test_load_commands_from_nonexistent_dir(cx: &mut TestAppContext) {
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree(path!("/"), json!({})).await;
- let fs: Arc<dyn Fs> = fs;
-
- let result = load_commands_from_path_async(
- &fs,
- Path::new(path!("/nonexistent")),
- CommandScope::User,
- )
- .await;
-
- assert!(result.commands.is_empty());
- assert!(result.errors.is_empty());
- }
-
- #[gpui::test]
- async fn test_load_single_command(cx: &mut TestAppContext) {
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree(
- path!("/commands"),
- json!({
- "review.md": "Please review: $1"
- }),
- )
- .await;
- let fs: Arc<dyn Fs> = fs;
-
- let result =
- load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User)
- .await;
-
- assert!(result.errors.is_empty());
- assert_eq!(result.commands.len(), 1);
- let cmd = &result.commands[0];
- assert_eq!(cmd.name.as_ref(), "review");
- assert_eq!(cmd.template.as_ref(), "Please review: $1");
- assert!(cmd.namespace.is_none());
- assert_eq!(cmd.scope, CommandScope::User);
- }
-
- #[gpui::test]
- async fn test_load_commands_with_namespace(cx: &mut TestAppContext) {
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree(
- path!("/commands"),
- json!({
- "frontend": {
- "component.md": "Create component: $1"
- }
- }),
- )
- .await;
- let fs: Arc<dyn Fs> = fs;
-
- let result =
- load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User)
- .await;
-
- assert!(result.errors.is_empty());
- assert_eq!(result.commands.len(), 1);
- let cmd = &result.commands[0];
- assert_eq!(cmd.name.as_ref(), "frontend:component");
- assert_eq!(cmd.namespace.as_ref().map(|s| s.as_ref()), Some("frontend"));
- }
-
- #[gpui::test]
- async fn test_load_commands_nested_namespace(cx: &mut TestAppContext) {
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree(
- path!("/commands"),
- json!({
- "tools": {
- "git": {
- "commit.md": "Git commit: $ARGUMENTS"
- }
- }
- }),
- )
- .await;
- let fs: Arc<dyn Fs> = fs;
-
- let result =
- load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User)
- .await;
-
- assert!(result.errors.is_empty());
- assert_eq!(result.commands.len(), 1);
- let cmd = &result.commands[0];
- assert_eq!(cmd.name.as_ref(), "tools:git:commit");
- assert_eq!(
- cmd.namespace.as_ref().map(|s| s.as_ref()),
- Some("tools/git")
- );
- }
-
- #[gpui::test]
- async fn test_deeply_nested_namespace(cx: &mut TestAppContext) {
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree(
- path!("/commands"),
- json!({
- "a": {
- "b": {
- "c": {
- "d": {
- "e": {
- "deep.md": "Very deep command"
- }
- }
- }
- }
- }
- }),
- )
- .await;
- let fs: Arc<dyn Fs> = fs;
-
- let result =
- load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User)
- .await;
-
- assert!(result.errors.is_empty());
- assert_eq!(result.commands.len(), 1);
- let cmd = &result.commands[0];
- assert_eq!(cmd.name.as_ref(), "a:b:c:d:e:deep");
- assert_eq!(
- cmd.namespace.as_ref().map(|s| s.as_ref()),
- Some("a/b/c/d/e")
- );
- }
-
- #[gpui::test]
- async fn test_load_commands_empty_file_ignored(cx: &mut TestAppContext) {
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree(
- path!("/commands"),
- json!({
- "empty.md": "",
- "valid.md": "Hello!"
- }),
- )
- .await;
- let fs: Arc<dyn Fs> = fs;
-
- let result =
- load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User)
- .await;
-
- assert!(result.errors.is_empty());
- assert_eq!(result.commands.len(), 1);
- assert_eq!(result.commands[0].name.as_ref(), "valid");
- }
-
- #[gpui::test]
- async fn test_load_commands_non_md_files_ignored(cx: &mut TestAppContext) {
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree(
- path!("/commands"),
- json!({
- "command.md": "Valid command",
- "readme.txt": "Not a command",
- "script.sh": "Also not a command"
- }),
- )
- .await;
- let fs: Arc<dyn Fs> = fs;
-
- let result =
- load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User)
- .await;
-
- assert!(result.errors.is_empty());
- assert_eq!(result.commands.len(), 1);
- assert_eq!(result.commands[0].name.as_ref(), "command");
- }
-
- #[gpui::test]
- async fn test_load_project_commands(cx: &mut TestAppContext) {
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree(
- path!("/project"),
- json!({
- ".zed": {
- "commands": {
- "build.md": "Build the project"
- }
- }
- }),
- )
- .await;
- let fs: Arc<dyn Fs> = fs;
-
- let commands_path = project_commands_dir(Path::new(path!("/project")));
- let result =
- load_commands_from_path_async(&fs, &commands_path, CommandScope::Project).await;
-
- assert!(result.errors.is_empty());
- assert_eq!(result.commands.len(), 1);
- assert_eq!(result.commands[0].name.as_ref(), "build");
- assert_eq!(result.commands[0].scope, CommandScope::Project);
- }
-
- #[gpui::test]
- async fn test_load_all_commands_no_duplicates(cx: &mut TestAppContext) {
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree(
- path!("/project1"),
- json!({
- ".zed": {
- "commands": {
- "review.md": "Project 1 review"
- }
- }
- }),
- )
- .await;
- fs.insert_tree(
- path!("/project2"),
- json!({
- ".zed": {
- "commands": {
- "build.md": "Project 2 build"
- }
- }
- }),
- )
- .await;
- let fs: Arc<dyn Fs> = fs;
-
- let result = load_all_commands_async(
- &fs,
- &[
- PathBuf::from(path!("/project1")),
- PathBuf::from(path!("/project2")),
- ],
- )
- .await;
-
- assert!(result.errors.is_empty());
- assert_eq!(result.commands.len(), 2);
- let names: Vec<&str> = result.commands.iter().map(|c| c.name.as_ref()).collect();
- assert!(names.contains(&"review"));
- assert!(names.contains(&"build"));
- }
-
- #[gpui::test]
- async fn test_load_all_commands_duplicate_error(cx: &mut TestAppContext) {
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree(
- path!("/project1"),
- json!({
- ".zed": {
- "commands": {
- "deploy.md": "Deploy from project 1"
- }
- }
- }),
- )
- .await;
- fs.insert_tree(
- path!("/project2"),
- json!({
- ".zed": {
- "commands": {
- "deploy.md": "Deploy from project 2"
- }
- }
- }),
- )
- .await;
- let fs: Arc<dyn Fs> = fs;
-
- let result = load_all_commands_async(
- &fs,
- &[
- PathBuf::from(path!("/project1")),
- PathBuf::from(path!("/project2")),
- ],
- )
- .await;
-
- // Should have one command and one error
- assert_eq!(result.commands.len(), 1);
- assert_eq!(result.errors.len(), 1);
- assert!(result.errors[0].message.contains("ambiguous"));
- assert!(result.errors[0].message.contains("deploy"));
- }
-
- #[gpui::test]
- async fn test_registry_loads_commands(cx: &mut TestAppContext) {
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree(
- path!("/project"),
- json!({
- ".zed": {
- "commands": {
- "test.md": "Test command"
- }
- }
- }),
- )
- .await;
- let fs: Arc<dyn Fs> = fs;
-
- let registry = cx.new(|cx| {
- SlashCommandRegistry::new(fs.clone(), vec![PathBuf::from(path!("/project"))], cx)
- });
-
- // Wait for async load
- cx.run_until_parked();
-
- registry.read_with(cx, |registry: &SlashCommandRegistry, _cx| {
- assert!(registry.errors().is_empty());
- assert!(registry.commands().contains_key("test"));
- });
- }
-
- #[gpui::test]
- async fn test_registry_updates_worktree_roots(cx: &mut TestAppContext) {
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree(
- path!("/project1"),
- json!({
- ".zed": {
- "commands": {
- "cmd1.md": "Command 1"
- }
- }
- }),
- )
- .await;
- fs.insert_tree(
- path!("/project2"),
- json!({
- ".zed": {
- "commands": {
- "cmd2.md": "Command 2"
- }
- }
- }),
- )
- .await;
- let fs: Arc<dyn Fs> = fs;
-
- let registry = cx.new(|cx| {
- SlashCommandRegistry::new(fs.clone(), vec![PathBuf::from(path!("/project1"))], cx)
- });
-
- cx.run_until_parked();
-
- registry.read_with(cx, |registry: &SlashCommandRegistry, _cx| {
- assert!(registry.commands().contains_key("cmd1"));
- assert!(!registry.commands().contains_key("cmd2"));
- });
-
- // Update worktree roots
- registry.update(cx, |registry: &mut SlashCommandRegistry, cx| {
- registry.set_worktree_roots(
- vec![
- PathBuf::from(path!("/project1")),
- PathBuf::from(path!("/project2")),
- ],
- cx,
- );
- });
-
- cx.run_until_parked();
-
- registry.read_with(cx, |registry: &SlashCommandRegistry, _cx| {
- assert!(registry.commands().contains_key("cmd1"));
- assert!(registry.commands().contains_key("cmd2"));
- });
- }
-
- #[gpui::test]
- async fn test_registry_reloads_on_file_change(cx: &mut TestAppContext) {
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree(
- path!("/project"),
- json!({
- ".zed": {
- "commands": {
- "original.md": "Original command"
- }
- }
- }),
- )
- .await;
- let fs: Arc<dyn Fs> = fs.clone();
-
- let registry = cx.new(|cx| {
- SlashCommandRegistry::new(fs.clone(), vec![PathBuf::from(path!("/project"))], cx)
- });
-
- // Wait for initial load
- cx.run_until_parked();
-
- registry.read_with(cx, |registry, _cx| {
- assert_eq!(registry.commands().len(), 1);
- assert!(registry.commands().contains_key("original"));
- });
-
- // Add a new command file
- fs.save(
- Path::new(path!("/project/.zed/commands/new.md")),
- &Rope::from("New command"),
- text::LineEnding::Unix,
- )
- .await
- .unwrap();
-
- // Wait for watcher to process the change
- cx.run_until_parked();
-
- registry.read_with(cx, |registry, _cx| {
- assert_eq!(registry.commands().len(), 2);
- assert!(registry.commands().contains_key("original"));
- assert!(registry.commands().contains_key("new"));
- });
-
- // Remove a command file
- fs.remove_file(
- Path::new(path!("/project/.zed/commands/original.md")),
- RemoveOptions::default(),
- )
- .await
- .unwrap();
-
- // Wait for watcher to process the change
- cx.run_until_parked();
-
- registry.read_with(cx, |registry, _cx| {
- assert_eq!(registry.commands().len(), 1);
- assert!(!registry.commands().contains_key("original"));
- assert!(registry.commands().contains_key("new"));
- });
-
- // Modify an existing command
- fs.save(
- Path::new(path!("/project/.zed/commands/new.md")),
- &Rope::from("Updated content"),
- text::LineEnding::Unix,
- )
- .await
- .unwrap();
-
- cx.run_until_parked();
-
- registry.read_with(cx, |registry, _cx| {
- let cmd = registry.commands().get("new").unwrap();
- assert_eq!(cmd.template.as_ref(), "Updated content");
- });
- }
-
- #[gpui::test]
- async fn test_concurrent_command_loading(cx: &mut TestAppContext) {
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree(
- path!("/project"),
- json!({
- ".zed": {
- "commands": {
- "cmd1.md": "Command 1",
- "cmd2.md": "Command 2",
- "cmd3.md": "Command 3"
- }
- }
- }),
- )
- .await;
- let fs: Arc<dyn Fs> = fs;
- let worktree_roots = vec![PathBuf::from(path!("/project"))];
-
- // Spawn multiple load tasks concurrently
- let fs1 = fs.clone();
- let roots1 = worktree_roots.clone();
- let task1 = cx
- .executor()
- .spawn(async move { load_all_commands_async(&fs1, &roots1).await });
-
- let fs2 = fs.clone();
- let roots2 = worktree_roots.clone();
- let task2 = cx
- .executor()
- .spawn(async move { load_all_commands_async(&fs2, &roots2).await });
-
- let fs3 = fs.clone();
- let roots3 = worktree_roots.clone();
- let task3 = cx
- .executor()
- .spawn(async move { load_all_commands_async(&fs3, &roots3).await });
-
- // Wait for all tasks to complete
- let (result1, result2, result3) = futures::join!(task1, task2, task3);
-
- // All should succeed with the same results
- assert!(result1.errors.is_empty());
- assert!(result2.errors.is_empty());
- assert!(result3.errors.is_empty());
-
- assert_eq!(result1.commands.len(), 3);
- assert_eq!(result2.commands.len(), 3);
- assert_eq!(result3.commands.len(), 3);
- }
-
- // ==================== Symlink Handling Tests ====================
-
- #[gpui::test]
- async fn test_load_commands_from_symlinked_directory(cx: &mut TestAppContext) {
- let fs = FakeFs::new(cx.executor());
-
- // Create the actual commands directory with a command
- fs.insert_tree(
- path!("/actual_commands"),
- json!({
- "review.md": "Please review: $1"
- }),
- )
- .await;
-
- // Create a symlink from /commands to /actual_commands
- fs.insert_tree(path!("/"), json!({})).await;
- fs.create_symlink(
- Path::new(path!("/commands")),
- PathBuf::from(path!("/actual_commands")),
- )
- .await
- .unwrap();
-
- let fs: Arc<dyn Fs> = fs;
-
- let result =
- load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User)
- .await;
-
- assert!(result.errors.is_empty());
- assert_eq!(result.commands.len(), 1);
- assert_eq!(result.commands[0].name.as_ref(), "review");
- }
-
- #[gpui::test]
- async fn test_load_commands_from_symlinked_file(cx: &mut TestAppContext) {
- let fs = FakeFs::new(cx.executor());
-
- // Create the actual command file
- fs.insert_tree(
- path!("/actual"),
- json!({
- "real_review.md": "Review command content: $1"
- }),
- )
- .await;
-
- // Create commands directory with a symlink to the file
- fs.insert_tree(path!("/commands"), json!({})).await;
- fs.create_symlink(
- Path::new(path!("/commands/review.md")),
- PathBuf::from(path!("/actual/real_review.md")),
- )
- .await
- .unwrap();
-
- let fs: Arc<dyn Fs> = fs;
-
- let result =
- load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User)
- .await;
-
- assert!(result.errors.is_empty());
- assert_eq!(result.commands.len(), 1);
- assert_eq!(result.commands[0].name.as_ref(), "review");
- assert_eq!(
- result.commands[0].template.as_ref(),
- "Review command content: $1"
- );
- }
-
- #[gpui::test]
- async fn test_load_commands_claude_symlink_pattern(cx: &mut TestAppContext) {
- // Simulates the common pattern of symlinking ~/.claude/commands/ to zed's commands dir
- let fs = FakeFs::new(cx.executor());
-
- // Create Claude's commands directory structure
- fs.insert_tree(
- path!("/home/user/.claude/commands"),
- json!({
- "explain.md": "Explain this code: $ARGUMENTS",
- "refactor": {
- "extract.md": "Extract method: $1"
- }
- }),
- )
- .await;
-
- // Create Zed config dir with symlink to Claude's commands
- fs.insert_tree(path!("/home/user/.config/zed"), json!({}))
- .await;
- fs.create_symlink(
- Path::new(path!("/home/user/.config/zed/commands")),
- PathBuf::from(path!("/home/user/.claude/commands")),
- )
- .await
- .unwrap();
-
- let fs: Arc<dyn Fs> = fs;
-
- let result = load_commands_from_path_async(
- &fs,
- Path::new(path!("/home/user/.config/zed/commands")),
- CommandScope::User,
- )
- .await;
-
- assert!(result.errors.is_empty());
- assert_eq!(result.commands.len(), 2);
-
- let names: Vec<&str> = result.commands.iter().map(|c| c.name.as_ref()).collect();
- assert!(names.contains(&"explain"));
- assert!(names.contains(&"refactor:extract"));
- }
-
- #[gpui::test]
- async fn test_symlink_to_parent_directory_skipped(cx: &mut TestAppContext) {
- let fs = FakeFs::new(cx.executor());
-
- // Create a directory structure with a symlink pointing outside the commands dir
- // This tests that symlinks to directories outside the command tree are handled
- fs.insert_tree(
- path!("/commands"),
- json!({
- "valid.md": "Valid command"
- }),
- )
- .await;
-
- // Create a separate directory
- fs.insert_tree(
- path!("/other"),
- json!({
- "external.md": "External command"
- }),
- )
- .await;
-
- // Create a symlink from /commands/external -> /other
- fs.create_symlink(
- Path::new(path!("/commands/external")),
- PathBuf::from(path!("/other")),
- )
- .await
- .unwrap();
-
- let fs: Arc<dyn Fs> = fs;
-
- let result =
- load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User)
- .await;
-
- // Should have loaded both the valid command and the external one via symlink
- assert!(result.commands.iter().any(|c| c.name.as_ref() == "valid"));
- assert!(
- result
- .commands
- .iter()
- .any(|c| c.name.as_ref() == "external:external")
- );
- }
-
- // ==================== Permission/Error Handling Tests ====================
-
- #[gpui::test]
- async fn test_load_commands_reports_directory_read_errors(cx: &mut TestAppContext) {
- let fs = FakeFs::new(cx.executor());
-
- // Create base directory but no commands subdirectory
- fs.insert_tree(path!("/"), json!({})).await;
-
- let fs: Arc<dyn Fs> = fs;
-
- // Try to load from a path that exists but isn't a directory
- // First create a file where we expect a directory
- fs.create_file(Path::new(path!("/commands")), fs::CreateOptions::default())
- .await
- .unwrap();
-
- let result =
- load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User)
- .await;
-
- // Should return empty since /commands is a file, not a directory
- assert!(result.commands.is_empty());
- }
-
- #[gpui::test]
- async fn test_load_all_commands_aggregates_errors(cx: &mut TestAppContext) {
- let fs = FakeFs::new(cx.executor());
-
- // Create two projects with duplicate command names
- fs.insert_tree(
- path!("/project1"),
- json!({
- ".zed": {
- "commands": {
- "build.md": "Build 1"
- }
- }
- }),
- )
- .await;
- fs.insert_tree(
- path!("/project2"),
- json!({
- ".zed": {
- "commands": {
- "build.md": "Build 2"
- }
- }
- }),
- )
- .await;
- fs.insert_tree(
- path!("/project3"),
- json!({
- ".zed": {
- "commands": {
- "build.md": "Build 3"
- }
- }
- }),
- )
- .await;
-
- let fs: Arc<dyn Fs> = fs;
-
- let result = load_all_commands_async(
- &fs,
- &[
- PathBuf::from(path!("/project1")),
- PathBuf::from(path!("/project2")),
- PathBuf::from(path!("/project3")),
- ],
- )
- .await;
-
- // Should have 1 command (first one) and 2 errors (for duplicates)
- assert_eq!(result.commands.len(), 1);
- assert_eq!(result.errors.len(), 2);
-
- // All errors should mention "ambiguous"
- for error in &result.errors {
- assert!(error.message.contains("ambiguous"));
- }
- }
-
- #[gpui::test]
- async fn test_mixed_valid_and_empty_files(cx: &mut TestAppContext) {
- let fs = FakeFs::new(cx.executor());
-
- fs.insert_tree(
- path!("/commands"),
- json!({
- "valid.md": "Valid command",
- "empty.md": "",
- "whitespace_only.md": " ",
- "another_valid.md": "Another valid"
- }),
- )
- .await;
-
- let fs: Arc<dyn Fs> = fs;
-
- let result =
- load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User)
- .await;
-
- // Empty file is ignored, whitespace-only is an error
- assert_eq!(result.commands.len(), 2);
- assert_eq!(result.errors.len(), 1);
- assert!(result.errors[0].message.contains("whitespace"));
- assert_eq!(
- result.errors[0].command_name().as_deref(),
- Some("whitespace_only")
- );
- }
-}
@@ -143,7 +143,7 @@ impl AgentThreadPane {
if let Some(active_thread_view) = &self.thread_view {
let thread_view = active_thread_view.view.read(cx);
if let Some(ready) = thread_view.as_active_thread() {
- let title = ready.thread.read(cx).title();
+ let title = ready.read(cx).thread.read(cx).title();
if !title.is_empty() {
return title;
}
@@ -24,12 +24,6 @@ impl FeatureFlag for AcpBetaFeatureFlag {
const NAME: &'static str = "acp-beta";
}
-pub struct UserSlashCommandsFeatureFlag;
-
-impl FeatureFlag for UserSlashCommandsFeatureFlag {
- const NAME: &'static str = "slash-commands";
-}
-
pub struct ToolPermissionsFeatureFlag;
impl FeatureFlag for ToolPermissionsFeatureFlag {
@@ -2030,7 +2030,7 @@ fn run_subagent_visual_tests(
thread_view
.read(cx)
.as_active_thread()
- .map(|active| active.thread.clone())
+ .map(|active| active.read(cx).thread.clone())
})
.ok_or_else(|| anyhow::anyhow!("Thread not available"))?;
@@ -2440,7 +2440,7 @@ fn run_agent_thread_view_test(
thread_view
.read(cx)
.as_active_thread()
- .map(|active| active.thread.clone())
+ .map(|active| active.read(cx).thread.clone())
})
.ok_or_else(|| anyhow::anyhow!("Thread not available"))?;