diff --git a/Cargo.lock b/Cargo.lock index 39801ac88d77e95379f9a1c79ab5b9aec4c6c9be..964cb4008b3c8ed53332caae5c3d631ee65293ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 502352cbf1cafbabd02c93887f19f478c79965fa..2a31781054fd29b30a3c8119e87491edbfb1e658 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -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 diff --git a/crates/agent_ui/src/acp/entry_view_state.rs b/crates/agent_ui/src/acp/entry_view_state.rs index 329938c4415817ca33ac127c627503abb0632652..2d6b1e7148891020d77654f97ccc2e281557f384 100644 --- a/crates/agent_ui/src/acp/entry_view_state.rs +++ b/crates/agent_ui/src/acp/entry_view_state.rs @@ -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, prompt_capabilities: Rc>, available_commands: Rc>>, - cached_user_commands: Rc>>, - cached_user_command_errors: Rc>>, agent_name: SharedString, } @@ -45,8 +42,6 @@ impl EntryViewState { prompt_store: Option>, prompt_capabilities: Rc>, available_commands: Rc>>, - cached_user_commands: Rc>>, - cached_user_command_errors: Rc>>, 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(), ) }); diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index f5a01c695ed1439e0b2102addff838d2582b7c90..1f90738c4699458f7c9f86efa488bfb82470a282 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -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, - errors: Vec, - }, - FromFs { - fs: Arc, - worktree_roots: Vec, - }, -} - pub struct MessageEditor { mention_set: Entity, editor: Entity, workspace: WeakEntity, prompt_capabilities: Rc>, available_commands: Rc>>, - cached_user_commands: Rc>>, - cached_user_command_errors: Rc>>, agent_name: SharedString, thread_store: Option>, _subscriptions: Vec, @@ -107,7 +92,6 @@ impl PromptCompletionProviderDelegate for Entity { 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 { fn confirm_command(&self, cx: &mut App) { self.update(cx, |this, cx| this.send(cx)); } - - fn cached_user_commands( - &self, - cx: &App, - ) -> Option> { - 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> { - 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 { - 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, - project: WeakEntity, - thread_store: Option>, - history: WeakEntity, - prompt_store: Option>, - prompt_capabilities: Rc>, - available_commands: Rc>>, - cached_user_commands: Rc>>, - cached_user_command_errors: Rc>>, - agent_name: SharedString, - placeholder: &str, - mode: EditorMode, - window: &mut Window, - cx: &mut Context, ) -> 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>, available_commands: Rc>>, - cached_user_commands: Rc>>, - cached_user_command_errors: Rc>>, _cx: &mut Context, ) { 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 { @@ -477,48 +398,11 @@ impl MessageEditor { &self, full_mention_content: bool, cx: &mut Context, - ) -> Task, Vec>)>> { - 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, - >, - cached_user_command_errors: Option>, - cx: &mut Context, ) -> Task, Vec>)>> { 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::() { - 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 = 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::>(); - 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 { diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index a3f66d4fc73a16499e9a9a5df69df634a7d79e38..d44a9c7b94aa339eb52cf1010dc479deceb017ff 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -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) -> Self { + fn from_err(error: anyhow::Error, agent_name: &str) -> Self { if error.is::() { Self::PaymentRequired } else if let Some(acp_error) = error.downcast_ref::() @@ -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::().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 { } } -#[derive(Default)] -struct ThreadFeedbackState { - feedback: Option, - comments_editor: Option>, -} - -impl ThreadFeedbackState { - pub fn submit( - &mut self, - thread: Entity, - 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, 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 { - 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>, 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, agent_server_store: Entity, @@ -333,33 +168,18 @@ pub struct AcpServerView { prompt_store: Option>, server_state: ServerState, login: Option, // is some <=> Active | Unauthenticated - recent_history_entries: Vec, history: Entity, - _history_subscription: Subscription, - hovered_recent_history_item: Option, - message_editor: Entity, focus_handle: FocusHandle, notifications: Vec>, notification_subscriptions: HashMap, Vec>, - slash_command_registry: Option>, auth_task: Option>, _subscriptions: Vec, - show_codex_windows_warning: bool, - in_flight_prompt: Option>, - add_context_menu_handle: PopoverMenuHandle, } impl AcpServerView { - pub fn as_active_thread(&self) -> Option<&AcpThreadView> { + pub fn as_active_thread(&self) -> Option> { 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, connection: Rc, } @@ -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::(window, Self::agent_ui_font_size_changed), cx.observe_global_in::(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::().is_some(); - - // Create SlashCommandRegistry to cache user-defined slash commands and watch for changes - let slash_command_registry = if cx.has_flag::() { - let fs = project.read(cx).fs().clone(); - let worktree_roots: Vec = 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::>(); - 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) { 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, prompt_capabilities: Rc>, available_commands: Rc>>, - cached_user_commands: Rc>>, - cached_user_command_errors: Rc>>, + initial_content: Option, window: &mut Window, cx: &mut Context, ) -> ServerState { @@ -688,8 +399,8 @@ impl AcpServerView { this.update_in(cx, |this, window, cx| { if err.downcast_ref::().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, ) { + 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::() { 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) { - if let Some(active) = self.as_active_thread_mut() { - active.cancel_generation(cx); - } - } - - fn share_thread(&mut self, _window: &mut Window, cx: &mut Context) { - 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::(), - "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) { - 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::()) - 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::(), - "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, + event: &EditorEvent, + window: &mut Window, cx: &mut Context, ) { - 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) { - 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, - event: &EditorEvent, - window: &mut Window, - cx: &mut Context, - ) { - 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) { + 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, - event: &MessageEditorEvent, + index: usize, + is_send_now: bool, window: &mut Window, cx: &mut Context, ) { - 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, - event: &EntryViewEvent, + thread: &Entity, + event: &AcpThreadEvent, window: &mut Window, cx: &mut Context, ) { - 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) { - if let Some(active) = self.as_active_thread_mut() { - active.retry_generation(cx); - }; - } - - fn send(&mut self, window: &mut Window, cx: &mut Context) { - 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) { - 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) -> 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, - window: &mut Window, - cx: &mut Context, - ) { - 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, Vec>)>>>, - window: &mut Window, - cx: &mut Context, - ) { - 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, - ) { - 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) { - 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, - window: &mut Window, - cx: &mut Context, - ) { - 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, - window: &mut Window, - cx: &mut Context, - ) { - 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) { - 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) { - 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) { - let (error_kind, acp_error_code, message): (&str, Option, 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) { - if let Some(active) = self.as_active_thread_mut() { - active.clear_thread_error(cx); - } - } - - fn handle_thread_event( - &mut self, - thread: &Entity, - event: &AcpThreadEvent, - window: &mut Window, - cx: &mut Context, - ) { - 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( @@ -1638,24 +972,26 @@ impl AcpServerView { cx, ); - let should_send_queued = if let Some(active) = self.as_active_thread_mut() { - if active.skip_queue_processing_count > 0 { - active.skip_queue_processing_count -= 1; - false - } else if active.user_interrupted_generation { - // Manual interruption: don't auto-process queue. - // Reset the flag so future completions can process normally. - active.user_interrupted_generation = false; - false - } else { - let has_queued = !active.local_queued_messages.is_empty(); - // Don't auto-send if the first message editor is currently focused - let is_first_editor_focused = active - .queued_message_editors - .first() - .is_some_and(|editor| editor.focus_handle(cx).is_focused(window)); - has_queued && !is_first_editor_focused - } + let should_send_queued = if let Some(active) = self.as_active_thread() { + active.update(cx, |active, cx| { + if active.skip_queue_processing_count > 0 { + active.skip_queue_processing_count -= 1; + false + } else if active.user_interrupted_generation { + // Manual interruption: don't auto-process queue. + // Reset the flag so future completions can process normally. + active.user_interrupted_generation = false; + false + } else { + let has_queued = !active.local_queued_messages.is_empty(); + // Don't auto-send if the first message editor is currently focused + let is_first_editor_focused = active + .queued_message_editors + .first() + .is_some_and(|editor| editor.focus_handle(cx).is_focused(window)); + has_queued && !is_first_editor_focused + } + }) } else { false }; @@ -1667,11 +1003,11 @@ impl AcpServerView { } AcpThreadEvent::Refusal => { let error = ThreadError::Refusal; - self.emit_thread_error_telemetry(&error, cx); - - if let Some(active) = self.as_active_thread_mut() { - active.thread_retry_status.take(); - active.thread_error = Some(error); + if let Some(active) = self.as_active_thread() { + active.update(cx, |active, cx| { + active.handle_thread_error(error, cx); + active.thread_retry_status.take(); + }); } let model_or_agent_name = self.current_model_name(cx); let notification_message = @@ -1679,8 +1015,10 @@ impl AcpServerView { self.notify_with_sound(¬ification_message, IconName::Warning, window, cx); } AcpThreadEvent::Error => { - 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(); + }); } self.notify_with_sound( "Agent stopped due to an error", @@ -1690,16 +1028,27 @@ impl AcpServerView { ); } AcpThreadEvent::LoadError(error) => { - self.server_state = ServerState::LoadError(error.clone()); - if self.message_editor.focus_handle(cx).is_focused(window) { - self.focus_handle.focus(window, cx) + 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) + } + } + _ => {} } + self.server_state = ServerState::LoadError(error.clone()); } AcpThreadEvent::TitleUpdated => { let title = thread.read(cx).title(); if let Some(title_editor) = self .as_active_thread() - .and_then(|active| active.title_editor.as_ref()) + .and_then(|active| active.read(cx).title_editor.clone()) { title_editor.update(cx, |editor, cx| { if editor.text(cx) != title { @@ -1710,10 +1059,12 @@ impl AcpServerView { self.history.update(cx, |history, cx| history.refresh(cx)); } AcpThreadEvent::PromptCapabilitiesUpdated => { - if let Some(active) = self.as_active_thread_mut() { - active - .prompt_capabilities - .replace(thread.read(cx).prompt_capabilities()); + if let Some(active) = self.as_active_thread() { + active.update(cx, |active, _cx| { + active + .prompt_capabilities + .replace(thread.read(_cx).prompt_capabilities()); + }); } } AcpThreadEvent::TokenUsageUpdated => { @@ -1734,10 +1085,11 @@ impl AcpServerView { } let has_commands = !available_commands.is_empty(); - if let Some(active) = self.as_active_thread_mut() { - active.available_commands.replace(available_commands); + if let Some(active) = self.as_active_thread() { + active.update(cx, |active, _cx| { + active.available_commands.replace(available_commands); + }); } - self.refresh_cached_user_commands(cx); let agent_display_name = self .agent_server_store @@ -1745,11 +1097,15 @@ impl AcpServerView { .agent_display_name(&ExternalAgentServerName(self.agent.name())) .unwrap_or_else(|| self.agent.name()); - let new_placeholder = placeholder_text(agent_display_name.as_ref(), has_commands); - - self.message_editor.update(cx, |editor, cx| { - editor.set_placeholder_text(&new_placeholder, window, cx); - }); + if let Some(active) = self.as_active_thread() { + let new_placeholder = + placeholder_text(agent_display_name.as_ref(), has_commands); + active.update(cx, |active, cx| { + active.message_editor.update(cx, |editor, cx| { + editor.set_placeholder_text(&new_placeholder, window, cx); + }); + }); + } } AcpThreadEvent::ModeUpdated(_mode) => { // The connection keeps track of the mode @@ -1885,7 +1241,11 @@ impl AcpServerView { { pending_auth_method.take(); } - this.handle_thread_error(err, cx); + if let Some(active) = this.as_active_thread() { + active.update(cx, |active, cx| { + active.handle_any_thread_error(err, cx); + }) + } } else { this.reset(window, cx); } @@ -1996,7 +1356,9 @@ impl AcpServerView { { pending_auth_method.take(); } - this.handle_thread_error(err, cx); + if let Some(active) = this.as_active_thread() { + active.update(cx, |active, cx| active.handle_any_thread_error(err, cx)); + } } else { this.reset(window, cx); } @@ -2128,5331 +1490,415 @@ impl AcpServerView { pub fn has_user_submitted_prompt(&self, cx: &App) -> bool { self.as_active_thread().is_some_and(|active| { - active.thread.read(cx).entries().iter().any(|entry| { - matches!( - entry, - AgentThreadEntry::UserMessage(user_message) if user_message.id.is_some() - ) - }) + active + .read(cx) + .thread + .read(cx) + .entries() + .iter() + .any(|entry| { + matches!( + entry, + AgentThreadEntry::UserMessage(user_message) if user_message.id.is_some() + ) + }) }) } - fn authorize_tool_call( - &mut self, - tool_call_id: acp::ToolCallId, - option_id: acp::PermissionOptionId, - option_kind: acp::PermissionOptionKind, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(active) = self.as_active_thread_mut() { - active.authorize_tool_call(tool_call_id, option_id, option_kind, window, cx); - }; - } - - fn authorize_subagent_tool_call( - &mut self, - subagent_thread: Entity, - tool_call_id: acp::ToolCallId, - option_id: acp::PermissionOptionId, - option_kind: acp::PermissionOptionKind, - _window: &mut Window, - cx: &mut Context, - ) { - subagent_thread.update(cx, |thread, cx| { - thread.authorize_tool_call(tool_call_id, option_id, option_kind, cx); - }); - } - - fn restore_checkpoint(&mut self, message_id: &UserMessageId, cx: &mut Context) { - if let Some(active) = self.as_active_thread_mut() { - active.restore_checkpoint(message_id, cx); - }; - } - - fn render_entry( + fn render_auth_required_state( &self, - entry_ix: usize, - total_entries: usize, - entry: &AgentThreadEntry, + connection: &Rc, + description: Option<&Entity>, + configuration_view: Option<&AnyView>, + pending_auth_method: Option<&acp::AuthMethodId>, window: &mut Window, cx: &Context, - ) -> AnyElement { - let is_indented = entry.is_indented(); - let is_first_indented = is_indented - && self.as_active_thread().is_some_and(|active| { - active - .thread - .read(cx) - .entries() - .get(entry_ix.saturating_sub(1)) - .is_none_or(|entry| !entry.is_indented()) - }); + ) -> impl IntoElement { + let auth_methods = connection.auth_methods(); - let primary = match &entry { - AgentThreadEntry::UserMessage(message) => { - let Some(entry_view_state) = self - .as_active_thread() - .map(|active| &active.entry_view_state) - else { - return Empty.into_any_element(); - }; - let Some(editor) = entry_view_state - .read(cx) - .entry(entry_ix) - .and_then(|entry| entry.message_editor()) - .cloned() - else { - return Empty.into_any_element(); - }; + let agent_display_name = self + .agent_server_store + .read(cx) + .agent_display_name(&ExternalAgentServerName(self.agent.name())) + .unwrap_or_else(|| self.agent.name()); - let editing = self - .as_active_thread() - .and_then(|active| active.editing_message) - == Some(entry_ix); - let editor_focus = editor.focus_handle(cx).is_focused(window); - let focus_border = cx.theme().colors().border_focused; + let show_fallback_description = auth_methods.len() > 1 + && configuration_view.is_none() + && description.is_none() + && pending_auth_method.is_none(); - let rules_item = if entry_ix == 0 { - self.render_rules_item(cx) - } else { - None - }; + let auth_buttons = || { + h_flex().justify_end().flex_wrap().gap_1().children( + connection + .auth_methods() + .iter() + .enumerate() + .rev() + .map(|(ix, method)| { + let (method_id, name) = if self.project.read(cx).is_via_remote_server() + && method.id.0.as_ref() == "oauth-personal" + && method.name == "Log in with Google" + { + ("spawn-gemini-cli".into(), "Log in with Gemini CLI".into()) + } else { + (method.id.0.clone(), method.name.clone()) + }; - let has_checkpoint_button = message - .checkpoint - .as_ref() - .is_some_and(|checkpoint| checkpoint.show); - - let agent_name = self.agent.name(); + let agent_telemetry_id = connection.telemetry_id(); - v_flex() - .id(("user_message", entry_ix)) - .map(|this| { - if is_first_indented { - this.pt_0p5() - } else if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() { - this.pt(rems_from_px(18.)) - } else if rules_item.is_some() { - this.pt_3() - } else { - this.pt_2() - } - }) - .pb_3() - .px_2() - .gap_1p5() - .w_full() - .children(rules_item) - .children(message.id.clone().and_then(|message_id| { - message.checkpoint.as_ref()?.show.then(|| { - h_flex() - .px_3() - .gap_2() - .child(Divider::horizontal()) - .child( - Button::new("restore-checkpoint", "Restore Checkpoint") - .icon(IconName::Undo) - .icon_size(IconSize::XSmall) - .icon_position(IconPosition::Start) - .label_size(LabelSize::XSmall) - .icon_color(Color::Muted) - .color(Color::Muted) - .tooltip(Tooltip::text("Restores all files in the project to the content they had at this point in the conversation.")) - .on_click(cx.listener(move |this, _, _window, cx| { - this.restore_checkpoint(&message_id, cx); - })) - ) - .child(Divider::horizontal()) - }) - })) - .child( - div() - .relative() - .child( - div() - .py_3() - .px_2() - .rounded_md() - .shadow_md() - .bg(cx.theme().colors().editor_background) - .border_1() - .when(is_indented, |this| { - this.py_2().px_2().shadow_sm() - }) - .when(editing && !editor_focus, |this| this.border_dashed()) - .border_color(cx.theme().colors().border) - .map(|this|{ - if editing && editor_focus { - this.border_color(focus_border) - } else if message.id.is_some() { - this.hover(|s| s.border_color(focus_border.opacity(0.8))) - } else { - this - } - }) - .text_xs() - .child(editor.clone().into_any_element()) - ) - .when(editor_focus, |this| { - let base_container = h_flex() - .absolute() - .top_neg_3p5() - .right_3() - .gap_1() - .rounded_sm() - .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().editor_background) - .overflow_hidden(); - - let is_loading_contents = matches!(&self.server_state, ServerState::Connected(ConnectedServerState { current: AcpThreadView { is_loading_contents: true, .. }, ..})); - if message.id.is_some() { - this.child( - base_container - .child( - IconButton::new("cancel", IconName::Close) - .disabled(is_loading_contents) - .icon_color(Color::Error) - .icon_size(IconSize::XSmall) - .on_click(cx.listener(Self::cancel_editing)) - ) - .child( - if is_loading_contents { - div() - .id("loading-edited-message-content") - .tooltip(Tooltip::text("Loading Added Context…")) - .child(loading_contents_spinner(IconSize::XSmall)) - .into_any_element() - } else { - IconButton::new("regenerate", IconName::Return) - .icon_color(Color::Muted) - .icon_size(IconSize::XSmall) - .tooltip(Tooltip::text( - "Editing will restart the thread from this point." - )) - .on_click(cx.listener({ - let editor = editor.clone(); - move |this, _, window, cx| { - this.regenerate( - entry_ix, editor.clone(), window, cx, - ); - } - })).into_any_element() - } - ) - ) + Button::new(method_id.clone(), name) + .label_size(LabelSize::Small) + .map(|this| { + if ix == 0 { + this.style(ButtonStyle::Tinted(TintColor::Accent)) } else { - this.child( - base_container - .border_dashed() - .child( - IconButton::new("editing_unavailable", IconName::PencilUnavailable) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .style(ButtonStyle::Transparent) - .tooltip(Tooltip::element({ - move |_, _| { - v_flex() - .gap_1() - .child(Label::new("Unavailable Editing")).child( - div().max_w_64().child( - Label::new(format!( - "Editing previous messages is not available for {} yet.", - agent_name.clone() - )) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .into_any_element() - } - })) - ) - ) + this.style(ButtonStyle::Outlined) } - }), - ) - .into_any() - } - AgentThreadEntry::AssistantMessage(AssistantMessage { - chunks, - indented: _, - }) => { - let mut is_blank = true; - let is_last = entry_ix + 1 == total_entries; - - let style = MarkdownStyle::themed(MarkdownFont::Agent, window, cx); - let message_body = v_flex() - .w_full() - .gap_3() - .children(chunks.iter().enumerate().filter_map( - |(chunk_ix, chunk)| match chunk { - AssistantMessageChunk::Message { block } => { - block.markdown().and_then(|md| { - let this_is_blank = md.read(cx).source().trim().is_empty(); - is_blank = is_blank && this_is_blank; - if this_is_blank { - return None; - } + }) + .when_some(method.description.clone(), |this, description| { + this.tooltip(Tooltip::text(description)) + }) + .on_click({ + cx.listener(move |this, _, window, cx| { + telemetry::event!( + "Authenticate Agent Started", + agent = agent_telemetry_id, + method = method_id + ); - Some( - self.render_markdown(md.clone(), style.clone()) - .into_any_element(), - ) - }) - } - AssistantMessageChunk::Thought { block } => { - block.markdown().and_then(|md| { - let this_is_blank = md.read(cx).source().trim().is_empty(); - is_blank = is_blank && this_is_blank; - if this_is_blank { - return None; - } - Some( - self.render_thinking_block( - entry_ix, - chunk_ix, - md.clone(), - window, - cx, - ) - .into_any_element(), + this.authenticate( + acp::AuthMethodId::new(method_id.clone()), + window, + cx, ) }) - } - }, - )) - .into_any(); - - if is_blank { - Empty.into_any() - } else { - v_flex() - .px_5() - .py_1p5() - .when(is_last, |this| this.pb_4()) - .w_full() - .text_ui(cx) - .child(self.render_message_context_menu(entry_ix, message_body, cx)) - .into_any() - } - } - AgentThreadEntry::ToolCall(tool_call) => { - let has_terminals = tool_call.terminals().next().is_some(); - - div() - .w_full() - .map(|this| { - if has_terminals { - this.children(tool_call.terminals().map(|terminal| { - self.render_terminal_tool_call( - entry_ix, terminal, tool_call, window, cx, - ) - })) - } else { - this.child(self.render_tool_call(entry_ix, tool_call, window, cx)) - } - }) - .into_any() - } - }; - - let primary = if is_indented { - let line_top = if is_first_indented { - rems_from_px(-12.0) - } else { - rems_from_px(0.0) - }; - - div() - .relative() - .w_full() - .pl_5() - .bg(cx.theme().colors().panel_background.opacity(0.2)) - .child( - div() - .absolute() - .left(rems_from_px(18.0)) - .top(line_top) - .bottom_0() - .w_px() - .bg(cx.theme().colors().border.opacity(0.6)), - ) - .child(primary) - .into_any_element() - } else { - primary - }; - - let needs_confirmation = if let AgentThreadEntry::ToolCall(tool_call) = entry { - matches!( - tool_call.status, - ToolCallStatus::WaitingForConfirmation { .. } + }) + }), ) - } else { - false - }; - - let Some(active) = self.as_active_thread() else { - return primary; }; - let primary = if entry_ix == total_entries - 1 { - v_flex() - .w_full() - .child(primary) - .map(|this| { - if needs_confirmation { - this.child(self.render_generating(true, cx)) - } else { - this.child(self.render_thread_controls(&active.thread, cx)) - } - }) - .when_some( - active.thread_feedback.comments_editor.clone(), - |this, editor| this.child(Self::render_feedback_feedback_editor(editor, cx)), + if pending_auth_method.is_some() { + return Callout::new() + .icon(IconName::Info) + .title(format!("Authenticating to {}…", agent_display_name)) + .actions_slot( + Icon::new(IconName::ArrowCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_rotate_animation(2) + .into_any_element(), ) - .into_any_element() - } else { - primary - }; - - if let Some(editing_index) = self - .as_active_thread() - .and_then(|active| active.editing_message) - && editing_index < entry_ix - { - let backdrop = div() - .id(("backdrop", entry_ix)) - .size_full() - .absolute() - .inset_0() - .bg(cx.theme().colors().panel_background) - .opacity(0.8) - .block_mouse_except_scroll() - .on_click(cx.listener(Self::cancel_editing)); - - div() - .relative() - .child(primary) - .child(backdrop) - .into_any_element() - } else { - primary + .into_any_element(); } - } - - fn render_message_context_menu( - &self, - entry_ix: usize, - message_body: AnyElement, - cx: &Context, - ) -> AnyElement { - let entity = cx.entity(); - let workspace = self.workspace.clone(); - - right_click_menu(format!("agent_context_menu-{}", entry_ix)) - .trigger(move |_, _, _| message_body) - .menu(move |window, cx| { - let focus = window.focused(cx); - let entity = entity.clone(); - let workspace = workspace.clone(); - - ContextMenu::build(window, cx, move |menu, _, cx| { - let active_thread = entity.read(cx).as_active_thread(); - let is_at_top = active_thread - .map(|active| &active.list_state) - .map_or(true, |state| state.logical_scroll_top().item_ix == 0); - - let has_selection = active_thread - .and_then(|active| active.thread.read(cx).entries().get(entry_ix)) - .and_then(|entry| match entry { - AgentThreadEntry::AssistantMessage(msg) => Some(&msg.chunks), - _ => None, - }) - .map(|chunks| { - chunks.iter().any(|chunk| { - let md = match chunk { - AssistantMessageChunk::Message { block } => block.markdown(), - AssistantMessageChunk::Thought { block } => block.markdown(), - }; - md.map_or(false, |m| m.read(cx).selected_text().is_some()) - }) - }) - .unwrap_or(false); - - let copy_this_agent_response = - ContextMenuEntry::new("Copy This Agent Response").handler({ - let entity = entity.clone(); - move |_, cx| { - entity.update(cx, |this, cx| { - if let Some(active) = this.as_active_thread() { - let entries = active.thread.read(cx).entries(); - if let Some(text) = - Self::get_agent_message_content(entries, entry_ix, cx) - { - cx.write_to_clipboard(ClipboardItem::new_string(text)); - } - } - }); - } - }); - - let scroll_item = if is_at_top { - ContextMenuEntry::new("Scroll to Bottom").handler({ - let entity = entity.clone(); - move |_, cx| { - entity.update(cx, |this, cx| { - this.scroll_to_bottom(cx); - }); - } - }) - } else { - ContextMenuEntry::new("Scroll to Top").handler({ - let entity = entity.clone(); - move |_, cx| { - entity.update(cx, |this, cx| { - this.scroll_to_top(cx); - }); - } - }) - }; - - let open_thread_as_markdown = ContextMenuEntry::new("Open Thread as Markdown") - .handler({ - let entity = entity.clone(); - let workspace = workspace.clone(); - move |window, cx| { - if let Some(workspace) = workspace.upgrade() { - entity - .update(cx, |this, cx| { - this.open_thread_as_markdown(workspace, window, cx) - }) - .detach_and_log_err(cx); - } - } - }); - menu.when_some(focus, |menu, focus| menu.context(focus)) - .action_disabled_when( - !has_selection, - "Copy Selection", - Box::new(markdown::CopyAsMarkdown), - ) - .item(copy_this_agent_response) - .separator() - .item(scroll_item) - .item(open_thread_as_markdown) - }) + Callout::new() + .icon(IconName::Info) + .title(format!("Authenticate to {}", agent_display_name)) + .when(auth_methods.len() == 1, |this| { + this.actions_slot(auth_buttons()) }) - .into_any_element() - } - - fn tool_card_header_bg(&self, cx: &Context) -> Hsla { - cx.theme() - .colors() - .element_background - .blend(cx.theme().colors().editor_foreground.opacity(0.025)) - } - - fn tool_card_border_color(&self, cx: &Context) -> Hsla { - cx.theme().colors().border.opacity(0.8) - } - - fn tool_name_font_size(&self) -> Rems { - rems_from_px(13.) - } - - fn render_thinking_block( - &self, - entry_ix: usize, - chunk_ix: usize, - chunk: Entity, - window: &Window, - cx: &Context, - ) -> AnyElement { - let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix)); - let card_header_id = SharedString::from("inner-card-header"); - - let key = (entry_ix, chunk_ix); - - let is_open = matches!(&self.server_state, ServerState::Connected(ConnectedServerState {current: AcpThreadView { expanded_thinking_blocks, .. }, ..}) if expanded_thinking_blocks.contains(&key)); - - let scroll_handle = self - .as_active_thread() - .map(|active| &active.entry_view_state) - .and_then(|entry_view_state| { - entry_view_state - .read(cx) - .entry(entry_ix) - .and_then(|entry| entry.scroll_handle_for_assistant_message_chunk(chunk_ix)) - }); - - let thinking_content = { - div() - .id(("thinking-content", chunk_ix)) - .when_some(scroll_handle, |this, scroll_handle| { - this.track_scroll(&scroll_handle) - }) - .text_ui_sm(cx) - .overflow_hidden() - .child(self.render_markdown( - chunk, - MarkdownStyle::themed(MarkdownFont::Agent, window, cx), - )) - }; - - v_flex() - .gap_1() - .child( - h_flex() - .id(header_id) - .group(&card_header_id) - .relative() - .w_full() - .pr_1() - .justify_between() - .child( - h_flex() - .h(window.line_height() - px(2.)) - .gap_1p5() - .overflow_hidden() - .child( - Icon::new(IconName::ToolThink) - .size(IconSize::Small) + .description_slot( + v_flex() + .text_ui(cx) + .map(|this| { + if show_fallback_description { + this.child( + Label::new("Choose one of the following authentication options:") + .size(LabelSize::Small) .color(Color::Muted), ) - .child( - div() - .text_size(self.tool_name_font_size()) - .text_color(cx.theme().colors().text_muted) - .child("Thinking"), - ), - ) - .child( - Disclosure::new(("expand", entry_ix), is_open) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown) - .visible_on_hover(&card_header_id) - .on_click(cx.listener({ - move |this, _event, _window, cx| { - if let Some(active) = this.as_active_thread_mut() { - if is_open { - active.expanded_thinking_blocks.remove(&key); - } else { - active.expanded_thinking_blocks.insert(key); - } - cx.notify(); - } - } - })), - ) - .on_click(cx.listener({ - move |this, _event, _window, cx| { - if let Some(active) = this.as_active_thread_mut() { - if is_open { - active.expanded_thinking_blocks.remove(&key); - } else { - active.expanded_thinking_blocks.insert(key); - } - cx.notify(); - } + } else { + this.children( + configuration_view + .cloned() + .map(|view| div().w_full().child(view)), + ) + .children(description.map(|desc| { + self.render_markdown( + desc.clone(), + MarkdownStyle::themed(MarkdownFont::Agent, window, cx), + ) + })) } - })), + }) + .when(auth_methods.len() > 1, |this| { + this.gap_1().child(auth_buttons()) + }), ) - .when(is_open, |this| { - this.child( - div() - .ml_1p5() - .pl_3p5() - .border_l_1() - .border_color(self.tool_card_border_color(cx)) - .child(thinking_content), - ) - }) .into_any_element() } - fn render_tool_call( + fn render_load_error( &self, - entry_ix: usize, - tool_call: &ToolCall, - window: &Window, - cx: &Context, - ) -> Div { - let has_location = tool_call.locations.len() == 1; - let card_header_id = SharedString::from("inner-tool-call-header"); - - let failed_or_canceled = match &tool_call.status { - ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed => true, - _ => false, - }; - - let needs_confirmation = matches!( - tool_call.status, - ToolCallStatus::WaitingForConfirmation { .. } - ); - let is_terminal_tool = matches!(tool_call.kind, acp::ToolKind::Execute); - - let is_edit = - matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some(); - let is_subagent = tool_call.is_subagent(); - - // For subagent tool calls, render the subagent cards directly without wrapper - if is_subagent { - return self.render_subagent_tool_call(entry_ix, tool_call, window, cx); - } - - let is_cancelled_edit = is_edit && matches!(tool_call.status, ToolCallStatus::Canceled); - let has_revealed_diff = tool_call.diffs().next().is_some_and(|diff| { - self.as_active_thread() - .and_then(|active| { - active - .entry_view_state - .read(cx) - .entry(entry_ix) - .and_then(|entry| entry.editor_for_diff(diff)) - }) - .is_some() - && diff.read(cx).has_revealed_range(cx) - }); - - let use_card_layout = needs_confirmation || is_edit || is_terminal_tool; - - let has_image_content = tool_call.content.iter().any(|c| c.image().is_some()); - let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation; - let mut is_open = match &self.server_state { - ServerState::Connected(ConnectedServerState { current, .. }) => { - current.expanded_tool_calls.contains(&tool_call.id) + e: &LoadError, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let (title, message, action_slot): (_, SharedString, _) = match e { + LoadError::Unsupported { + command: path, + current_version, + minimum_version, + } => { + return self.render_unsupported(path, current_version, minimum_version, window, cx); } - _ => false, - }; - - is_open |= needs_confirmation; - - let should_show_raw_input = !is_terminal_tool && !is_edit && !has_image_content; - - let input_output_header = |label: SharedString| { - Label::new(label) - .size(LabelSize::XSmall) - .color(Color::Muted) - .buffer_font(cx) + LoadError::FailedToInstall(msg) => ( + "Failed to Install", + msg.into(), + Some(self.create_copy_button(msg.to_string()).into_any_element()), + ), + LoadError::Exited { status } => ( + "Failed to Launch", + format!("Server exited with status {status}").into(), + None, + ), + LoadError::Other(msg) => ( + "Failed to Launch", + msg.into(), + Some(self.create_copy_button(msg.to_string()).into_any_element()), + ), }; - let tool_output_display = if is_open { - match &tool_call.status { - ToolCallStatus::WaitingForConfirmation { options, .. } => v_flex() - .w_full() - .children( - tool_call - .content - .iter() - .enumerate() - .map(|(content_ix, content)| { - div() - .child(self.render_tool_call_content( - entry_ix, - content, - content_ix, - tool_call, - use_card_layout, - has_image_content, - failed_or_canceled, - window, - cx, - )) - .into_any_element() - }), - ) - .when(should_show_raw_input, |this| { - let is_raw_input_expanded = - matches!(&self.server_state, ServerState::Connected(ConnectedServerState {current: AcpThreadView { expanded_tool_call_raw_inputs, .. }, ..}) if expanded_tool_call_raw_inputs.contains(&tool_call.id)); - - let input_header = if is_raw_input_expanded { - "Raw Input:" - } else { - "View Raw Input" - }; - - this.child( - v_flex() - .p_2() - .gap_1() - .border_t_1() - .border_color(self.tool_card_border_color(cx)) - .child( - h_flex() - .id("disclosure_container") - .pl_0p5() - .gap_1() - .justify_between() - .rounded_xs() - .hover(|s| s.bg(cx.theme().colors().element_hover)) - .child(input_output_header(input_header.into())) - .child( - Disclosure::new( - ("raw-input-disclosure", entry_ix), - is_raw_input_expanded, - ) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown), - ) - .on_click(cx.listener({ - let id = tool_call.id.clone(); - - move |this: &mut Self, _, _, cx| { - if let Some(active) = this.as_active_thread_mut() { - if active.expanded_tool_call_raw_inputs.contains(&id) { - active.expanded_tool_call_raw_inputs.remove(&id); - } else { - active.expanded_tool_call_raw_inputs.insert(id.clone()); - } - cx.notify(); - } - } - })), - ) - .when(is_raw_input_expanded, |this| { - this.children(tool_call.raw_input_markdown.clone().map( - |input| { - self.render_markdown( - input, - MarkdownStyle::themed( - MarkdownFont::Agent, - window, - cx, - ), - ) - }, - )) - }), - ) - }) - .child(self.render_permission_buttons( - options, - entry_ix, - tool_call.id.clone(), - cx, - )) - .into_any(), - ToolCallStatus::Pending | ToolCallStatus::InProgress - if is_edit - && tool_call.content.is_empty() - && self.as_native_connection(cx).is_some() => - { - self.render_diff_loading(cx).into_any() - } - ToolCallStatus::Pending - | ToolCallStatus::InProgress - | ToolCallStatus::Completed - | ToolCallStatus::Failed - | ToolCallStatus::Canceled => v_flex() - .when(should_show_raw_input, |this| { - this.mt_1p5().w_full().child( - v_flex() - .ml(rems(0.4)) - .px_3p5() - .pb_1() - .gap_1() - .border_l_1() - .border_color(self.tool_card_border_color(cx)) - .child(input_output_header("Raw Input:".into())) - .children(tool_call.raw_input_markdown.clone().map(|input| { - div().id(("tool-call-raw-input-markdown", entry_ix)).child( - self.render_markdown( - input, - MarkdownStyle::themed(MarkdownFont::Agent, window, cx), - ), - ) - })) - .child(input_output_header("Output:".into())), - ) - }) - .children( - tool_call - .content - .iter() - .enumerate() - .map(|(content_ix, content)| { - div().id(("tool-call-output", entry_ix)).child( - self.render_tool_call_content( - entry_ix, - content, - content_ix, - tool_call, - use_card_layout, - has_image_content, - failed_or_canceled, - window, - cx, - ), - ) - }), - ) - .into_any(), - ToolCallStatus::Rejected => Empty.into_any(), - } - .into() - } else { - None - }; - - v_flex() - .map(|this| { - if use_card_layout { - this.my_1p5() - .rounded_md() - .border_1() - .when(failed_or_canceled, |this| this.border_dashed()) - .border_color(self.tool_card_border_color(cx)) - .bg(cx.theme().colors().editor_background) - .overflow_hidden() - } else { - this.my_1() - } - }) - .map(|this| { - if has_location && !use_card_layout { - this.ml_4() - } else { - this.ml_5() - } - }) - .mr_5() - .map(|this| { - if is_terminal_tool { - let label_source = tool_call.label.read(cx).source(); - this.child(self.render_collapsible_command(true, label_source, &tool_call.id, cx)) - } else { - this.child( - h_flex() - .group(&card_header_id) - .relative() - .w_full() - .gap_1() - .justify_between() - .when(use_card_layout, |this| { - this.p_0p5() - .rounded_t(rems_from_px(5.)) - .bg(self.tool_card_header_bg(cx)) - }) - .child(self.render_tool_call_label( - entry_ix, - tool_call, - is_edit, - is_cancelled_edit, - has_revealed_diff, - use_card_layout, - window, - cx, - )) - .when(is_collapsible || failed_or_canceled, |this| { - let diff_for_discard = - if has_revealed_diff && is_cancelled_edit && cx.has_flag::() { - tool_call.diffs().next().cloned() - } else { - None - }; - this.child( - h_flex() - .px_1() - .when_some(diff_for_discard.clone(), |this, _| this.pr_0p5()) - .gap_1() - .when(is_collapsible, |this| { - this.child( - Disclosure::new(("expand-output", entry_ix), is_open) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown) - .visible_on_hover(&card_header_id) - .on_click(cx.listener({ - let id = tool_call.id.clone(); - move |this: &mut Self, _, _, cx: &mut Context| { - if let Some(active) = this.as_active_thread_mut() { - if is_open { - active.expanded_tool_calls.remove(&id); - } else { - active.expanded_tool_calls.insert(id.clone()); - } - cx.notify(); - } - } - })), - ) - }) - .when(failed_or_canceled, |this| { - if is_cancelled_edit && !has_revealed_diff { - this.child( - div() - .id(entry_ix) - .tooltip(Tooltip::text( - "Interrupted Edit", - )) - .child( - Icon::new(IconName::XCircle) - .color(Color::Muted) - .size(IconSize::Small), - ), - ) - } else if is_cancelled_edit { - this - } else { - this.child( - Icon::new(IconName::Close) - .color(Color::Error) - .size(IconSize::Small), - ) - } - }) - .when_some(diff_for_discard, |this, diff| { - let tool_call_id = tool_call.id.clone(); - let is_discarded = matches!(&self.server_state, ServerState::Connected(ConnectedServerState{current: AcpThreadView { discarded_partial_edits, .. }, ..}) if discarded_partial_edits.contains(&tool_call_id)); - this.when(!is_discarded, |this| { - this.child( - IconButton::new( - ("discard-partial-edit", entry_ix), - IconName::Undo, - ) - .icon_size(IconSize::Small) - .tooltip(move |_, cx| Tooltip::with_meta( - "Discard Interrupted Edit", - None, - "You can discard this interrupted partial edit and restore the original file content.", - cx - )) - .on_click(cx.listener({ - let tool_call_id = tool_call_id.clone(); - move |this, _, _window, cx| { - let diff_data = diff.read(cx); - let base_text = diff_data.base_text().clone(); - let buffer = diff_data.buffer().clone(); - buffer.update(cx, |buffer, cx| { - buffer.set_text(base_text.as_ref(), cx); - }); - if let Some(active) = this.as_active_thread_mut() { - active.discarded_partial_edits.insert(tool_call_id.clone()); - } - cx.notify(); - } - })), - ) - }) - }) - - ) - }), - ) - } - }) - .children(tool_output_display) - } - - fn render_tool_call_label( - &self, - entry_ix: usize, - tool_call: &ToolCall, - is_edit: bool, - has_failed: bool, - has_revealed_diff: bool, - use_card_layout: bool, - window: &Window, - cx: &Context, - ) -> Div { - let has_location = tool_call.locations.len() == 1; - let is_file = tool_call.kind == acp::ToolKind::Edit && has_location; - - let file_icon = if has_location { - FileIcons::get_icon(&tool_call.locations[0].path, cx) - .map(Icon::from_path) - .unwrap_or(Icon::new(IconName::ToolPencil)) - } else { - Icon::new(IconName::ToolPencil) - }; - - let tool_icon = if is_file && has_failed && has_revealed_diff { - div() - .id(entry_ix) - .tooltip(Tooltip::text("Interrupted Edit")) - .child(DecoratedIcon::new( - file_icon, - Some( - IconDecoration::new( - IconDecorationKind::Triangle, - self.tool_card_header_bg(cx), - cx, - ) - .color(cx.theme().status().warning) - .position(gpui::Point { - x: px(-2.), - y: px(-2.), - }), - ), - )) - .into_any_element() - } else if is_file { - div().child(file_icon).into_any_element() - } else { - div() - .child( - Icon::new(match tool_call.kind { - acp::ToolKind::Read => IconName::ToolSearch, - acp::ToolKind::Edit => IconName::ToolPencil, - acp::ToolKind::Delete => IconName::ToolDeleteFile, - acp::ToolKind::Move => IconName::ArrowRightLeft, - acp::ToolKind::Search => IconName::ToolSearch, - acp::ToolKind::Execute => IconName::ToolTerminal, - acp::ToolKind::Think => IconName::ToolThink, - acp::ToolKind::Fetch => IconName::ToolWeb, - acp::ToolKind::SwitchMode => IconName::ArrowRightLeft, - acp::ToolKind::Other | _ => IconName::ToolHammer, - }) - .size(IconSize::Small) - .color(Color::Muted), - ) - .into_any_element() - }; - - let gradient_overlay = { - div() - .absolute() - .top_0() - .right_0() - .w_12() - .h_full() - .map(|this| { - if use_card_layout { - this.bg(linear_gradient( - 90., - linear_color_stop(self.tool_card_header_bg(cx), 1.), - linear_color_stop(self.tool_card_header_bg(cx).opacity(0.2), 0.), - )) - } else { - this.bg(linear_gradient( - 90., - linear_color_stop(cx.theme().colors().panel_background, 1.), - linear_color_stop( - cx.theme().colors().panel_background.opacity(0.2), - 0., - ), - )) - } - }) - }; - - h_flex() - .relative() - .w_full() - .h(window.line_height() - px(2.)) - .text_size(self.tool_name_font_size()) - .gap_1p5() - .when(has_location || use_card_layout, |this| this.px_1()) - .when(has_location, |this| { - this.cursor(CursorStyle::PointingHand) - .rounded(rems_from_px(3.)) // Concentric border radius - .hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.5))) - }) - .overflow_hidden() - .child(tool_icon) - .child(if has_location { - h_flex() - .id(("open-tool-call-location", entry_ix)) - .w_full() - .map(|this| { - if use_card_layout { - this.text_color(cx.theme().colors().text) - } else { - this.text_color(cx.theme().colors().text_muted) - } - }) - .child( - self.render_markdown( - tool_call.label.clone(), - MarkdownStyle { - prevent_mouse_interaction: true, - ..MarkdownStyle::themed(MarkdownFont::Agent, window, cx) - .with_muted_text(cx) - }, - ), - ) - .tooltip(Tooltip::text("Go to File")) - .on_click(cx.listener(move |this, _, window, cx| { - this.open_tool_call_location(entry_ix, 0, window, cx); - })) - .into_any_element() - } else { - h_flex() - .w_full() - .child(self.render_markdown( - tool_call.label.clone(), - MarkdownStyle::themed(MarkdownFont::Agent, window, cx).with_muted_text(cx), - )) - .into_any() - }) - .when(!is_edit, |this| this.child(gradient_overlay)) - } - - fn render_tool_call_content( - &self, - entry_ix: usize, - content: &ToolCallContent, - context_ix: usize, - tool_call: &ToolCall, - card_layout: bool, - is_image_tool_call: bool, - has_failed: bool, - window: &Window, - cx: &Context, - ) -> AnyElement { - match content { - ToolCallContent::ContentBlock(content) => { - if let Some(resource_link) = content.resource_link() { - self.render_resource_link(resource_link, cx) - } else if let Some(markdown) = content.markdown() { - self.render_markdown_output( - markdown.clone(), - tool_call.id.clone(), - context_ix, - card_layout, - window, - cx, - ) - } else if let Some(image) = content.image() { - let location = tool_call.locations.first().cloned(); - self.render_image_output( - entry_ix, - image.clone(), - location, - card_layout, - is_image_tool_call, - cx, - ) - } else { - Empty.into_any_element() - } - } - ToolCallContent::Diff(diff) => { - self.render_diff_editor(entry_ix, diff, tool_call, has_failed, cx) - } - ToolCallContent::Terminal(terminal) => { - self.render_terminal_tool_call(entry_ix, terminal, tool_call, window, cx) - } - ToolCallContent::SubagentThread(_thread) => { - // Subagent threads are rendered by render_subagent_tool_call, not here - Empty.into_any_element() - } - } - } - - fn render_subagent_tool_call( - &self, - entry_ix: usize, - tool_call: &ToolCall, - window: &Window, - cx: &Context, - ) -> Div { - let subagent_threads: Vec<_> = tool_call - .content - .iter() - .filter_map(|c| c.subagent_thread().cloned()) - .collect(); - - let tool_call_status = &tool_call.status; - - v_flex() - .mx_5() - .my_1p5() - .gap_3() - .children( - subagent_threads - .into_iter() - .enumerate() - .map(|(context_ix, thread)| { - self.render_subagent_card( - entry_ix, - context_ix, - &thread, - tool_call_status, - window, - cx, - ) - }), - ) - } - - fn render_subagent_card( - &self, - entry_ix: usize, - context_ix: usize, - thread: &Entity, - tool_call_status: &ToolCallStatus, - window: &Window, - cx: &Context, - ) -> AnyElement { - let thread_read = thread.read(cx); - let session_id = thread_read.session_id().clone(); - let title = thread_read.title(); - let action_log = thread_read.action_log(); - let changed_buffers = action_log.read(cx).changed_buffers(cx); - - let is_expanded = if let Some(active) = self.as_active_thread() { - active.expanded_subagents.contains(&session_id) - } else { - false - }; - let files_changed = changed_buffers.len(); - let diff_stats = DiffStats::all_files(&changed_buffers, cx); - - let is_running = matches!( - tool_call_status, - ToolCallStatus::Pending | ToolCallStatus::InProgress - ); - let is_canceled_or_failed = matches!( - tool_call_status, - ToolCallStatus::Canceled | ToolCallStatus::Failed | ToolCallStatus::Rejected - ); - - let card_header_id = - SharedString::from(format!("subagent-header-{}-{}", entry_ix, context_ix)); - let diff_stat_id = SharedString::from(format!("subagent-diff-{}-{}", entry_ix, context_ix)); - - let icon = h_flex().w_4().justify_center().child(if is_running { - SpinnerLabel::new() - .size(LabelSize::Small) - .into_any_element() - } else if is_canceled_or_failed { - Icon::new(IconName::Close) - .size(IconSize::Small) - .color(Color::Error) - .into_any_element() - } else { - Icon::new(IconName::Check) - .size(IconSize::Small) - .color(Color::Success) - .into_any_element() - }); - - let has_expandable_content = thread_read.entries().iter().rev().any(|entry| { - if let AgentThreadEntry::AssistantMessage(msg) = entry { - msg.chunks.iter().any(|chunk| match chunk { - AssistantMessageChunk::Message { block } => block.markdown().is_some(), - AssistantMessageChunk::Thought { block } => block.markdown().is_some(), - }) - } else { - false - } - }); - - v_flex() - .w_full() - .rounded_md() - .border_1() - .border_color(self.tool_card_border_color(cx)) - .overflow_hidden() - .child( - h_flex() - .group(&card_header_id) - .py_1() - .px_1p5() - .w_full() - .gap_1() - .justify_between() - .bg(self.tool_card_header_bg(cx)) - .child( - h_flex() - .gap_1p5() - .child(icon) - .child( - Label::new(title.to_string()) - .size(LabelSize::Small) - .color(Color::Default), - ) - .when(files_changed > 0, |this| { - this.child( - h_flex() - .gap_1() - .child( - Label::new(format!( - "β€” {} {} changed", - files_changed, - if files_changed == 1 { "file" } else { "files" } - )) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child(DiffStat::new( - diff_stat_id.clone(), - diff_stats.lines_added as usize, - diff_stats.lines_removed as usize, - )), - ) - }), - ) - .child( - h_flex() - .gap_1p5() - .when(is_running, |buttons| { - buttons.child( - Button::new( - SharedString::from(format!( - "stop-subagent-{}-{}", - entry_ix, context_ix - )), - "Stop", - ) - .icon(IconName::Stop) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .icon_color(Color::Error) - .label_size(LabelSize::Small) - .tooltip(Tooltip::text("Stop this subagent")) - .on_click({ - let thread = thread.clone(); - cx.listener(move |_this, _event, _window, cx| { - thread.update(cx, |thread, _cx| { - thread.stop_by_user(); - }); - }) - }), - ) - }) - .child( - IconButton::new( - SharedString::from(format!( - "subagent-disclosure-{}-{}", - entry_ix, context_ix - )), - if is_expanded { - IconName::ChevronUp - } else { - IconName::ChevronDown - }, - ) - .shape(IconButtonShape::Square) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .disabled(!has_expandable_content) - .when(has_expandable_content, |button| { - button.on_click(cx.listener({ - move |this, _, _, cx| { - if let Some(active) = this.as_active_thread_mut() { - if active.expanded_subagents.contains(&session_id) { - active.expanded_subagents.remove(&session_id); - } else { - active - .expanded_subagents - .insert(session_id.clone()); - } - } - cx.notify(); - } - })) - }) - .when( - !has_expandable_content, - |button| { - button.tooltip(Tooltip::text("Waiting for content...")) - }, - ), - ), - ), - ) - .when(is_expanded, |this| { - this.child( - self.render_subagent_expanded_content(entry_ix, context_ix, thread, window, cx), - ) - }) - .children( - thread_read - .first_tool_awaiting_confirmation() - .and_then(|tc| { - if let ToolCallStatus::WaitingForConfirmation { options, .. } = &tc.status { - Some(self.render_subagent_pending_tool_call( - entry_ix, - context_ix, - thread.clone(), - tc, - options, - window, - cx, - )) - } else { - None - } - }), - ) - .into_any_element() - } - - fn render_subagent_expanded_content( - &self, - _entry_ix: usize, - _context_ix: usize, - thread: &Entity, - window: &Window, - cx: &Context, - ) -> impl IntoElement { - let thread_read = thread.read(cx); - let session_id = thread_read.session_id().clone(); - let entries = thread_read.entries(); - - // Find the most recent agent message with any content (message or thought) - let last_assistant_markdown = entries.iter().rev().find_map(|entry| { - if let AgentThreadEntry::AssistantMessage(msg) = entry { - msg.chunks.iter().find_map(|chunk| match chunk { - AssistantMessageChunk::Message { block } => block.markdown().cloned(), - AssistantMessageChunk::Thought { block } => block.markdown().cloned(), - }) - } else { - None - } - }); - - let scroll_handle = self - .as_active_thread() - .map(|state| { - state - .subagent_scroll_handles - .borrow_mut() - .entry(session_id.clone()) - .or_default() - .clone() - }) - .unwrap_or_default(); - - scroll_handle.scroll_to_bottom(); - let editor_bg = cx.theme().colors().editor_background; - - let gradient_overlay = { - div().absolute().inset_0().bg(linear_gradient( - 180., - linear_color_stop(editor_bg, 0.), - linear_color_stop(editor_bg.opacity(0.), 0.15), - )) - }; - - div() - .relative() - .w_full() - .max_h_56() - .p_2p5() - .text_ui(cx) - .border_t_1() - .border_color(self.tool_card_border_color(cx)) - .bg(editor_bg.opacity(0.4)) - .overflow_hidden() - .child( - div() - .id(format!("subagent-content-{}", session_id)) - .size_full() - .track_scroll(&scroll_handle) - .when_some(last_assistant_markdown, |this, markdown| { - this.child(self.render_markdown( - markdown, - MarkdownStyle::themed(MarkdownFont::Agent, window, cx), - )) - }), - ) - .child(gradient_overlay) - } - - fn render_markdown_output( - &self, - markdown: Entity, - tool_call_id: acp::ToolCallId, - context_ix: usize, - card_layout: bool, - window: &Window, - cx: &Context, - ) -> AnyElement { - let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id)); - - v_flex() - .gap_2() - .map(|this| { - if card_layout { - this.when(context_ix > 0, |this| { - this.pt_2() - .border_t_1() - .border_color(self.tool_card_border_color(cx)) - }) - } else { - this.ml(rems(0.4)) - .px_3p5() - .border_l_1() - .border_color(self.tool_card_border_color(cx)) - } - }) - .text_xs() - .text_color(cx.theme().colors().text_muted) - .child(self.render_markdown( - markdown, - MarkdownStyle::themed(MarkdownFont::Agent, window, cx), - )) - .when(!card_layout, |this| { - this.child( - IconButton::new(button_id, IconName::ChevronUp) - .full_width() - .style(ButtonStyle::Outlined) - .icon_color(Color::Muted) - .on_click(cx.listener({ - move |this: &mut Self, _, _, cx: &mut Context| { - if let Some(active) = this.as_active_thread_mut() { - active.expanded_tool_calls.remove(&tool_call_id); - cx.notify(); - } - } - })), - ) - }) - .into_any_element() - } - - fn render_image_output( - &self, - entry_ix: usize, - image: Arc, - location: Option, - card_layout: bool, - show_dimensions: bool, - cx: &Context, - ) -> AnyElement { - let dimensions_label = if show_dimensions { - let format_name = match image.format() { - gpui::ImageFormat::Png => "PNG", - gpui::ImageFormat::Jpeg => "JPEG", - gpui::ImageFormat::Webp => "WebP", - gpui::ImageFormat::Gif => "GIF", - gpui::ImageFormat::Svg => "SVG", - gpui::ImageFormat::Bmp => "BMP", - gpui::ImageFormat::Tiff => "TIFF", - gpui::ImageFormat::Ico => "ICO", - }; - let dimensions = image::ImageReader::new(std::io::Cursor::new(image.bytes())) - .with_guessed_format() - .ok() - .and_then(|reader| reader.into_dimensions().ok()); - dimensions.map(|(w, h)| format!("{}Γ—{} {}", w, h, format_name)) - } else { - None - }; - - v_flex() - .gap_2() - .map(|this| { - if card_layout { - this - } else { - this.ml(rems(0.4)) - .px_3p5() - .border_l_1() - .border_color(self.tool_card_border_color(cx)) - } - }) - .when(dimensions_label.is_some() || location.is_some(), |this| { - this.child( - h_flex() - .w_full() - .justify_between() - .items_center() - .children(dimensions_label.map(|label| { - Label::new(label) - .size(LabelSize::XSmall) - .color(Color::Muted) - .buffer_font(cx) - })) - .when_some(location, |this, _loc| { - this.child( - Button::new(("go-to-file", entry_ix), "Go to File") - .label_size(LabelSize::Small) - .on_click(cx.listener(move |this, _, window, cx| { - this.open_tool_call_location(entry_ix, 0, window, cx); - })), - ) - }), - ) - }) - .child( - img(image) - .max_w_96() - .max_h_96() - .object_fit(ObjectFit::ScaleDown), - ) - .into_any_element() - } - - fn render_resource_link( - &self, - resource_link: &acp::ResourceLink, - cx: &Context, - ) -> AnyElement { - let uri: SharedString = resource_link.uri.clone().into(); - let is_file = resource_link.uri.strip_prefix("file://"); - - let label: SharedString = if let Some(abs_path) = is_file { - if let Some(project_path) = self - .project - .read(cx) - .project_path_for_absolute_path(&Path::new(abs_path), cx) - && let Some(worktree) = self - .project - .read(cx) - .worktree_for_id(project_path.worktree_id, cx) - { - worktree - .read(cx) - .full_path(&project_path.path) - .to_string_lossy() - .to_string() - .into() - } else { - abs_path.to_string().into() - } - } else { - uri.clone() - }; - - let button_id = SharedString::from(format!("item-{}", uri)); - - div() - .ml(rems(0.4)) - .pl_2p5() - .border_l_1() - .border_color(self.tool_card_border_color(cx)) - .overflow_hidden() - .child( - Button::new(button_id, label) - .label_size(LabelSize::Small) - .color(Color::Muted) - .truncate(true) - .when(is_file.is_none(), |this| { - this.icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - }) - .on_click(cx.listener({ - let workspace = self.workspace.clone(); - move |_, _, window, cx: &mut Context| { - Self::open_link(uri.clone(), &workspace, window, cx); - } - })), - ) + Callout::new() + .severity(Severity::Error) + .icon(IconName::XCircleFilled) + .title(title) + .description(message) + .actions_slot(div().children(action_slot)) .into_any_element() } - fn render_permission_buttons( - &self, - options: &PermissionOptions, - entry_ix: usize, - tool_call_id: acp::ToolCallId, - cx: &Context, - ) -> Div { - match options { - PermissionOptions::Flat(options) => { - self.render_permission_buttons_flat(options, entry_ix, tool_call_id, cx) - } - PermissionOptions::Dropdown(options) => { - self.render_permission_buttons_dropdown(options, entry_ix, tool_call_id, cx) - } - } - } - - fn render_permission_buttons_dropdown( - &self, - choices: &[PermissionOptionChoice], - entry_ix: usize, - tool_call_id: acp::ToolCallId, - cx: &Context, - ) -> Div { - let is_first = self.as_active_thread().is_some_and(|active| { - active - .thread - .read(cx) - .first_tool_awaiting_confirmation() - .is_some_and(|call| call.id == tool_call_id) - }); - - // Get the selected granularity index, defaulting to the last option ("Only this time") - let selected_index = if let Some(active) = self.as_active_thread() { - active - .selected_permission_granularity - .get(&tool_call_id) - .copied() - .unwrap_or_else(|| choices.len().saturating_sub(1)) - } else { - choices.len().saturating_sub(1) - }; - - let selected_choice = choices.get(selected_index).or(choices.last()); - - let dropdown_label: SharedString = selected_choice - .map(|choice| choice.label()) - .unwrap_or_else(|| "Only this time".into()); - - let (allow_option_id, allow_option_kind, deny_option_id, deny_option_kind) = - if let Some(choice) = selected_choice { - ( - choice.allow.option_id.clone(), - choice.allow.kind, - choice.deny.option_id.clone(), - choice.deny.kind, - ) - } else { - ( - acp::PermissionOptionId::new("allow"), - acp::PermissionOptionKind::AllowOnce, - acp::PermissionOptionId::new("deny"), - acp::PermissionOptionKind::RejectOnce, - ) - }; - - h_flex() - .w_full() - .p_1() - .gap_2() - .justify_between() - .border_t_1() - .border_color(self.tool_card_border_color(cx)) - .child( - h_flex() - .gap_0p5() - .child( - Button::new(("allow-btn", entry_ix), "Allow") - .icon(IconName::Check) - .icon_color(Color::Success) - .icon_position(IconPosition::Start) - .icon_size(IconSize::XSmall) - .label_size(LabelSize::Small) - .when(is_first, |this| { - this.key_binding( - KeyBinding::for_action_in( - &AllowOnce as &dyn Action, - &self.focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(10.))), - ) - }) - .on_click(cx.listener({ - let tool_call_id = tool_call_id.clone(); - let option_id = allow_option_id; - let option_kind = allow_option_kind; - move |this, _, window, cx| { - this.authorize_tool_call( - tool_call_id.clone(), - option_id.clone(), - option_kind, - window, - cx, - ); - } - })), - ) - .child( - Button::new(("deny-btn", entry_ix), "Deny") - .icon(IconName::Close) - .icon_color(Color::Error) - .icon_position(IconPosition::Start) - .icon_size(IconSize::XSmall) - .label_size(LabelSize::Small) - .when(is_first, |this| { - this.key_binding( - KeyBinding::for_action_in( - &RejectOnce as &dyn Action, - &self.focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(10.))), - ) - }) - .on_click(cx.listener({ - let tool_call_id = tool_call_id.clone(); - let option_id = deny_option_id; - let option_kind = deny_option_kind; - move |this, _, window, cx| { - this.authorize_tool_call( - tool_call_id.clone(), - option_id.clone(), - option_kind, - window, - cx, - ); - } - })), - ), - ) - .child(self.render_permission_granularity_dropdown( - choices, - dropdown_label, - entry_ix, - tool_call_id, - selected_index, - is_first, - cx, - )) - } - - fn render_permission_granularity_dropdown( - &self, - choices: &[PermissionOptionChoice], - current_label: SharedString, - entry_ix: usize, - tool_call_id: acp::ToolCallId, - selected_index: usize, - is_first: bool, - cx: &Context, - ) -> AnyElement { - let menu_options: Vec<(usize, SharedString)> = choices - .iter() - .enumerate() - .map(|(i, choice)| (i, choice.label())) - .collect(); - - let permission_dropdown_handle = match self.as_active_thread() { - Some(thread) => thread.permission_dropdown_handle.clone(), - None => return div().into_any_element(), - }; - - PopoverMenu::new(("permission-granularity", entry_ix)) - .with_handle(permission_dropdown_handle) - .trigger( - Button::new(("granularity-trigger", entry_ix), current_label) - .icon(IconName::ChevronDown) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .label_size(LabelSize::Small) - .when(is_first, |this| { - this.key_binding( - KeyBinding::for_action_in( - &crate::OpenPermissionDropdown as &dyn Action, - &self.focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(10.))), - ) - }), - ) - .menu(move |window, cx| { - let tool_call_id = tool_call_id.clone(); - let options = menu_options.clone(); - - Some(ContextMenu::build(window, cx, move |mut menu, _, _| { - for (index, display_name) in options.iter() { - let display_name = display_name.clone(); - let index = *index; - let tool_call_id_for_entry = tool_call_id.clone(); - let is_selected = index == selected_index; - - menu = menu.toggleable_entry( - display_name, - is_selected, - IconPosition::End, - None, - move |window, cx| { - window.dispatch_action( - SelectPermissionGranularity { - tool_call_id: tool_call_id_for_entry.0.to_string(), - index, - } - .boxed_clone(), - cx, - ); - }, - ); - } - - menu - })) - }) - .into_any_element() - } - - fn render_permission_buttons_flat( - &self, - options: &[acp::PermissionOption], - entry_ix: usize, - tool_call_id: acp::ToolCallId, - cx: &Context, - ) -> Div { - let is_first = self.as_active_thread().is_some_and(|active| { - active - .thread - .read(cx) - .first_tool_awaiting_confirmation() - .is_some_and(|call| call.id == tool_call_id) - }); - let mut seen_kinds: ArrayVec = ArrayVec::new(); - - div() - .p_1() - .border_t_1() - .border_color(self.tool_card_border_color(cx)) - .w_full() - .v_flex() - .gap_0p5() - .children(options.iter().map(move |option| { - let option_id = SharedString::from(option.option_id.0.clone()); - Button::new((option_id, entry_ix), option.name.clone()) - .map(|this| { - let (this, action) = match option.kind { - acp::PermissionOptionKind::AllowOnce => ( - this.icon(IconName::Check).icon_color(Color::Success), - Some(&AllowOnce as &dyn Action), - ), - acp::PermissionOptionKind::AllowAlways => ( - this.icon(IconName::CheckDouble).icon_color(Color::Success), - Some(&AllowAlways as &dyn Action), - ), - acp::PermissionOptionKind::RejectOnce => ( - this.icon(IconName::Close).icon_color(Color::Error), - Some(&RejectOnce as &dyn Action), - ), - acp::PermissionOptionKind::RejectAlways | _ => { - (this.icon(IconName::Close).icon_color(Color::Error), None) - } - }; - - let Some(action) = action else { - return this; - }; - - if !is_first || seen_kinds.contains(&option.kind) { - return this; - } - - seen_kinds.push(option.kind); - - this.key_binding( - KeyBinding::for_action_in(action, &self.focus_handle, cx) - .map(|kb| kb.size(rems_from_px(10.))), - ) - }) - .icon_position(IconPosition::Start) - .icon_size(IconSize::XSmall) - .label_size(LabelSize::Small) - .on_click(cx.listener({ - let tool_call_id = tool_call_id.clone(); - let option_id = option.option_id.clone(); - let option_kind = option.kind; - move |this, _, window, cx| { - this.authorize_tool_call( - tool_call_id.clone(), - option_id.clone(), - option_kind, - window, - cx, - ); - } - })) - })) - } - - fn render_subagent_pending_tool_call( - &self, - entry_ix: usize, - context_ix: usize, - subagent_thread: Entity, - tool_call: &ToolCall, - options: &PermissionOptions, - window: &Window, - cx: &Context, - ) -> Div { - let tool_call_id = tool_call.id.clone(); - let is_edit = - matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some(); - let has_image_content = tool_call.content.iter().any(|c| c.image().is_some()); - - v_flex() - .w_full() - .border_t_1() - .border_color(self.tool_card_border_color(cx)) - .child( - self.render_tool_call_label( - entry_ix, tool_call, is_edit, false, // has_failed - false, // has_revealed_diff - true, // use_card_layout - window, cx, - ) - .py_1(), - ) - .children( - tool_call - .content - .iter() - .enumerate() - .map(|(content_ix, content)| { - self.render_tool_call_content( - entry_ix, - content, - content_ix, - tool_call, - true, // card_layout - has_image_content, - false, // has_failed - window, - cx, - ) - }), - ) - .child(self.render_subagent_permission_buttons( - entry_ix, - context_ix, - subagent_thread, - tool_call_id, - options, - cx, - )) - } - - fn render_subagent_permission_buttons( - &self, - entry_ix: usize, - context_ix: usize, - subagent_thread: Entity, - tool_call_id: acp::ToolCallId, - options: &PermissionOptions, - cx: &Context, - ) -> Div { - match options { - PermissionOptions::Flat(options) => self.render_subagent_permission_buttons_flat( - entry_ix, - context_ix, - subagent_thread, - tool_call_id, - options, - cx, - ), - PermissionOptions::Dropdown(options) => self - .render_subagent_permission_buttons_dropdown( - entry_ix, - context_ix, - subagent_thread, - tool_call_id, - options, - cx, - ), - } - } - - fn render_subagent_permission_buttons_flat( - &self, - entry_ix: usize, - context_ix: usize, - subagent_thread: Entity, - tool_call_id: acp::ToolCallId, - options: &[acp::PermissionOption], - cx: &Context, - ) -> Div { - div() - .p_1() - .border_t_1() - .border_color(self.tool_card_border_color(cx)) - .w_full() - .v_flex() - .gap_0p5() - .children(options.iter().map(move |option| { - let option_id = SharedString::from(format!( - "subagent-{}-{}-{}", - entry_ix, context_ix, option.option_id.0 - )); - Button::new((option_id, entry_ix), option.name.clone()) - .map(|this| match option.kind { - acp::PermissionOptionKind::AllowOnce => { - this.icon(IconName::Check).icon_color(Color::Success) - } - acp::PermissionOptionKind::AllowAlways => { - this.icon(IconName::CheckDouble).icon_color(Color::Success) - } - acp::PermissionOptionKind::RejectOnce - | acp::PermissionOptionKind::RejectAlways - | _ => this.icon(IconName::Close).icon_color(Color::Error), - }) - .icon_position(IconPosition::Start) - .icon_size(IconSize::XSmall) - .label_size(LabelSize::Small) - .on_click(cx.listener({ - let subagent_thread = subagent_thread.clone(); - let tool_call_id = tool_call_id.clone(); - let option_id = option.option_id.clone(); - let option_kind = option.kind; - move |this, _, window, cx| { - this.authorize_subagent_tool_call( - subagent_thread.clone(), - tool_call_id.clone(), - option_id.clone(), - option_kind, - window, - cx, - ); - } - })) - })) - } - - fn render_subagent_permission_buttons_dropdown( - &self, - entry_ix: usize, - context_ix: usize, - subagent_thread: Entity, - tool_call_id: acp::ToolCallId, - choices: &[PermissionOptionChoice], - cx: &Context, - ) -> Div { - let selected_index = if let Some(active) = self.as_active_thread() { - active - .selected_permission_granularity - .get(&tool_call_id) - .copied() - .unwrap_or_else(|| choices.len().saturating_sub(1)) - } else { - choices.len().saturating_sub(1) - }; - - let selected_choice = choices.get(selected_index).or(choices.last()); - - let dropdown_label: SharedString = selected_choice - .map(|choice| choice.label()) - .unwrap_or_else(|| "Only this time".into()); - - let (allow_option_id, allow_option_kind, deny_option_id, deny_option_kind) = - if let Some(choice) = selected_choice { - ( - choice.allow.option_id.clone(), - choice.allow.kind, - choice.deny.option_id.clone(), - choice.deny.kind, - ) - } else { - ( - acp::PermissionOptionId::new("allow"), - acp::PermissionOptionKind::AllowOnce, - acp::PermissionOptionId::new("deny"), - acp::PermissionOptionKind::RejectOnce, - ) - }; - - h_flex() - .w_full() - .p_1() - .gap_2() - .justify_between() - .border_t_1() - .border_color(self.tool_card_border_color(cx)) - .child( - h_flex() - .gap_0p5() - .child( - Button::new( - ( - SharedString::from(format!( - "subagent-allow-btn-{}-{}", - entry_ix, context_ix - )), - entry_ix, - ), - "Allow", - ) - .icon(IconName::Check) - .icon_color(Color::Success) - .icon_position(IconPosition::Start) - .icon_size(IconSize::XSmall) - .label_size(LabelSize::Small) - .on_click(cx.listener({ - let subagent_thread = subagent_thread.clone(); - let tool_call_id = tool_call_id.clone(); - let option_id = allow_option_id; - let option_kind = allow_option_kind; - move |this, _, window, cx| { - this.authorize_subagent_tool_call( - subagent_thread.clone(), - tool_call_id.clone(), - option_id.clone(), - option_kind, - window, - cx, - ); - } - })), - ) - .child( - Button::new( - ( - SharedString::from(format!( - "subagent-deny-btn-{}-{}", - entry_ix, context_ix - )), - entry_ix, - ), - "Deny", - ) - .icon(IconName::Close) - .icon_color(Color::Error) - .icon_position(IconPosition::Start) - .icon_size(IconSize::XSmall) - .label_size(LabelSize::Small) - .on_click(cx.listener({ - let tool_call_id = tool_call_id.clone(); - let option_id = deny_option_id; - let option_kind = deny_option_kind; - move |this, _, window, cx| { - this.authorize_subagent_tool_call( - subagent_thread.clone(), - tool_call_id.clone(), - option_id.clone(), - option_kind, - window, - cx, - ); - } - })), - ), - ) - .child(self.render_subagent_permission_granularity_dropdown( - choices, - dropdown_label, - entry_ix, - context_ix, - tool_call_id, - selected_index, - cx, - )) - } - - fn render_subagent_permission_granularity_dropdown( - &self, - choices: &[PermissionOptionChoice], - current_label: SharedString, - entry_ix: usize, - context_ix: usize, - tool_call_id: acp::ToolCallId, - selected_index: usize, - _cx: &Context, - ) -> AnyElement { - let menu_options: Vec<(usize, SharedString)> = choices - .iter() - .enumerate() - .map(|(i, choice)| (i, choice.label())) - .collect(); - - let permission_dropdown_handle = match self.as_active_thread() { - Some(thread) => thread.permission_dropdown_handle.clone(), - _ => return div().into_any_element(), - }; - - PopoverMenu::new(( - SharedString::from(format!( - "subagent-permission-granularity-{}-{}", - entry_ix, context_ix - )), - entry_ix, - )) - .with_handle(permission_dropdown_handle) - .trigger( - Button::new( - ( - SharedString::from(format!( - "subagent-granularity-trigger-{}-{}", - entry_ix, context_ix - )), - entry_ix, - ), - current_label, - ) - .icon(IconName::ChevronDown) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .label_size(LabelSize::Small), - ) - .menu(move |window, cx| { - let tool_call_id = tool_call_id.clone(); - let options = menu_options.clone(); - - Some(ContextMenu::build(window, cx, move |mut menu, _, _| { - for (index, display_name) in options.iter() { - let display_name = display_name.clone(); - let index = *index; - let tool_call_id_for_entry = tool_call_id.clone(); - let is_selected = index == selected_index; - - menu = menu.toggleable_entry( - display_name, - is_selected, - IconPosition::End, - None, - move |window, cx| { - window.dispatch_action( - SelectPermissionGranularity { - tool_call_id: tool_call_id_for_entry.0.to_string(), - index, - } - .boxed_clone(), - cx, - ); - }, - ); - } - - menu - })) - }) - .into_any_element() - } - - fn render_diff_loading(&self, cx: &Context) -> AnyElement { - let bar = |n: u64, width_class: &str| { - let bg_color = cx.theme().colors().element_active; - let base = h_flex().h_1().rounded_full(); - - let modified = match width_class { - "w_4_5" => base.w_3_4(), - "w_1_4" => base.w_1_4(), - "w_2_4" => base.w_2_4(), - "w_3_5" => base.w_3_5(), - "w_2_5" => base.w_2_5(), - _ => base.w_1_2(), - }; - - modified.with_animation( - ElementId::Integer(n), - Animation::new(Duration::from_secs(2)).repeat(), - move |tab, delta| { - let delta = (delta - 0.15 * n as f32) / 0.7; - let delta = 1.0 - (0.5 - delta).abs() * 2.; - let delta = ease_in_out(delta.clamp(0., 1.)); - let delta = 0.1 + 0.9 * delta; - - tab.bg(bg_color.opacity(delta)) - }, - ) - }; - - v_flex() - .p_3() - .gap_1() - .rounded_b_md() - .bg(cx.theme().colors().editor_background) - .child(bar(0, "w_4_5")) - .child(bar(1, "w_1_4")) - .child(bar(2, "w_2_4")) - .child(bar(3, "w_3_5")) - .child(bar(4, "w_2_5")) - .into_any_element() - } - - fn render_diff_editor( - &self, - entry_ix: usize, - diff: &Entity, - tool_call: &ToolCall, - has_failed: bool, - cx: &Context, - ) -> AnyElement { - let tool_progress = matches!( - &tool_call.status, - ToolCallStatus::InProgress | ToolCallStatus::Pending - ); - - let revealed_diff_editor = if let Some(entry_view_state) = self - .as_active_thread() - .map(|active| &active.entry_view_state) - && let Some(entry) = entry_view_state.read(cx).entry(entry_ix) - && let Some(editor) = entry.editor_for_diff(diff) - && diff.read(cx).has_revealed_range(cx) - { - Some(editor) - } else { - None - }; - - let show_top_border = !has_failed || revealed_diff_editor.is_some(); - - v_flex() - .h_full() - .when(show_top_border, |this| { - this.border_t_1() - .when(has_failed, |this| this.border_dashed()) - .border_color(self.tool_card_border_color(cx)) - }) - .child(if let Some(editor) = revealed_diff_editor { - editor.into_any_element() - } else if tool_progress && self.as_native_connection(cx).is_some() { - self.render_diff_loading(cx) - } else { - Empty.into_any() - }) - .into_any() - } - - fn render_collapsible_command( - &self, - is_preview: bool, - command_source: &str, - tool_call_id: &acp::ToolCallId, - cx: &Context, - ) -> Div { - let command_group = - SharedString::from(format!("collapsible-command-group-{}", tool_call_id)); - - v_flex() - .group(command_group.clone()) - .bg(self.tool_card_header_bg(cx)) - .child( - v_flex() - .p_1p5() - .when(is_preview, |this| { - this.pt_1().child( - // Wrapping this label on a container with 24px height to avoid - // layout shift when it changes from being a preview label - // to the actual path where the command will run in - h_flex().h_6().child( - Label::new("Run Command") - .buffer_font(cx) - .size(LabelSize::XSmall) - .color(Color::Muted), - ), - ) - }) - .children(command_source.lines().map(|line| { - let text: SharedString = if line.is_empty() { - " ".into() - } else { - line.to_string().into() - }; - - Label::new(text).buffer_font(cx).size(LabelSize::Small) - })) - .child( - div().absolute().top_1().right_1().child( - CopyButton::new("copy-command", command_source.to_string()) - .tooltip_label("Copy Command") - .visible_on_hover(command_group), - ), - ), - ) - } - - fn render_terminal_tool_call( - &self, - entry_ix: usize, - terminal: &Entity, - tool_call: &ToolCall, - window: &Window, - cx: &Context, - ) -> AnyElement { - let terminal_data = terminal.read(cx); - let working_dir = terminal_data.working_dir(); - let command = terminal_data.command(); - let started_at = terminal_data.started_at(); - - let tool_failed = matches!( - &tool_call.status, - ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed - ); - - let output = terminal_data.output(); - let command_finished = output.is_some(); - let truncated_output = - output.is_some_and(|output| output.original_content_len > output.content.len()); - let output_line_count = output.map(|output| output.content_line_count).unwrap_or(0); - - let command_failed = command_finished - && output.is_some_and(|o| o.exit_status.is_some_and(|status| !status.success())); - - let time_elapsed = if let Some(output) = output { - output.ended_at.duration_since(started_at) - } else { - started_at.elapsed() - }; - - let header_id = - SharedString::from(format!("terminal-tool-header-{}", terminal.entity_id())); - let header_group = SharedString::from(format!( - "terminal-tool-header-group-{}", - terminal.entity_id() - )); - let header_bg = cx - .theme() - .colors() - .element_background - .blend(cx.theme().colors().editor_foreground.opacity(0.025)); - let border_color = cx.theme().colors().border.opacity(0.6); - - let working_dir = working_dir - .as_ref() - .map(|path| path.display().to_string()) - .unwrap_or_else(|| "current directory".to_string()); - - // Since the command's source is wrapped in a markdown code block - // (```\n...\n```), we need to strip that so we're left with only the - // command's content. - let command_source = command.read(cx).source(); - let command_content = command_source - .strip_prefix("```\n") - .and_then(|s| s.strip_suffix("\n```")) - .unwrap_or(&command_source); - - let command_element = - self.render_collapsible_command(false, command_content, &tool_call.id, cx); - - let is_expanded = self - .as_connected() - .is_some_and(|c| c.current.expanded_tool_calls.contains(&tool_call.id)); - - let header = h_flex() - .id(header_id) - .px_1p5() - .pt_1() - .flex_none() - .gap_1() - .justify_between() - .rounded_t_md() - .child( - div() - .id(("command-target-path", terminal.entity_id())) - .w_full() - .max_w_full() - .overflow_x_scroll() - .child( - Label::new(working_dir) - .buffer_font(cx) - .size(LabelSize::XSmall) - .color(Color::Muted), - ), - ) - .when(!command_finished, |header| { - header - .gap_1p5() - .child( - Button::new( - SharedString::from(format!("stop-terminal-{}", terminal.entity_id())), - "Stop", - ) - .icon(IconName::Stop) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .icon_color(Color::Error) - .label_size(LabelSize::Small) - .tooltip(move |_window, cx| { - Tooltip::with_meta( - "Stop This Command", - None, - "Also possible by placing your cursor inside the terminal and using regular terminal bindings.", - cx, - ) - }) - .on_click({ - let terminal = terminal.clone(); - cx.listener(move |this, _event, _window, cx| { - terminal.update(cx, |terminal, cx| { - terminal.stop_by_user(cx); - }); - if AgentSettings::get_global(cx).cancel_generation_on_terminal_stop { - this.cancel_generation(cx); - } - }) - }), - ) - .child(Divider::vertical()) - .child( - Icon::new(IconName::ArrowCircle) - .size(IconSize::XSmall) - .color(Color::Info) - .with_rotate_animation(2) - ) - }) - .when(truncated_output, |header| { - let tooltip = if let Some(output) = output { - if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES { - format!("Output exceeded terminal max lines and was \ - truncated, the model received the first {}.", format_file_size(output.content.len() as u64, true)) - } else { - format!( - "Output is {} long, and to avoid unexpected token usage, \ - only {} was sent back to the agent.", - format_file_size(output.original_content_len as u64, true), - format_file_size(output.content.len() as u64, true) - ) - } - } else { - "Output was truncated".to_string() - }; - - header.child( - h_flex() - .id(("terminal-tool-truncated-label", terminal.entity_id())) - .gap_1() - .child( - Icon::new(IconName::Info) - .size(IconSize::XSmall) - .color(Color::Ignored), - ) - .child( - Label::new("Truncated") - .color(Color::Muted) - .size(LabelSize::XSmall), - ) - .tooltip(Tooltip::text(tooltip)), - ) - }) - .when(time_elapsed > Duration::from_secs(10), |header| { - header.child( - Label::new(format!("({})", duration_alt_display(time_elapsed))) - .buffer_font(cx) - .color(Color::Muted) - .size(LabelSize::XSmall), - ) - }) - .when(tool_failed || command_failed, |header| { - header.child( - div() - .id(("terminal-tool-error-code-indicator", terminal.entity_id())) - .child( - Icon::new(IconName::Close) - .size(IconSize::Small) - .color(Color::Error), - ) - .when_some(output.and_then(|o| o.exit_status), |this, status| { - this.tooltip(Tooltip::text(format!( - "Exited with code {}", - status.code().unwrap_or(-1), - ))) - }), - ) - }) - .child( - Disclosure::new( - SharedString::from(format!( - "terminal-tool-disclosure-{}", - terminal.entity_id() - )), - is_expanded, - ) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown) - .visible_on_hover(&header_group) - .on_click(cx.listener({ - let id = tool_call.id.clone(); - move |this, _event, _window, _cx| { - if let Some(active) = this.as_active_thread_mut() { - if is_expanded { - active.expanded_tool_calls.remove(&id); - } else { - active.expanded_tool_calls.insert(id.clone()); - } - } - } - })), - ); - - let terminal_view = self - .as_active_thread() - .map(|active| &active.entry_view_state) - .and_then(|entry_view_state| { - entry_view_state - .read(cx) - .entry(entry_ix) - .and_then(|entry| entry.terminal(terminal)) - }); - - v_flex() - .my_1p5() - .mx_5() - .border_1() - .when(tool_failed || command_failed, |card| card.border_dashed()) - .border_color(border_color) - .rounded_md() - .overflow_hidden() - .child( - v_flex() - .group(&header_group) - .bg(header_bg) - .text_xs() - .child(header) - .child(command_element), - ) - .when(is_expanded && terminal_view.is_some(), |this| { - this.child( - div() - .pt_2() - .border_t_1() - .when(tool_failed || command_failed, |card| card.border_dashed()) - .border_color(border_color) - .bg(cx.theme().colors().editor_background) - .rounded_b_md() - .text_ui_sm(cx) - .h_full() - .children(terminal_view.map(|terminal_view| { - let element = if terminal_view - .read(cx) - .content_mode(window, cx) - .is_scrollable() - { - div().h_72().child(terminal_view).into_any_element() - } else { - terminal_view.into_any_element() - }; - - div() - .on_action(cx.listener(|_this, _: &NewTerminal, window, cx| { - window.dispatch_action(NewThread.boxed_clone(), cx); - cx.stop_propagation(); - })) - .child(element) - .into_any_element() - })), - ) - }) - .into_any() - } - - fn render_rules_item(&self, cx: &Context) -> Option { - let project_context = self - .as_native_thread(cx)? - .read(cx) - .project_context() - .read(cx); - - let user_rules_text = if project_context.user_rules.is_empty() { - None - } else if project_context.user_rules.len() == 1 { - let user_rules = &project_context.user_rules[0]; - - match user_rules.title.as_ref() { - Some(title) => Some(format!("Using \"{title}\" user rule")), - None => Some("Using user rule".into()), - } - } else { - Some(format!( - "Using {} user rules", - project_context.user_rules.len() - )) - }; - - let first_user_rules_id = project_context - .user_rules - .first() - .map(|user_rules| user_rules.uuid.0); - - let rules_files = project_context - .worktrees - .iter() - .filter_map(|worktree| worktree.rules_file.as_ref()) - .collect::>(); - - let rules_file_text = match rules_files.as_slice() { - &[] => None, - &[rules_file] => Some(format!( - "Using project {:?} file", - rules_file.path_in_worktree - )), - rules_files => Some(format!("Using {} project rules files", rules_files.len())), - }; - - if user_rules_text.is_none() && rules_file_text.is_none() { - return None; - } - - let has_both = user_rules_text.is_some() && rules_file_text.is_some(); - - Some( - h_flex() - .px_2p5() - .child( - Icon::new(IconName::Attach) - .size(IconSize::XSmall) - .color(Color::Disabled), - ) - .when_some(user_rules_text, |parent, user_rules_text| { - parent.child( - h_flex() - .id("user-rules") - .ml_1() - .mr_1p5() - .child( - Label::new(user_rules_text) - .size(LabelSize::XSmall) - .color(Color::Muted) - .truncate(), - ) - .hover(|s| s.bg(cx.theme().colors().element_hover)) - .tooltip(Tooltip::text("View User Rules")) - .on_click(move |_event, window, cx| { - window.dispatch_action( - Box::new(OpenRulesLibrary { - prompt_to_select: first_user_rules_id, - }), - cx, - ) - }), - ) - }) - .when(has_both, |this| { - this.child( - Label::new("β€’") - .size(LabelSize::XSmall) - .color(Color::Disabled), - ) - }) - .when_some(rules_file_text, |parent, rules_file_text| { - parent.child( - h_flex() - .id("project-rules") - .ml_1p5() - .child( - Label::new(rules_file_text) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .hover(|s| s.bg(cx.theme().colors().element_hover)) - .tooltip(Tooltip::text("View Project Rules")) - .on_click(cx.listener(Self::handle_open_rules)), - ) - }) - .into_any(), - ) - } - - fn render_empty_state_section_header( - &self, - label: impl Into, - action_slot: Option, - cx: &mut Context, - ) -> impl IntoElement { - div().pl_1().pr_1p5().child( - h_flex() - .mt_2() - .pl_1p5() - .pb_1() - .w_full() - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border_variant) - .child( - Label::new(label.into()) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .children(action_slot), - ) - } - - fn render_resume_notice(&self, _cx: &Context) -> AnyElement { - let description = "This agent does not support viewing previous messages. However, your session will still continue from where you last left off."; - - div() - .px_2() - .pt_2() - .pb_3() - .w_full() - .child( - Callout::new() - .severity(Severity::Info) - .icon(IconName::Info) - .title("Resumed Session") - .description(description), - ) - .into_any_element() - } - - fn update_recent_history_from_cache( - &mut self, - history: &Entity, - cx: &mut Context, - ) { - self.recent_history_entries = history.read(cx).get_recent_sessions(3); - self.hovered_recent_history_item = None; - cx.notify(); - } - - fn render_recent_history(&self, cx: &mut Context) -> AnyElement { - let render_history = !self.recent_history_entries.is_empty(); - - v_flex() - .size_full() - .when(render_history, |this| { - let recent_history = self.recent_history_entries.clone(); - this.justify_end().child( - v_flex() - .child( - self.render_empty_state_section_header( - "Recent", - Some( - Button::new("view-history", "View All") - .style(ButtonStyle::Subtle) - .label_size(LabelSize::Small) - .key_binding( - KeyBinding::for_action_in( - &OpenHistory, - &self.focus_handle(cx), - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(move |_event, window, cx| { - window.dispatch_action(OpenHistory.boxed_clone(), cx); - }) - .into_any_element(), - ), - cx, - ), - ) - .child(v_flex().p_1().pr_1p5().gap_1().children({ - let supports_delete = self.history.read(cx).supports_delete(); - recent_history - .into_iter() - .enumerate() - .map(move |(index, entry)| { - // TODO: Add keyboard navigation. - let is_hovered = - self.hovered_recent_history_item == Some(index); - crate::acp::thread_history::AcpHistoryEntryElement::new( - entry, - cx.entity().downgrade(), - ) - .hovered(is_hovered) - .supports_delete(supports_delete) - .on_hover(cx.listener(move |this, is_hovered, _window, cx| { - if *is_hovered { - this.hovered_recent_history_item = Some(index); - } else if this.hovered_recent_history_item == Some(index) { - this.hovered_recent_history_item = None; - } - cx.notify(); - })) - .into_any_element() - }) - })), - ) - }) - .into_any() - } - - fn render_auth_required_state( - &self, - connection: &Rc, - description: Option<&Entity>, - configuration_view: Option<&AnyView>, - pending_auth_method: Option<&acp::AuthMethodId>, - window: &mut Window, - cx: &Context, - ) -> impl IntoElement { - let auth_methods = connection.auth_methods(); - - let agent_display_name = self - .agent_server_store - .read(cx) - .agent_display_name(&ExternalAgentServerName(self.agent.name())) - .unwrap_or_else(|| self.agent.name()); - - let show_fallback_description = auth_methods.len() > 1 - && configuration_view.is_none() - && description.is_none() - && pending_auth_method.is_none(); - - let auth_buttons = || { - h_flex().justify_end().flex_wrap().gap_1().children( - connection - .auth_methods() - .iter() - .enumerate() - .rev() - .map(|(ix, method)| { - let (method_id, name) = if self.project.read(cx).is_via_remote_server() - && method.id.0.as_ref() == "oauth-personal" - && method.name == "Log in with Google" - { - ("spawn-gemini-cli".into(), "Log in with Gemini CLI".into()) - } else { - (method.id.0.clone(), method.name.clone()) - }; - - let agent_telemetry_id = connection.telemetry_id(); - - Button::new(method_id.clone(), name) - .label_size(LabelSize::Small) - .map(|this| { - if ix == 0 { - this.style(ButtonStyle::Tinted(TintColor::Accent)) - } else { - this.style(ButtonStyle::Outlined) - } - }) - .when_some(method.description.clone(), |this, description| { - this.tooltip(Tooltip::text(description)) - }) - .on_click({ - cx.listener(move |this, _, window, cx| { - telemetry::event!( - "Authenticate Agent Started", - agent = agent_telemetry_id, - method = method_id - ); - - this.authenticate( - acp::AuthMethodId::new(method_id.clone()), - window, - cx, - ) - }) - }) - }), - ) - }; - - if pending_auth_method.is_some() { - return Callout::new() - .icon(IconName::Info) - .title(format!("Authenticating to {}…", agent_display_name)) - .actions_slot( - Icon::new(IconName::ArrowCircle) - .size(IconSize::Small) - .color(Color::Muted) - .with_rotate_animation(2) - .into_any_element(), - ) - .into_any_element(); - } - - Callout::new() - .icon(IconName::Info) - .title(format!("Authenticate to {}", agent_display_name)) - .when(auth_methods.len() == 1, |this| { - this.actions_slot(auth_buttons()) - }) - .description_slot( - v_flex() - .text_ui(cx) - .map(|this| { - if show_fallback_description { - this.child( - Label::new("Choose one of the following authentication options:") - .size(LabelSize::Small) - .color(Color::Muted), - ) - } else { - this.children( - configuration_view - .cloned() - .map(|view| div().w_full().child(view)), - ) - .children(description.map(|desc| { - self.render_markdown( - desc.clone(), - MarkdownStyle::themed(MarkdownFont::Agent, window, cx), - ) - })) - } - }) - .when(auth_methods.len() > 1, |this| { - this.gap_1().child(auth_buttons()) - }), - ) - .into_any_element() - } - - fn render_load_error( - &self, - e: &LoadError, - window: &mut Window, - cx: &mut Context, - ) -> AnyElement { - let (title, message, action_slot): (_, SharedString, _) = match e { - LoadError::Unsupported { - command: path, - current_version, - minimum_version, - } => { - return self.render_unsupported(path, current_version, minimum_version, window, cx); - } - LoadError::FailedToInstall(msg) => ( - "Failed to Install", - msg.into(), - Some(self.create_copy_button(msg.to_string()).into_any_element()), - ), - LoadError::Exited { status } => ( - "Failed to Launch", - format!("Server exited with status {status}").into(), - None, - ), - LoadError::Other(msg) => ( - "Failed to Launch", - msg.into(), - Some(self.create_copy_button(msg.to_string()).into_any_element()), - ), - }; - - Callout::new() - .severity(Severity::Error) - .icon(IconName::XCircleFilled) - .title(title) - .description(message) - .actions_slot(div().children(action_slot)) - .into_any_element() - } - - fn render_unsupported( - &self, - path: &SharedString, - version: &SharedString, - minimum_version: &SharedString, - _window: &mut Window, - cx: &mut Context, - ) -> AnyElement { - let (heading_label, description_label) = ( - format!("Upgrade {} to work with Zed", self.agent.name()), - if version.is_empty() { - format!( - "Currently using {}, which does not report a valid --version", - path, - ) - } else { - format!( - "Currently using {}, which is only version {} (need at least {minimum_version})", - path, version - ) - }, - ); - - v_flex() - .w_full() - .p_3p5() - .gap_2p5() - .border_t_1() - .border_color(cx.theme().colors().border) - .bg(linear_gradient( - 180., - linear_color_stop(cx.theme().colors().editor_background.opacity(0.4), 4.), - linear_color_stop(cx.theme().status().info_background.opacity(0.), 0.), - )) - .child( - v_flex().gap_0p5().child(Label::new(heading_label)).child( - Label::new(description_label) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .into_any_element() - } - - fn activity_bar_bg(&self, cx: &Context) -> Hsla { - let editor_bg_color = cx.theme().colors().editor_background; - let active_color = cx.theme().colors().element_selected; - editor_bg_color.blend(active_color.opacity(0.3)) - } - - fn render_activity_bar( - &self, - thread_entity: &Entity, - window: &mut Window, - cx: &Context, - ) -> Option { - let thread = thread_entity.read(cx); - let action_log = thread.action_log(); - let telemetry = ActionLogTelemetry::from(thread); - let changed_buffers = action_log.read(cx).changed_buffers(cx); - let plan = thread.plan(); - let queue_is_empty = !self.has_queued_messages(); - - if changed_buffers.is_empty() && plan.is_empty() && queue_is_empty { - return None; - } - - // Temporarily always enable ACP edit controls. This is temporary, to lessen the - // impact of a nasty bug that causes them to sometimes be disabled when they shouldn't - // be, which blocks you from being able to accept or reject edits. This switches the - // bug to be that sometimes it's enabled when it shouldn't be, which at least doesn't - // block you from using the panel. - let pending_edits = false; - - let Some(active) = self.as_active_thread() else { - return None; - }; - - v_flex() - .mt_1() - .mx_2() - .bg(self.activity_bar_bg(cx)) - .border_1() - .border_b_0() - .border_color(cx.theme().colors().border) - .rounded_t_md() - .shadow(vec![gpui::BoxShadow { - color: gpui::black().opacity(0.15), - offset: point(px(1.), px(-1.)), - blur_radius: px(3.), - spread_radius: px(0.), - }]) - .when(!plan.is_empty(), |this| { - this.child(self.render_plan_summary(plan, window, cx)) - .when(active.plan_expanded, |parent| { - parent.child(self.render_plan_entries(plan, window, cx)) - }) - }) - .when(!plan.is_empty() && !changed_buffers.is_empty(), |this| { - this.child(Divider::horizontal().color(DividerColor::Border)) - }) - .when(!changed_buffers.is_empty(), |this| { - this.child(self.render_edits_summary( - &changed_buffers, - active.edits_expanded, - pending_edits, - cx, - )) - .when(active.edits_expanded, |parent| { - parent.child(self.render_edited_files( - action_log, - telemetry.clone(), - &changed_buffers, - pending_edits, - cx, - )) - }) - }) - .when(!queue_is_empty, |this| { - this.when(!plan.is_empty() || !changed_buffers.is_empty(), |this| { - this.child(Divider::horizontal().color(DividerColor::Border)) - }) - .child(self.render_message_queue_summary(window, cx)) - .when(active.queue_expanded, |parent| { - parent.child(self.render_message_queue_entries(window, cx)) - }) - }) - .into_any() - .into() - } - - fn render_plan_summary( - &self, - plan: &Plan, - window: &mut Window, - cx: &Context, - ) -> impl IntoElement { - let Some(active) = self.as_active_thread() else { - return Empty.into_any_element(); - }; - let stats = plan.stats(); - - let title = if let Some(entry) = stats.in_progress_entry - && !active.plan_expanded - { - h_flex() - .cursor_default() - .relative() - .w_full() - .gap_1() - .truncate() - .child( - Label::new("Current:") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().colors().text_muted) - .line_clamp(1) - .child(MarkdownElement::new( - entry.content.clone(), - plan_label_markdown_style(&entry.status, window, cx), - )), - ) - .when(stats.pending > 0, |this| { - this.child( - h_flex() - .absolute() - .top_0() - .right_0() - .h_full() - .child(div().min_w_8().h_full().bg(linear_gradient( - 90., - linear_color_stop(self.activity_bar_bg(cx), 1.), - linear_color_stop(self.activity_bar_bg(cx).opacity(0.2), 0.), - ))) - .child( - div().pr_0p5().bg(self.activity_bar_bg(cx)).child( - Label::new(format!("{} left", stats.pending)) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ), - ) - }) - } else { - let status_label = if stats.pending == 0 { - "All Done".to_string() - } else if stats.completed == 0 { - format!("{} Tasks", plan.entries.len()) - } else { - format!("{}/{}", stats.completed, plan.entries.len()) - }; - - h_flex() - .w_full() - .gap_1() - .justify_between() - .child( - Label::new("Plan") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - Label::new(status_label) - .size(LabelSize::Small) - .color(Color::Muted) - .mr_1(), - ) - }; - - h_flex() - .id("plan_summary") - .p_1() - .w_full() - .gap_1() - .when(active.plan_expanded, |this| { - this.border_b_1().border_color(cx.theme().colors().border) - }) - .child(Disclosure::new("plan_disclosure", active.plan_expanded)) - .child(title) - .on_click(cx.listener(|this, _, _, cx| { - let Some(active) = this.as_active_thread_mut() else { - return; - }; - active.plan_expanded = !active.plan_expanded; - cx.notify(); - })) - .into_any_element() - } - - fn render_plan_entries( - &self, - plan: &Plan, - window: &mut Window, - cx: &Context, - ) -> impl IntoElement { - v_flex() - .id("plan_items_list") - .max_h_40() - .overflow_y_scroll() - .children(plan.entries.iter().enumerate().flat_map(|(index, entry)| { - let element = h_flex() - .py_1() - .px_2() - .gap_2() - .justify_between() - .bg(cx.theme().colors().editor_background) - .when(index < plan.entries.len() - 1, |parent| { - parent.border_color(cx.theme().colors().border).border_b_1() - }) - .child( - h_flex() - .id(("plan_entry", index)) - .gap_1p5() - .max_w_full() - .overflow_x_scroll() - .text_xs() - .text_color(cx.theme().colors().text_muted) - .child(match entry.status { - acp::PlanEntryStatus::InProgress => { - Icon::new(IconName::TodoProgress) - .size(IconSize::Small) - .color(Color::Accent) - .with_rotate_animation(2) - .into_any_element() - } - acp::PlanEntryStatus::Completed => { - Icon::new(IconName::TodoComplete) - .size(IconSize::Small) - .color(Color::Success) - .into_any_element() - } - acp::PlanEntryStatus::Pending | _ => { - Icon::new(IconName::TodoPending) - .size(IconSize::Small) - .color(Color::Muted) - .into_any_element() - } - }) - .child(MarkdownElement::new( - entry.content.clone(), - plan_label_markdown_style(&entry.status, window, cx), - )), - ); - - Some(element) - })) - .into_any_element() - } - - fn render_edits_summary( - &self, - changed_buffers: &BTreeMap, Entity>, - expanded: bool, - pending_edits: bool, - cx: &Context, - ) -> Div { - const EDIT_NOT_READY_TOOLTIP_LABEL: &str = "Wait until file edits are complete."; - - let focus_handle = self.focus_handle(cx); - - h_flex() - .p_1() - .justify_between() - .flex_wrap() - .when(expanded, |this| { - this.border_b_1().border_color(cx.theme().colors().border) - }) - .child( - h_flex() - .id("edits-container") - .cursor_pointer() - .gap_1() - .child(Disclosure::new("edits-disclosure", expanded)) - .map(|this| { - if pending_edits { - this.child( - Label::new(format!( - "Editing {} {}…", - changed_buffers.len(), - if changed_buffers.len() == 1 { - "file" - } else { - "files" - } - )) - .color(Color::Muted) - .size(LabelSize::Small) - .with_animation( - "edit-label", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.3, 0.7)), - |label, delta| label.alpha(delta), - ), - ) - } else { - let stats = DiffStats::all_files(changed_buffers, cx); - let dot_divider = || { - Label::new("β€’") - .size(LabelSize::XSmall) - .color(Color::Disabled) - }; - - this.child( - Label::new("Edits") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child(dot_divider()) - .child( - Label::new(format!( - "{} {}", - changed_buffers.len(), - if changed_buffers.len() == 1 { - "file" - } else { - "files" - } - )) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child(dot_divider()) - .child(DiffStat::new( - "total", - stats.lines_added as usize, - stats.lines_removed as usize, - )) - } - }) - .on_click(cx.listener(|this, _, _, cx| { - let Some(active) = this.as_active_thread_mut() else { - return; - }; - active.edits_expanded = !active.edits_expanded; - cx.notify(); - })), - ) - .child( - h_flex() - .gap_1() - .child( - IconButton::new("review-changes", IconName::ListTodo) - .icon_size(IconSize::Small) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |_window, cx| { - Tooltip::for_action_in( - "Review Changes", - &OpenAgentDiff, - &focus_handle, - cx, - ) - } - }) - .on_click(cx.listener(|_, _, window, cx| { - window.dispatch_action(OpenAgentDiff.boxed_clone(), cx); - })), - ) - .child(Divider::vertical().color(DividerColor::Border)) - .child( - Button::new("reject-all-changes", "Reject All") - .label_size(LabelSize::Small) - .disabled(pending_edits) - .when(pending_edits, |this| { - this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL)) - }) - .key_binding( - KeyBinding::for_action_in(&RejectAll, &focus_handle.clone(), cx) - .map(|kb| kb.size(rems_from_px(10.))), - ) - .on_click(cx.listener(move |this, _, window, cx| { - this.reject_all(&RejectAll, window, cx); - })), - ) - .child( - Button::new("keep-all-changes", "Keep All") - .label_size(LabelSize::Small) - .disabled(pending_edits) - .when(pending_edits, |this| { - this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL)) - }) - .key_binding( - KeyBinding::for_action_in(&KeepAll, &focus_handle, cx) - .map(|kb| kb.size(rems_from_px(10.))), - ) - .on_click(cx.listener(move |this, _, window, cx| { - this.keep_all(&KeepAll, window, cx); - })), - ), - ) - } - - fn render_edited_files_buttons( - &self, - index: usize, - buffer: &Entity, - action_log: &Entity, - telemetry: &ActionLogTelemetry, - pending_edits: bool, - editor_bg_color: Hsla, - cx: &Context, - ) -> impl IntoElement { - h_flex() - .id("edited-buttons-container") - .visible_on_hover("edited-code") - .absolute() - .right_0() - .px_1() - .gap_1() - .bg(editor_bg_color) - .on_hover(cx.listener(move |this, is_hovered, _window, cx| { - if let Some(active) = this.as_active_thread_mut() { - if *is_hovered { - active.hovered_edited_file_buttons = Some(index); - } else if active.hovered_edited_file_buttons == Some(index) { - active.hovered_edited_file_buttons = None; - } - } - cx.notify(); - })) - .child( - Button::new("review", "Review") - .label_size(LabelSize::Small) - .on_click({ - let buffer = buffer.clone(); - cx.listener(move |this, _, window, cx| { - this.open_edited_buffer(&buffer, window, cx); - }) - }), - ) - .child( - Button::new(("reject-file", index), "Reject") - .label_size(LabelSize::Small) - .disabled(pending_edits) - .on_click({ - let buffer = buffer.clone(); - let action_log = action_log.clone(); - let telemetry = telemetry.clone(); - move |_, _, cx| { - action_log.update(cx, |action_log, cx| { - action_log - .reject_edits_in_ranges( - buffer.clone(), - vec![Anchor::min_max_range_for_buffer( - buffer.read(cx).remote_id(), - )], - Some(telemetry.clone()), - cx, - ) - .detach_and_log_err(cx); - }) - } - }), - ) - .child( - Button::new(("keep-file", index), "Keep") - .label_size(LabelSize::Small) - .disabled(pending_edits) - .on_click({ - let buffer = buffer.clone(); - let action_log = action_log.clone(); - let telemetry = telemetry.clone(); - move |_, _, cx| { - action_log.update(cx, |action_log, cx| { - action_log.keep_edits_in_range( - buffer.clone(), - Anchor::min_max_range_for_buffer(buffer.read(cx).remote_id()), - Some(telemetry.clone()), - cx, - ); - }) - } - }), - ) - } - - fn render_edited_files( - &self, - action_log: &Entity, - telemetry: ActionLogTelemetry, - changed_buffers: &BTreeMap, Entity>, - pending_edits: bool, - cx: &Context, - ) -> impl IntoElement { - let editor_bg_color = cx.theme().colors().editor_background; - - // Sort edited files alphabetically for consistency with Git diff view - let mut sorted_buffers: Vec<_> = changed_buffers.iter().collect(); - sorted_buffers.sort_by(|(buffer_a, _), (buffer_b, _)| { - let path_a = buffer_a.read(cx).file().map(|f| f.path().clone()); - let path_b = buffer_b.read(cx).file().map(|f| f.path().clone()); - path_a.cmp(&path_b) - }); - - v_flex() - .id("edited_files_list") - .max_h_40() - .overflow_y_scroll() - .children( - sorted_buffers - .into_iter() - .enumerate() - .flat_map(|(index, (buffer, diff))| { - let file = buffer.read(cx).file()?; - let path = file.path(); - let path_style = file.path_style(cx); - let separator = file.path_style(cx).primary_separator(); - - let file_path = path.parent().and_then(|parent| { - if parent.is_empty() { - None - } else { - Some( - Label::new(format!( - "{}{separator}", - parent.display(path_style) - )) - .color(Color::Muted) - .size(LabelSize::XSmall) - .buffer_font(cx), - ) - } - }); - - let file_name = path.file_name().map(|name| { - Label::new(name.to_string()) - .size(LabelSize::XSmall) - .buffer_font(cx) - .ml_1() - }); - - let full_path = path.display(path_style).to_string(); - - let file_icon = FileIcons::get_icon(path.as_std_path(), cx) - .map(Icon::from_path) - .map(|icon| icon.color(Color::Muted).size(IconSize::Small)) - .unwrap_or_else(|| { - Icon::new(IconName::File) - .color(Color::Muted) - .size(IconSize::Small) - }); - - let file_stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx); - - let buttons = self.render_edited_files_buttons( - index, - buffer, - action_log, - &telemetry, - pending_edits, - editor_bg_color, - cx, - ); - - let element = h_flex() - .group("edited-code") - .id(("file-container", index)) - .relative() - .min_w_0() - .p_1p5() - .gap_2() - .justify_between() - .bg(editor_bg_color) - .when(index < changed_buffers.len() - 1, |parent| { - parent.border_color(cx.theme().colors().border).border_b_1() - }) - .child( - h_flex() - .id(("file-name-path", index)) - .cursor_pointer() - .pr_0p5() - .gap_0p5() - .rounded_xs() - .child(file_icon) - .children(file_name) - .children(file_path) - .child( - DiffStat::new( - "file", - file_stats.lines_added as usize, - file_stats.lines_removed as usize, - ) - .label_size(LabelSize::XSmall), - ) - .hover(|s| s.bg(cx.theme().colors().element_hover)) - .tooltip({ - move |_, cx| { - Tooltip::with_meta( - "Go to File", - None, - full_path.clone(), - cx, - ) - } - }) - .on_click({ - let buffer = buffer.clone(); - cx.listener(move |this, _, window, cx| { - this.open_edited_buffer(&buffer, window, cx); - }) - }), - ) - .child(buttons); - - Some(element) - }), - ) - .into_any_element() - } - - fn render_message_queue_summary( - &self, - _window: &mut Window, - cx: &Context, - ) -> impl IntoElement { - let queue_count = self.queued_messages_len(); - let title: SharedString = if queue_count == 1 { - "1 Queued Message".into() - } else { - format!("{} Queued Messages", queue_count).into() - }; - - let Some(active) = self.as_active_thread() else { - return Empty.into_any_element(); - }; - - h_flex() - .p_1() - .w_full() - .gap_1() - .justify_between() - .when(active.queue_expanded, |this| { - this.border_b_1().border_color(cx.theme().colors().border) - }) - .child( - h_flex() - .id("queue_summary") - .gap_1() - .child(Disclosure::new("queue_disclosure", active.queue_expanded)) - .child(Label::new(title).size(LabelSize::Small).color(Color::Muted)) - .on_click(cx.listener(|this, _, _, cx| { - let Some(active) = this.as_active_thread_mut() else { - return; - }; - active.queue_expanded = !active.queue_expanded; - cx.notify(); - })), - ) - .child( - Button::new("clear_queue", "Clear All") - .label_size(LabelSize::Small) - .key_binding(KeyBinding::for_action(&ClearMessageQueue, cx)) - .on_click(cx.listener(|this, _, _, cx| { - this.clear_queue(cx); - if let Some(active) = this.as_active_thread_mut() { - active.can_fast_track_queue = false; - } - cx.notify(); - })), - ) - .into_any_element() - } - - fn render_message_queue_entries( - &self, - _window: &mut Window, - cx: &Context, - ) -> impl IntoElement { - let message_editor = self.message_editor.read(cx); - let focus_handle = message_editor.focus_handle(cx); - - let queued_message_editors = self - .as_connected() - .map(|c| c.current.queued_message_editors.as_slice()) - .unwrap_or(&[]); - - let queue_len = queued_message_editors.len(); - let can_fast_track = if let Some(active) = self.as_active_thread() { - active.can_fast_track_queue && queue_len > 0 - } else { - false - }; - - v_flex() - .id("message_queue_list") - .max_h_40() - .overflow_y_scroll() - .children( - queued_message_editors - .iter() - .enumerate() - .map(|(index, editor)| { - let is_next = index == 0; - let (icon_color, tooltip_text) = if is_next { - (Color::Accent, "Next in Queue") - } else { - (Color::Muted, "In Queue") - }; - - let editor_focused = editor.focus_handle(cx).is_focused(_window); - let keybinding_size = rems_from_px(12.); - - h_flex() - .group("queue_entry") - .w_full() - .p_1p5() - .gap_1() - .bg(cx.theme().colors().editor_background) - .when(index < queue_len - 1, |this| { - this.border_b_1() - .border_color(cx.theme().colors().border_variant) - }) - .child( - div() - .id("next_in_queue") - .child( - Icon::new(IconName::Circle) - .size(IconSize::Small) - .color(icon_color), - ) - .tooltip(Tooltip::text(tooltip_text)), - ) - .child(editor.clone()) - .child(if editor_focused { - h_flex() - .gap_1() - .min_w_40() - .child( - IconButton::new(("cancel_edit", index), IconName::Close) - .icon_size(IconSize::Small) - .icon_color(Color::Error) - .tooltip({ - let focus_handle = editor.focus_handle(cx); - move |_window, cx| { - Tooltip::for_action_in( - "Cancel Edit", - &editor::actions::Cancel, - &focus_handle, - cx, - ) - } - }) - .on_click({ - let main_editor = self.message_editor.clone(); - cx.listener(move |_, _, window, cx| { - window.focus(&main_editor.focus_handle(cx), cx); - }) - }), - ) - .child( - IconButton::new(("save_edit", index), IconName::Check) - .icon_size(IconSize::Small) - .icon_color(Color::Success) - .tooltip({ - let focus_handle = editor.focus_handle(cx); - move |_window, cx| { - Tooltip::for_action_in( - "Save Edit", - &Chat, - &focus_handle, - cx, - ) - } - }) - .on_click({ - let main_editor = self.message_editor.clone(); - cx.listener(move |_, _, window, cx| { - window.focus(&main_editor.focus_handle(cx), cx); - }) - }), - ) - .child( - Button::new(("send_now_focused", index), "Send Now") - .label_size(LabelSize::Small) - .style(ButtonStyle::Outlined) - .key_binding( - KeyBinding::for_action_in( - &SendImmediately, - &editor.focus_handle(cx), - cx, - ) - .map(|kb| kb.size(keybinding_size)), - ) - .on_click(cx.listener(move |this, _, window, cx| { - this.send_queued_message_at_index( - index, true, window, cx, - ); - })), - ) - } else { - h_flex() - .gap_1() - .when(!is_next, |this| this.visible_on_hover("queue_entry")) - .child( - IconButton::new(("edit", index), IconName::Pencil) - .icon_size(IconSize::Small) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |_window, cx| { - if is_next { - Tooltip::for_action_in( - "Edit", - &EditFirstQueuedMessage, - &focus_handle, - cx, - ) - } else { - Tooltip::simple("Edit", cx) - } - } - }) - .on_click({ - let editor = editor.clone(); - cx.listener(move |_, _, window, cx| { - window.focus(&editor.focus_handle(cx), cx); - }) - }), - ) - .child( - IconButton::new(("delete", index), IconName::Trash) - .icon_size(IconSize::Small) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |_window, cx| { - if is_next { - Tooltip::for_action_in( - "Remove Message from Queue", - &RemoveFirstQueuedMessage, - &focus_handle, - cx, - ) - } else { - Tooltip::simple( - "Remove Message from Queue", - cx, - ) - } - } - }) - .on_click(cx.listener(move |this, _, _, cx| { - this.remove_from_queue(index, cx); - cx.notify(); - })), - ) - .child( - Button::new(("send_now", index), "Send Now") - .label_size(LabelSize::Small) - .when(is_next && message_editor.is_empty(cx), |this| { - let action: Box = - if can_fast_track { - Box::new(Chat) - } else { - Box::new(SendNextQueuedMessage) - }; - - this.style(ButtonStyle::Outlined).key_binding( - KeyBinding::for_action_in( - action.as_ref(), - &focus_handle.clone(), - cx, - ) - .map(|kb| kb.size(keybinding_size)), - ) - }) - .when(is_next && !message_editor.is_empty(cx), |this| { - this.style(ButtonStyle::Outlined) - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.send_queued_message_at_index( - index, true, window, cx, - ); - })), - ) - }) - }), - ) - .into_any_element() - } - - fn render_message_editor(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { - let focus_handle = self.message_editor.focus_handle(cx); - let editor_bg_color = cx.theme().colors().editor_background; - let editor_expanded = self - .as_active_thread() - .is_some_and(|active| active.editor_expanded); - let (expand_icon, expand_tooltip) = if editor_expanded { - (IconName::Minimize, "Minimize Message Editor") - } else { - (IconName::Maximize, "Expand Message Editor") - }; - - let backdrop = div() - .size_full() - .absolute() - .inset_0() - .bg(cx.theme().colors().panel_background) - .opacity(0.8) - .block_mouse_except_scroll(); - - let enable_editor = self - .as_connected() - .is_some_and(|conn| conn.auth_state.is_ok()); - - v_flex() - .on_action(cx.listener(Self::expand_message_editor)) - .p_2() - .gap_2() - .border_t_1() - .border_color(cx.theme().colors().border) - .bg(editor_bg_color) - .when(editor_expanded, |this| { - this.h(vh(0.8, window)).size_full().justify_between() - }) - .child( - v_flex() - .relative() - .size_full() - .pt_1() - .pr_2p5() - .child(self.message_editor.clone()) - .child( - h_flex() - .absolute() - .top_0() - .right_0() - .opacity(0.5) - .hover(|this| this.opacity(1.0)) - .child( - IconButton::new("toggle-height", expand_icon) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip({ - move |_window, cx| { - Tooltip::for_action_in( - expand_tooltip, - &ExpandMessageEditor, - &focus_handle, - cx, - ) - } - }) - .on_click(cx.listener(|this, _, window, cx| { - this.expand_message_editor( - &ExpandMessageEditor, - window, - cx, - ); - })), - ), - ), - ) - .child( - h_flex() - .flex_none() - .flex_wrap() - .justify_between() - .child( - h_flex() - .gap_0p5() - .child(self.render_add_context_button(cx)) - .child(self.render_follow_toggle(cx)) - .children(self.render_thinking_toggle(cx)), - ) - .child( - h_flex() - .gap_1() - .children(self.render_token_usage(cx)) - .when_some(self.as_active_thread(), |this, active| { - this.children(active.profile_selector.clone()).map(|this| { - // Either config_options_view OR (mode_selector + model_selector) - match active.config_options_view.clone() { - Some(config_view) => this.child(config_view), - None => this - .children(active.mode_selector.clone()) - .children(active.model_selector.clone()), - } - }) - }) - .child(self.render_send_button(cx)), - ), - ) - .when(!enable_editor, |this| this.child(backdrop)) - .into_any() - } - - pub(crate) fn as_native_connection( - &self, - cx: &App, - ) -> Option> { - let acp_thread = self.as_active_thread()?.thread.read(cx); - acp_thread.connection().clone().downcast() - } - - pub(crate) fn as_native_thread(&self, cx: &App) -> Option> { - let acp_thread = self.as_active_thread()?.thread.read(cx); - self.as_native_connection(cx)? - .thread(acp_thread.session_id(), cx) - } - - fn queued_messages_len(&self) -> usize { - self.as_active_thread() - .map(|thread| thread.local_queued_messages.len()) - .unwrap_or_default() - } - - fn has_queued_messages(&self) -> bool { - self.as_active_thread() - .map(|active| active.has_queued_messages()) - .unwrap_or(false) - } - - /// Syncs the has_queued_message flag to the native thread (if applicable). - /// This flag tells the native thread to end its turn at the next message boundary. - fn sync_queue_flag_to_native_thread(&self, cx: &mut Context) { - if let Some(active) = self.as_active_thread() { - active.sync_queue_flag_to_native_thread(cx); - } - } - - fn add_to_queue( - &mut self, - content: Vec, - tracked_buffers: Vec>, - cx: &mut Context, - ) { - if let Some(active) = self.as_active_thread_mut() { - active.local_queued_messages.push(QueuedMessage { - content, - tracked_buffers, - }); - } - self.sync_queue_flag_to_native_thread(cx); - } - - fn remove_from_queue(&mut self, index: usize, cx: &mut Context) -> Option { - self.as_active_thread_mut() - .and_then(|active| active.remove_from_queue(index, cx)) - } - - fn update_queued_message( - &mut self, - index: usize, - content: Vec, - tracked_buffers: Vec>, - _cx: &mut Context, - ) -> bool { - match self.as_active_thread_mut() { - Some(thread) if index < thread.local_queued_messages.len() => { - thread.local_queued_messages[index] = QueuedMessage { - content, - tracked_buffers, - }; - true - } - Some(_) | None => false, - } - } - - fn clear_queue(&mut self, cx: &mut Context) { - if let Some(active) = self.as_active_thread_mut() { - active.local_queued_messages.clear(); - } - self.sync_queue_flag_to_native_thread(cx); - } - - fn queued_message_contents(&self) -> Vec> { - match self.as_active_thread() { - None => Vec::new(), - Some(thread) => thread - .local_queued_messages - .iter() - .map(|q| q.content.clone()) - .collect(), - } - } - - fn save_queued_message_at_index(&mut self, index: usize, cx: &mut Context) { - let editor = match self.as_active_thread() { - Some(thread) => thread.queued_message_editors.get(index).cloned(), - None => None, - }; - let Some(editor) = editor else { - return; - }; - - let contents_task = editor.update(cx, |editor, cx| editor.contents(false, cx)); - - cx.spawn(async move |this, cx| { - let Ok((content, tracked_buffers)) = contents_task.await else { - return Ok::<(), anyhow::Error>(()); - }; - - this.update(cx, |this, cx| { - this.update_queued_message(index, content, tracked_buffers, cx); - cx.notify(); - })?; - - Ok(()) - }) - .detach_and_log_err(cx); - } - - fn sync_queued_message_editors(&mut self, window: &mut Window, cx: &mut Context) { - let needed_count = self.queued_messages_len(); - let queued_messages = self.queued_message_contents(); - - let agent_name = self.agent.name(); - let workspace = self.workspace.clone(); - let project = self.project.downgrade(); - let history = self.history.downgrade(); - let message_editor = self.message_editor.clone(); - - let Some(thread) = self.as_active_thread_mut() else { - return; - }; - let prompt_capabilities = thread.prompt_capabilities.clone(); - let available_commands = thread.available_commands.clone(); - - let current_count = thread.queued_message_editors.len(); - - if current_count == needed_count && needed_count == thread.last_synced_queue_length { - return; - } - - if current_count > needed_count { - thread.queued_message_editors.truncate(needed_count); - thread - .queued_message_editor_subscriptions - .truncate(needed_count); - - for (index, editor) in thread.queued_message_editors.iter().enumerate() { - if let Some(content) = queued_messages.get(index) { - editor.update(cx, |editor, cx| { - editor.set_message(content.clone(), window, cx); - }); - } - } - } - - while thread.queued_message_editors.len() < needed_count { - let index = thread.queued_message_editors.len(); - let content = queued_messages.get(index).cloned().unwrap_or_default(); - - let editor = cx.new(|cx| { - let mut editor = MessageEditor::new( - workspace.clone(), - project.clone(), - None, - history.clone(), - None, - prompt_capabilities.clone(), - available_commands.clone(), - agent_name.clone(), - "", - EditorMode::AutoHeight { - min_lines: 1, - max_lines: Some(10), - }, - window, - cx, - ); - editor.set_message(content, window, cx); - editor - }); - - let message_editor = message_editor.clone(); - let subscription = cx.subscribe_in( - &editor, - window, - move |this, _editor, event, window, cx| match event { - MessageEditorEvent::LostFocus => { - this.save_queued_message_at_index(index, cx); - } - MessageEditorEvent::Cancel => { - window.focus(&message_editor.focus_handle(cx), cx); - } - MessageEditorEvent::Send => { - window.focus(&message_editor.focus_handle(cx), cx); - } - MessageEditorEvent::SendImmediately => { - this.send_queued_message_at_index(index, true, window, cx); - } - _ => {} - }, - ); - - thread.queued_message_editors.push(editor); - thread - .queued_message_editor_subscriptions - .push(subscription); - } - - if let Some(active) = self.as_active_thread_mut() { - active.last_synced_queue_length = needed_count; - } - } - - fn is_imported_thread(&self, cx: &App) -> bool { - if let Some(active) = self.as_active_thread() { - active.is_imported_thread(cx) - } else { - false - } - } - - fn supports_split_token_display(&self, cx: &App) -> bool { - self.as_native_thread(cx) - .and_then(|thread| thread.read(cx).model()) - .is_some_and(|model| model.supports_split_token_display()) - } - - fn render_token_usage(&self, cx: &mut Context) -> Option
{ - let active = self.as_active_thread()?; - let thread = active.thread.read(cx); - let usage = thread.token_usage()?; - let is_generating = thread.status() != ThreadStatus::Idle; - let show_split = self.supports_split_token_display(cx); - - let separator_color = Color::Custom(cx.theme().colors().text_muted.opacity(0.5)); - let token_label = |text: String, animation_id: &'static str| { - Label::new(text) - .size(LabelSize::Small) - .color(Color::Muted) - .map(|label| { - if is_generating { - label - .with_animation( - animation_id, - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.3, 0.8)), - |label, delta| label.alpha(delta), - ) - .into_any() - } else { - label.into_any_element() - } - }) - }; - - if show_split { - let max_output_tokens = self - .as_native_thread(cx) - .and_then(|thread| thread.read(cx).model()) - .and_then(|model| model.max_output_tokens()) - .unwrap_or(0); - - let input = crate::text_thread_editor::humanize_token_count(usage.input_tokens); - let input_max = crate::text_thread_editor::humanize_token_count( - usage.max_tokens.saturating_sub(max_output_tokens), - ); - let output = crate::text_thread_editor::humanize_token_count(usage.output_tokens); - let output_max = crate::text_thread_editor::humanize_token_count(max_output_tokens); - - Some( - h_flex() - .flex_shrink_0() - .gap_1() - .mr_1p5() - .child( - h_flex() - .gap_0p5() - .child( - Icon::new(IconName::ArrowUp) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child(token_label(input, "input-tokens-label")) - .child( - Label::new("/") - .size(LabelSize::Small) - .color(separator_color), - ) - .child( - Label::new(input_max) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .child( - h_flex() - .gap_0p5() - .child( - Icon::new(IconName::ArrowDown) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child(token_label(output, "output-tokens-label")) - .child( - Label::new("/") - .size(LabelSize::Small) - .color(separator_color), - ) - .child( - Label::new(output_max) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ), - ) - } else { - let used = crate::text_thread_editor::humanize_token_count(usage.used_tokens); - let max = crate::text_thread_editor::humanize_token_count(usage.max_tokens); - - Some( - h_flex() - .flex_shrink_0() - .gap_0p5() - .mr_1p5() - .child(token_label(used, "used-tokens-label")) - .child( - Label::new("/") - .size(LabelSize::Small) - .color(separator_color), - ) - .child(Label::new(max).size(LabelSize::Small).color(Color::Muted)), - ) - } - } - - fn render_thinking_toggle(&self, cx: &mut Context) -> Option { - if !cx.has_flag::() { - return None; - } - - let thread = self.as_native_thread(cx)?.read(cx); - - let supports_thinking = thread.model()?.supports_thinking(); - if !supports_thinking { - return None; - } - - let thinking = thread.thinking_enabled(); - - let (tooltip_label, icon) = if thinking { - ("Disable Thinking Mode", IconName::ThinkingMode) - } else { - ("Enable Thinking Mode", IconName::ToolThink) - }; - - let focus_handle = self.message_editor.focus_handle(cx); - - Some( - IconButton::new("thinking-mode", icon) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .toggle_state(thinking) - .tooltip(move |_, cx| { - Tooltip::for_action_in(tooltip_label, &ToggleThinkingMode, &focus_handle, cx) - }) - .on_click(cx.listener(move |this, _, _window, cx| { - if let Some(thread) = this.as_native_thread(cx) { - thread.update(cx, |thread, cx| { - thread.set_thinking_enabled(!thread.thinking_enabled(), cx); - }); - } - })), - ) - } - - fn keep_all(&mut self, _: &KeepAll, _window: &mut Window, cx: &mut Context) { - if let Some(active) = self.as_active_thread_mut() { - active.keep_all(cx); - }; - } - - fn reject_all(&mut self, _: &RejectAll, _window: &mut Window, cx: &mut Context) { - if let Some(active) = self.as_active_thread_mut() { - active.reject_all(cx); - }; - } - - fn allow_always(&mut self, _: &AllowAlways, window: &mut Window, cx: &mut Context) { - self.authorize_pending_tool_call(acp::PermissionOptionKind::AllowAlways, window, cx); - } - - fn allow_once(&mut self, _: &AllowOnce, window: &mut Window, cx: &mut Context) { - self.authorize_pending_with_granularity(true, window, cx); - } - - fn reject_once(&mut self, _: &RejectOnce, window: &mut Window, cx: &mut Context) { - self.authorize_pending_with_granularity(false, window, cx); - } - - fn authorize_pending_with_granularity( - &mut self, - is_allow: bool, - window: &mut Window, - cx: &mut Context, - ) -> Option<()> { - let active = self.as_active_thread()?; - let thread = active.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 = if let Some(active) = self.as_active_thread() { - active - .selected_permission_granularity - .get(&tool_call_id) - .copied() - .unwrap_or_else(|| choices.len().saturating_sub(1)) - } 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(()) - } - - fn open_permission_dropdown( - &mut self, - _: &crate::OpenPermissionDropdown, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(active) = self.as_active_thread() { - active.permission_dropdown_handle.toggle(window, cx); - } - } - - fn handle_select_permission_granularity( - &mut self, - action: &SelectPermissionGranularity, - _window: &mut Window, - cx: &mut Context, - ) { - if let Some(active) = self.as_active_thread_mut() { - active.handle_select_permission_granularity(action, cx); - } - } - - fn handle_authorize_tool_call( - &mut self, - action: &AuthorizeToolCall, - window: &mut Window, - cx: &mut Context, - ) { - 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); - } - - fn authorize_pending_tool_call( - &mut self, - kind: acp::PermissionOptionKind, - window: &mut Window, - cx: &mut Context, - ) -> Option<()> { - self.as_active_thread_mut()? - .authorize_pending_tool_call(kind, window, cx) - } - - fn render_send_button(&self, cx: &mut Context) -> AnyElement { - let message_editor = self.message_editor.read(cx); - let is_editor_empty = message_editor.is_empty(cx); - let focus_handle = message_editor.focus_handle(cx); - - let is_generating = self - .as_active_thread() - .is_some_and(|active| active.thread.read(cx).status() != ThreadStatus::Idle); - - if self - .as_active_thread() - .is_some_and(|thread| thread.is_loading_contents) - { - div() - .id("loading-message-content") - .px_1() - .tooltip(Tooltip::text("Loading Added Context…")) - .child(loading_contents_spinner(IconSize::default())) - .into_any_element() - } else if is_generating && is_editor_empty { - IconButton::new("stop-generation", IconName::Stop) - .icon_color(Color::Error) - .style(ButtonStyle::Tinted(TintColor::Error)) - .tooltip(move |_window, cx| { - Tooltip::for_action("Stop Generation", &editor::actions::Cancel, cx) - }) - .on_click(cx.listener(|this, _event, _, cx| this.cancel_generation(cx))) - .into_any_element() - } else { - IconButton::new("send-message", IconName::Send) - .style(ButtonStyle::Filled) - .map(|this| { - if is_editor_empty && !is_generating { - this.disabled(true).icon_color(Color::Muted) - } else { - this.icon_color(Color::Accent) - } - }) - .tooltip(move |_window, cx| { - if is_editor_empty && !is_generating { - Tooltip::for_action("Type to Send", &Chat, cx) - } else if is_generating { - let focus_handle = focus_handle.clone(); - - Tooltip::element(move |_window, cx| { - v_flex() - .gap_1() - .child( - h_flex() - .gap_2() - .justify_between() - .child(Label::new("Queue and Send")) - .child(KeyBinding::for_action_in(&Chat, &focus_handle, cx)), - ) - .child( - h_flex() - .pt_1() - .gap_2() - .justify_between() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child(Label::new("Send Immediately")) - .child(KeyBinding::for_action_in( - &SendImmediately, - &focus_handle, - cx, - )), - ) - .into_any_element() - })(_window, cx) - } else { - Tooltip::for_action("Send Message", &Chat, cx) - } - }) - .on_click(cx.listener(|this, _, window, cx| { - this.send(window, cx); - })) - .into_any_element() - } - } - - fn is_following(&self, cx: &App) -> bool { - match self - .as_active_thread() - .map(|active| active.thread.read(cx).status()) - { - Some(ThreadStatus::Generating) => self - .workspace - .read_with(cx, |workspace, _| { - workspace.is_being_followed(CollaboratorId::Agent) - }) - .unwrap_or(false), - _ => self - .as_active_thread() - .is_some_and(|thread| thread.should_be_following), - } - } - - fn toggle_following(&mut self, window: &mut Window, cx: &mut Context) { - let following = self.is_following(cx); - - if let Some(active) = self.as_active_thread_mut() { - active.should_be_following = !following; - } - if self - .as_active_thread() - .map(|active| active.thread.read(cx).status()) - == Some(ThreadStatus::Generating) - { - self.workspace - .update(cx, |workspace, cx| { - if following { - workspace.unfollow(CollaboratorId::Agent, window, cx); - } else { - workspace.follow(CollaboratorId::Agent, window, cx); - } - }) - .ok(); - } - - telemetry::event!("Follow Agent Selected", following = !following); - } - - fn render_follow_toggle(&self, cx: &mut Context) -> impl IntoElement { - let following = self.is_following(cx); - - let tooltip_label = if following { - if self.agent.name() == "Zed Agent" { - format!("Stop Following the {}", self.agent.name()) - } else { - format!("Stop Following {}", self.agent.name()) - } - } else { - if self.agent.name() == "Zed Agent" { - format!("Follow the {}", self.agent.name()) - } else { - format!("Follow {}", self.agent.name()) - } - }; - - IconButton::new("follow-agent", IconName::Crosshair) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .toggle_state(following) - .selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor))) - .tooltip(move |_window, cx| { - if following { - Tooltip::for_action(tooltip_label.clone(), &Follow, cx) - } else { - Tooltip::with_meta( - tooltip_label.clone(), - Some(&Follow), - "Track the agent's location as it reads and edits files.", - cx, - ) - } - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.toggle_following(window, cx); - })) - } - - fn render_add_context_button(&mut self, cx: &mut Context) -> impl IntoElement { - let focus_handle = self.message_editor.focus_handle(cx); - let weak_self = cx.weak_entity(); - - PopoverMenu::new("add-context-menu") - .trigger_with_tooltip( - IconButton::new("add-context", IconName::Plus) - .icon_size(IconSize::Small) - .icon_color(Color::Muted), - { - move |_window, cx| { - Tooltip::for_action_in( - "Add Context", - &OpenAddContextMenu, - &focus_handle, - cx, - ) - } - }, - ) - .anchor(gpui::Corner::BottomLeft) - .with_handle(self.add_context_menu_handle.clone()) - .offset(gpui::Point { - x: px(0.0), - y: px(-2.0), - }) - .menu(move |window, cx| { - weak_self - .update(cx, |this, cx| this.build_add_context_menu(window, cx)) - .ok() - }) - } - - fn build_add_context_menu( + fn render_unsupported( &self, - window: &mut Window, + path: &SharedString, + version: &SharedString, + minimum_version: &SharedString, + _window: &mut Window, cx: &mut Context, - ) -> Entity { - let message_editor = self.message_editor.clone(); - let workspace = self.workspace.clone(); - let supports_images = self - .as_active_thread() - .map(|active| active.prompt_capabilities.borrow().image) - .unwrap_or_default(); - - let has_editor_selection = workspace - .upgrade() - .and_then(|ws| { - ws.read(cx) - .active_item(cx) - .and_then(|item| item.downcast::()) - }) - .is_some_and(|editor| { - editor.update(cx, |editor, cx| { - editor.has_non_empty_selection(&editor.display_snapshot(cx)) - }) - }); - - let has_terminal_selection = workspace - .upgrade() - .and_then(|ws| ws.read(cx).panel::(cx)) - .is_some_and(|panel| !panel.read(cx).terminal_selections(cx).is_empty()); - - let has_selection = has_editor_selection || has_terminal_selection; - - ContextMenu::build(window, cx, move |menu, _window, _cx| { - menu.key_context("AddContextMenu") - .header("Context") - .item( - ContextMenuEntry::new("Files & Directories") - .icon(IconName::File) - .icon_color(Color::Muted) - .icon_size(IconSize::XSmall) - .handler({ - let message_editor = message_editor.clone(); - move |window, cx| { - message_editor.focus_handle(cx).focus(window, cx); - message_editor.update(cx, |editor, cx| { - editor.insert_context_type("file", window, cx); - }); - } - }), - ) - .item( - ContextMenuEntry::new("Symbols") - .icon(IconName::Code) - .icon_color(Color::Muted) - .icon_size(IconSize::XSmall) - .handler({ - let message_editor = message_editor.clone(); - move |window, cx| { - message_editor.focus_handle(cx).focus(window, cx); - message_editor.update(cx, |editor, cx| { - editor.insert_context_type("symbol", window, cx); - }); - } - }), - ) - .item( - ContextMenuEntry::new("Threads") - .icon(IconName::Thread) - .icon_color(Color::Muted) - .icon_size(IconSize::XSmall) - .handler({ - let message_editor = message_editor.clone(); - move |window, cx| { - message_editor.focus_handle(cx).focus(window, cx); - message_editor.update(cx, |editor, cx| { - editor.insert_context_type("thread", window, cx); - }); - } - }), - ) - .item( - ContextMenuEntry::new("Rules") - .icon(IconName::Reader) - .icon_color(Color::Muted) - .icon_size(IconSize::XSmall) - .handler({ - let message_editor = message_editor.clone(); - move |window, cx| { - message_editor.focus_handle(cx).focus(window, cx); - message_editor.update(cx, |editor, cx| { - editor.insert_context_type("rule", window, cx); - }); - } - }), - ) - .item( - ContextMenuEntry::new("Image") - .icon(IconName::Image) - .icon_color(Color::Muted) - .icon_size(IconSize::XSmall) - .disabled(!supports_images) - .handler({ - let message_editor = message_editor.clone(); - move |window, cx| { - message_editor.focus_handle(cx).focus(window, cx); - message_editor.update(cx, |editor, cx| { - editor.add_images_from_picker(window, cx); - }); - } - }), + ) -> AnyElement { + let (heading_label, description_label) = ( + format!("Upgrade {} to work with Zed", self.agent.name()), + if version.is_empty() { + format!( + "Currently using {}, which does not report a valid --version", + path, ) - .item( - ContextMenuEntry::new("Selection") - .icon(IconName::CursorIBeam) - .icon_color(Color::Muted) - .icon_size(IconSize::XSmall) - .disabled(!has_selection) - .handler({ - move |window, cx| { - window.dispatch_action( - zed_actions::agent::AddSelectionToThread.boxed_clone(), - cx, - ); - } - }), + } else { + format!( + "Currently using {}, which is only version {} (need at least {minimum_version})", + path, version ) - }) - } + }, + ); - fn open_add_context_menu( - &mut self, - _action: &OpenAddContextMenu, - window: &mut Window, - cx: &mut Context, - ) { - let menu_handle = self.add_context_menu_handle.clone(); - window.defer(cx, move |window, cx| { - menu_handle.toggle(window, cx); - }); + v_flex() + .w_full() + .p_3p5() + .gap_2p5() + .border_t_1() + .border_color(cx.theme().colors().border) + .bg(linear_gradient( + 180., + linear_color_stop(cx.theme().colors().editor_background.opacity(0.4), 4.), + linear_color_stop(cx.theme().status().info_background.opacity(0.), 0.), + )) + .child( + v_flex().gap_0p5().child(Label::new(heading_label)).child( + Label::new(description_label) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .into_any_element() } - fn render_markdown(&self, markdown: Entity, style: MarkdownStyle) -> MarkdownElement { - let workspace = self.workspace.clone(); - MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| { - Self::open_link(text, &workspace, window, cx); - }) + pub(crate) fn as_native_connection( + &self, + cx: &App, + ) -> Option> { + let acp_thread = self.as_active_thread()?.read(cx).thread.read(cx); + acp_thread.connection().clone().downcast() } - fn open_link( - url: SharedString, - workspace: &WeakEntity, - window: &mut Window, - cx: &mut App, - ) { - let Some(workspace) = workspace.upgrade() else { - cx.open_url(&url); - return; - }; - - if let Some(mention) = MentionUri::parse(&url, workspace.read(cx).path_style(cx)).log_err() - { - workspace.update(cx, |workspace, cx| match mention { - MentionUri::File { abs_path } => { - let project = workspace.project(); - let Some(path) = - project.update(cx, |project, cx| project.find_project_path(abs_path, cx)) - else { - return; - }; - - workspace - .open_path(path, None, true, window, cx) - .detach_and_log_err(cx); - } - MentionUri::PastedImage => {} - MentionUri::Directory { abs_path } => { - let project = workspace.project(); - let Some(entry_id) = project.update(cx, |project, cx| { - let path = project.find_project_path(abs_path, cx)?; - project.entry_for_path(&path, cx).map(|entry| entry.id) - }) else { - return; - }; + pub(crate) fn as_native_thread(&self, cx: &App) -> Option> { + let acp_thread = self.as_active_thread()?.read(cx).thread.read(cx); + self.as_native_connection(cx)? + .thread(acp_thread.session_id(), cx) + } - project.update(cx, |_, cx| { - cx.emit(project::Event::RevealInProjectPanel(entry_id)); - }); - } - MentionUri::Symbol { - abs_path: path, - line_range, - .. - } - | MentionUri::Selection { - abs_path: Some(path), - line_range, - } => { - let project = workspace.project(); - let Some(path) = - project.update(cx, |project, cx| project.find_project_path(path, cx)) - else { - return; - }; + fn queued_messages_len(&self, cx: &App) -> usize { + self.as_active_thread() + .map(|thread| thread.read(cx).local_queued_messages.len()) + .unwrap_or_default() + } - let item = workspace.open_path(path, None, true, window, cx); - window - .spawn(cx, async move |cx| { - let Some(editor) = item.await?.downcast::() else { - return Ok(()); - }; - let range = Point::new(*line_range.start(), 0) - ..Point::new(*line_range.start(), 0); - editor - .update_in(cx, |editor, window, cx| { - editor.change_selections( - SelectionEffects::scroll(Autoscroll::center()), - window, - cx, - |s| s.select_ranges(vec![range]), - ); - }) - .ok(); - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - MentionUri::Selection { abs_path: None, .. } => {} - MentionUri::Thread { id, name } => { - if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| { - panel.open_thread( - AgentSessionInfo { - session_id: id, - cwd: None, - title: Some(name.into()), - updated_at: None, - meta: None, - }, - window, - cx, - ) - }); - } - } - MentionUri::TextThread { path, .. } => { - if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| { - panel - .open_saved_text_thread(path.as_path().into(), window, cx) - .detach_and_log_err(cx); - }); - } - } - MentionUri::Rule { id, .. } => { - let PromptId::User { uuid } = id else { - return; + fn update_queued_message( + &mut self, + index: usize, + content: Vec, + tracked_buffers: Vec>, + cx: &mut Context, + ) -> bool { + match self.as_active_thread() { + Some(thread) => thread.update(cx, |thread, _cx| { + if index < thread.local_queued_messages.len() { + thread.local_queued_messages[index] = QueuedMessage { + content, + tracked_buffers, }; - window.dispatch_action( - Box::new(OpenRulesLibrary { - prompt_to_select: Some(uuid.0), - }), - cx, - ) - } - MentionUri::Fetch { url } => { - cx.open_url(url.as_str()); + true + } else { + false } - MentionUri::Diagnostics { .. } => {} - MentionUri::TerminalSelection { .. } => {} - }) - } else { - cx.open_url(&url); + }), + None => false, } } - fn open_tool_call_location( - &self, - entry_ix: usize, - location_ix: usize, - window: &mut Window, - cx: &mut Context, - ) -> Option<()> { - let (tool_call_location, agent_location) = self - .as_active_thread()? - .thread - .read(cx) - .entries() - .get(entry_ix)? - .location(location_ix)?; - - let project_path = self - .project - .read(cx) - .find_project_path(&tool_call_location.path, cx)?; + fn queued_message_contents(&self, cx: &App) -> Vec> { + match self.as_active_thread() { + None => Vec::new(), + Some(thread) => thread + .read(cx) + .local_queued_messages + .iter() + .map(|q| q.content.clone()) + .collect(), + } + } - let open_task = self - .workspace - .update(cx, |workspace, cx| { - workspace.open_path(project_path, None, true, window, cx) - }) - .log_err()?; - window - .spawn(cx, async move |cx| { - let item = open_task.await?; + fn save_queued_message_at_index(&mut self, index: usize, cx: &mut Context) { + let editor = match self.as_active_thread() { + Some(thread) => thread.read(cx).queued_message_editors.get(index).cloned(), + None => None, + }; + let Some(editor) = editor else { + return; + }; - let Some(active_editor) = item.downcast::() else { - return anyhow::Ok(()); - }; + let contents_task = editor.update(cx, |editor, cx| editor.contents(false, cx)); - active_editor.update_in(cx, |editor, window, cx| { - let multibuffer = editor.buffer().read(cx); - let buffer = multibuffer.as_singleton(); - if agent_location.buffer.upgrade() == buffer { - let excerpt_id = multibuffer.excerpt_ids().first().cloned(); - let anchor = - editor::Anchor::in_buffer(excerpt_id.unwrap(), agent_location.position); - editor.change_selections(Default::default(), window, cx, |selections| { - selections.select_anchor_ranges([anchor..anchor]); - }) - } else { - let row = tool_call_location.line.unwrap_or_default(); - editor.change_selections(Default::default(), window, cx, |selections| { - selections.select_ranges([Point::new(row, 0)..Point::new(row, 0)]); - }) - } - })?; + cx.spawn(async move |this, cx| { + let Ok((content, tracked_buffers)) = contents_task.await else { + return Ok::<(), anyhow::Error>(()); + }; - anyhow::Ok(()) - }) - .detach_and_log_err(cx); + this.update(cx, |this, cx| { + this.update_queued_message(index, content, tracked_buffers, cx); + cx.notify(); + })?; - None + Ok(()) + }) + .detach_and_log_err(cx); } - pub fn open_thread_as_markdown( - &self, - workspace: Entity, - window: &mut Window, - cx: &mut App, - ) -> Task> { - let markdown_language_task = workspace - .read(cx) - .app_state() - .languages - .language_for_name("Markdown"); + fn sync_queued_message_editors(&mut self, window: &mut Window, cx: &mut Context) { + let needed_count = self.queued_messages_len(cx); + let queued_messages = self.queued_message_contents(cx); - let (thread_title, markdown) = if let Some(active) = self.as_active_thread() { - let thread = active.thread.read(cx); - (thread.title().to_string(), thread.to_markdown(cx)) - } else { - return Task::ready(Ok(())); + let agent_name = self.agent.name(); + let workspace = self.workspace.clone(); + let project = self.project.downgrade(); + let history = self.history.downgrade(); + + let Some(thread) = self.as_active_thread() else { + return; }; + let prompt_capabilities = thread.read(cx).prompt_capabilities.clone(); + let available_commands = thread.read(cx).available_commands.clone(); - let project = workspace.read(cx).project().clone(); - window.spawn(cx, async move |cx| { - let markdown_language = markdown_language_task.await?; + let current_count = thread.read(cx).queued_message_editors.len(); + let last_synced = thread.read(cx).last_synced_queue_length; - let buffer = project - .update(cx, |project, cx| { - project.create_buffer(Some(markdown_language), false, cx) - }) - .await?; + if current_count == needed_count && needed_count == last_synced { + return; + } - buffer.update(cx, |buffer, cx| { - buffer.set_text(markdown, cx); - buffer.set_capability(language::Capability::ReadWrite, cx); + if current_count > needed_count { + thread.update(cx, |thread, _cx| { + thread.queued_message_editors.truncate(needed_count); + thread + .queued_message_editor_subscriptions + .truncate(needed_count); }); - workspace.update_in(cx, |workspace, window, cx| { - let buffer = cx - .new(|cx| MultiBuffer::singleton(buffer, cx).with_title(thread_title.clone())); + let editors = thread.read(cx).queued_message_editors.clone(); + for (index, editor) in editors.into_iter().enumerate() { + if let Some(content) = queued_messages.get(index) { + editor.update(cx, |editor, cx| { + editor.set_message(content.clone(), window, cx); + }); + } + } + } + + while thread.read(cx).queued_message_editors.len() < needed_count { + let index = thread.read(cx).queued_message_editors.len(); + let content = queued_messages.get(index).cloned().unwrap_or_default(); - workspace.add_item_to_active_pane( - Box::new(cx.new(|cx| { - let mut editor = - Editor::for_multibuffer(buffer, Some(project.clone()), window, cx); - editor.set_breadcrumb_header(thread_title); - editor - })), + let editor = cx.new(|cx| { + let mut editor = MessageEditor::new( + workspace.clone(), + project.clone(), None, - true, + history.clone(), + None, + prompt_capabilities.clone(), + available_commands.clone(), + agent_name.clone(), + "", + EditorMode::AutoHeight { + min_lines: 1, + max_lines: Some(10), + }, window, cx, ); - })?; - anyhow::Ok(()) - }) - } - - fn scroll_to_top(&mut self, cx: &mut Context) { - if let Some(list_state) = self - .as_active_thread_mut() - .map(|active| &mut active.list_state) - { - list_state.scroll_to(ListOffset::default()); - cx.notify(); - } - } + editor.set_message(content, window, cx); + editor + }); - fn scroll_to_most_recent_user_prompt(&mut self, cx: &mut Context) { - let Some(active) = self.as_active_thread() else { - return; - }; + let subscription = cx.subscribe_in( + &editor, + window, + move |this, _editor, event, window, cx| match event { + MessageEditorEvent::LostFocus => { + this.save_queued_message_at_index(index, cx); + } + MessageEditorEvent::Cancel => { + window.focus(&this.focus_handle(cx), cx); + } + MessageEditorEvent::Send => { + window.focus(&this.focus_handle(cx), cx); + } + MessageEditorEvent::SendImmediately => { + this.send_queued_message_at_index(index, true, window, cx); + } + _ => {} + }, + ); - let entries = active.thread.read(cx).entries(); - if entries.is_empty() { - return; + thread.update(cx, |thread, _cx| { + thread.queued_message_editors.push(editor); + thread + .queued_message_editor_subscriptions + .push(subscription); + }); } - // Find the most recent user message and scroll it to the top of the viewport. - // (Fallback: if no user message exists, scroll to the bottom.) - if let Some(ix) = entries - .iter() - .rposition(|entry| matches!(entry, AgentThreadEntry::UserMessage(_))) - { - if let Some(list_state) = self - .as_active_thread_mut() - .map(|active| &mut active.list_state) - { - list_state.scroll_to(ListOffset { - item_ix: ix, - offset_in_item: px(0.0), - }); - cx.notify(); - } - } else { - self.scroll_to_bottom(cx); + if let Some(active) = self.as_active_thread() { + active.update(cx, |active, _cx| { + active.last_synced_queue_length = needed_count; + }); } } - pub fn scroll_to_bottom(&mut self, cx: &mut Context) { - if let Some(active) = self.as_active_thread() { - let entry_count = active.thread.read(cx).entries().len(); - active.list_state.reset(entry_count); - cx.notify(); - } + fn render_markdown(&self, markdown: Entity, style: MarkdownStyle) -> MarkdownElement { + let workspace = self.workspace.clone(); + MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| { + crate::acp::thread_view::active_thread::open_link(text, &workspace, window, cx); + }) } fn notify_with_sound( @@ -7618,428 +2064,10 @@ impl AcpServerView { } } - fn render_generating(&self, confirmation: bool, cx: &App) -> impl IntoElement { - let Some(active) = self.as_active_thread() else { - return div().into_any_element(); - }; - - let show_stats = AgentSettings::get_global(cx).show_turn_stats; - let elapsed_label = show_stats - .then(|| { - active.turn_fields.turn_started_at.and_then(|started_at| { - let elapsed = started_at.elapsed(); - (elapsed > STOPWATCH_THRESHOLD).then(|| duration_alt_display(elapsed)) - }) - }) - .flatten(); - - let is_waiting = confirmation || active.thread.read(cx).has_in_progress_tool_calls(); - - let turn_tokens_label = elapsed_label - .is_some() - .then(|| { - active - .turn_fields - .turn_tokens - .filter(|&tokens| tokens > TOKEN_THRESHOLD) - .map(|tokens| crate::text_thread_editor::humanize_token_count(tokens)) - }) - .flatten(); - - let arrow_icon = if is_waiting { - IconName::ArrowUp - } else { - IconName::ArrowDown - }; - - h_flex() - .id("generating-spinner") - .py_2() - .px(rems_from_px(22.)) - .gap_2() - .map(|this| { - if confirmation { - this.child( - h_flex() - .w_2() - .child(SpinnerLabel::sand().size(LabelSize::Small)), - ) - .child( - div().min_w(rems(8.)).child( - LoadingLabel::new("Awaiting Confirmation") - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - } else { - this.child(SpinnerLabel::new().size(LabelSize::Small)) - } - }) - .when_some(elapsed_label, |this, elapsed| { - this.child( - Label::new(elapsed) - .size(LabelSize::Small) - .color(Color::Muted), - ) - }) - .when_some(turn_tokens_label, |this, tokens| { - this.child( - h_flex() - .gap_0p5() - .child( - Icon::new(arrow_icon) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child( - Label::new(format!("{} tokens", tokens)) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - }) - .into_any_element() - } - - fn render_thread_controls( - &self, - thread: &Entity, - cx: &Context, - ) -> impl IntoElement { - let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); - if is_generating { - return self.render_generating(false, cx).into_any_element(); - } - - let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(Color::Ignored) - .tooltip(Tooltip::text("Open Thread as Markdown")) - .on_click(cx.listener(move |this, _, window, cx| { - if let Some(workspace) = this.workspace.upgrade() { - this.open_thread_as_markdown(workspace, window, cx) - .detach_and_log_err(cx); - } - })); - - let scroll_to_recent_user_prompt = - IconButton::new("scroll_to_recent_user_prompt", IconName::ForwardArrow) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(Color::Ignored) - .tooltip(Tooltip::text("Scroll To Most Recent User Prompt")) - .on_click(cx.listener(move |this, _, _, cx| { - this.scroll_to_most_recent_user_prompt(cx); - })); - - let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(Color::Ignored) - .tooltip(Tooltip::text("Scroll To Top")) - .on_click(cx.listener(move |this, _, _, cx| { - this.scroll_to_top(cx); - })); - - let Some(active) = self.as_active_thread() else { - return div().into_any_element(); - }; - - let show_stats = AgentSettings::get_global(cx).show_turn_stats; - let last_turn_clock = show_stats - .then(|| { - active - .turn_fields - .last_turn_duration - .filter(|&duration| duration > STOPWATCH_THRESHOLD) - .map(|duration| { - Label::new(duration_alt_display(duration)) - .size(LabelSize::Small) - .color(Color::Muted) - }) - }) - .flatten(); - - let last_turn_tokens_label = last_turn_clock - .is_some() - .then(|| { - active - .turn_fields - .last_turn_tokens - .filter(|&tokens| tokens > TOKEN_THRESHOLD) - .map(|tokens| { - Label::new(format!( - "{} tokens", - crate::text_thread_editor::humanize_token_count(tokens) - )) - .size(LabelSize::Small) - .color(Color::Muted) - }) - }) - .flatten(); - - let mut container = h_flex() - .w_full() - .py_2() - .px_5() - .gap_px() - .opacity(0.6) - .hover(|s| s.opacity(1.)) - .justify_end() - .when( - last_turn_tokens_label.is_some() || last_turn_clock.is_some(), - |this| { - this.child( - h_flex() - .gap_1() - .px_1() - .when_some(last_turn_tokens_label, |this, label| this.child(label)) - .when_some(last_turn_clock, |this, label| this.child(label)), - ) - }, - ); - - if let Some(active) = self.as_active_thread() { - if AgentSettings::get_global(cx).enable_feedback - && active.thread.read(cx).connection().telemetry().is_some() - { - let feedback = active.thread_feedback.feedback; - - let tooltip_meta = || { - SharedString::new( - "Rating the thread sends all of your current conversation to the Zed team.", - ) - }; - - container = container - .child( - IconButton::new("feedback-thumbs-up", IconName::ThumbsUp) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(match feedback { - Some(ThreadFeedback::Positive) => Color::Accent, - _ => Color::Ignored, - }) - .tooltip(move |window, cx| match feedback { - Some(ThreadFeedback::Positive) => { - Tooltip::text("Thanks for your feedback!")(window, cx) - } - _ => { - Tooltip::with_meta("Helpful Response", None, tooltip_meta(), cx) - } - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.handle_feedback_click(ThreadFeedback::Positive, window, cx); - })), - ) - .child( - IconButton::new("feedback-thumbs-down", IconName::ThumbsDown) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(match feedback { - Some(ThreadFeedback::Negative) => Color::Accent, - _ => Color::Ignored, - }) - .tooltip(move |window, cx| match feedback { - Some(ThreadFeedback::Negative) => { - Tooltip::text( - "We appreciate your feedback and will use it to improve in the future.", - )(window, cx) - } - _ => { - Tooltip::with_meta( - "Not Helpful Response", - None, - tooltip_meta(), - cx, - ) - } - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.handle_feedback_click(ThreadFeedback::Negative, window, cx); - })), - ); - } - } - - if cx.has_flag::() - && self.is_imported_thread(cx) - && self - .project - .read(cx) - .client() - .status() - .borrow() - .is_connected() - { - let sync_button = IconButton::new("sync-thread", IconName::ArrowCircle) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(Color::Ignored) - .tooltip(Tooltip::text("Sync with source thread")) - .on_click(cx.listener(move |this, _, window, cx| { - this.sync_thread(window, cx); - })); - - container = container.child(sync_button); - } - - if cx.has_flag::() && !self.is_imported_thread(cx) { - let share_button = IconButton::new("share-thread", IconName::ArrowUpRight) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(Color::Ignored) - .tooltip(Tooltip::text("Share Thread")) - .on_click(cx.listener(move |this, _, window, cx| { - this.share_thread(window, cx); - })); - - container = container.child(share_button); - } - - container - .child(open_as_markdown) - .child(scroll_to_recent_user_prompt) - .child(scroll_to_top) - .into_any_element() - } - - fn render_feedback_feedback_editor(editor: Entity, cx: &Context) -> Div { - h_flex() - .key_context("AgentFeedbackMessageEditor") - .on_action(cx.listener(move |this, _: &menu::Cancel, _, cx| { - if let Some(active) = this.as_active_thread_mut() { - active.thread_feedback.dismiss_comments(); - } - cx.notify(); - })) - .on_action(cx.listener(move |this, _: &menu::Confirm, _window, cx| { - this.submit_feedback_message(cx); - })) - .p_2() - .mb_2() - .mx_5() - .gap_1() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().editor_background) - .child(div().w_full().child(editor)) - .child( - h_flex() - .child( - IconButton::new("dismiss-feedback-message", IconName::Close) - .icon_color(Color::Error) - .icon_size(IconSize::XSmall) - .shape(ui::IconButtonShape::Square) - .on_click(cx.listener(move |this, _, _window, cx| { - if let Some(active) = this.as_active_thread_mut() { - active.thread_feedback.dismiss_comments(); - } - cx.notify(); - })), - ) - .child( - IconButton::new("submit-feedback-message", IconName::Return) - .icon_size(IconSize::XSmall) - .shape(ui::IconButtonShape::Square) - .on_click(cx.listener(move |this, _, _window, cx| { - this.submit_feedback_message(cx); - })), - ), - ) - } - - fn handle_feedback_click( - &mut self, - feedback: ThreadFeedback, - window: &mut Window, - cx: &mut Context, - ) { - let Some(active) = self.as_active_thread_mut() else { - return; - }; - - active - .thread_feedback - .submit(active.thread.clone(), feedback, window, cx); - cx.notify(); - } - - fn submit_feedback_message(&mut self, cx: &mut Context) { - let Some(active) = self.as_active_thread_mut() else { - return; - }; - - active - .thread_feedback - .submit_comments(active.thread.clone(), cx); - cx.notify(); - } - - fn render_token_limit_callout(&self, cx: &mut Context) -> Option { - let Some(active) = self.as_active_thread() else { - return None; - }; - - if active.token_limit_callout_dismissed { - return None; - } - - let token_usage = active.thread.read(cx).token_usage()?; - let ratio = token_usage.ratio(); - - let (severity, icon, title) = match ratio { - acp_thread::TokenUsageRatio::Normal => return None, - acp_thread::TokenUsageRatio::Warning => ( - Severity::Warning, - IconName::Warning, - "Thread reaching the token limit soon", - ), - acp_thread::TokenUsageRatio::Exceeded => ( - Severity::Error, - IconName::XCircle, - "Thread reached the token limit", - ), - }; - - let description = "To continue, start a new thread from a summary."; - - Some( - Callout::new() - .severity(severity) - .icon(icon) - .title(title) - .description(description) - .actions_slot( - h_flex().gap_0p5().child( - Button::new("start-new-thread", "Start New Thread") - .label_size(LabelSize::Small) - .on_click(cx.listener(|this, _, window, cx| { - let Some(active) = this.as_active_thread() else { - return; - }; - let session_id = active.thread.read(cx).session_id().clone(); - window.dispatch_action( - crate::NewNativeAgentThreadFromSummary { - from_session_id: session_id, - } - .boxed_clone(), - cx, - ); - })), - ), - ) - .dismiss_action(self.dismiss_error_button(cx)), - ) - } - fn agent_ui_font_size_changed(&mut self, _window: &mut Window, cx: &mut Context) { if let Some(entry_view_state) = self .as_active_thread() - .map(|active| &active.entry_view_state) - .cloned() + .map(|active| active.read(cx).entry_view_state.clone()) { entry_view_state.update(cx, |entry_view_state, cx| { entry_view_state.agent_ui_font_size_changed(cx); @@ -8054,17 +2082,25 @@ impl AcpServerView { window: &mut Window, cx: &mut Context, ) { - self.message_editor.update(cx, |message_editor, cx| { - message_editor.insert_dragged_files(paths, added_worktrees, window, cx); - }) + if let Some(active_thread) = self.as_active_thread() { + active_thread.update(cx, |thread, cx| { + thread.message_editor.update(cx, |editor, cx| { + editor.insert_dragged_files(paths, added_worktrees, window, cx); + }) + }); + } } /// Inserts the selected text into the message editor or the message being /// edited, if any. pub(crate) fn insert_selections(&self, window: &mut Window, cx: &mut Context) { - self.active_editor(cx).update(cx, |editor, cx| { - editor.insert_selections(window, cx); - }); + if let Some(active_thread) = self.as_active_thread() { + active_thread.update(cx, |thread, cx| { + thread.active_editor(cx).update(cx, |editor, cx| { + editor.insert_selections(window, cx); + }) + }); + } } /// Inserts terminal text as a crease into the message editor. @@ -8074,148 +2110,13 @@ impl AcpServerView { window: &mut Window, cx: &mut Context, ) { - self.message_editor.update(cx, |message_editor, cx| { - message_editor.insert_terminal_crease(text, window, cx); - }); - } - - /// Inserts code snippets as creases into the message editor. - pub(crate) fn insert_code_crease( - &self, - creases: Vec<(String, String)>, - window: &mut Window, - cx: &mut Context, - ) { - self.message_editor.update(cx, |message_editor, cx| { - message_editor.insert_code_creases(creases, window, cx); - }); - } - - fn render_codex_windows_warning(&self, cx: &mut Context) -> Callout { - Callout::new() - .icon(IconName::Warning) - .severity(Severity::Warning) - .title("Codex on Windows") - .description("For best performance, run Codex in Windows Subsystem for Linux (WSL2)") - .actions_slot( - Button::new("open-wsl-modal", "Open in WSL") - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .on_click(cx.listener({ - move |_, _, _window, cx| { - #[cfg(windows)] - _window.dispatch_action( - zed_actions::wsl_actions::OpenWsl::default().boxed_clone(), - cx, - ); - cx.notify(); - } - })), - ) - .dismiss_action( - IconButton::new("dismiss", IconName::Close) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip(Tooltip::text("Dismiss Warning")) - .on_click(cx.listener({ - move |this, _, _, cx| { - this.show_codex_windows_warning = false; - cx.notify(); - } - })), - ) - } - - fn clear_command_load_errors(&mut self, cx: &mut Context) { - if let Some(active) = self.as_active_thread_mut() { - active.command_load_errors_dismissed = true; + if let Some(active_thread) = self.as_active_thread() { + active_thread.update(cx, |thread, cx| { + thread.message_editor.update(cx, |editor, cx| { + editor.insert_terminal_crease(text, window, cx); + }) + }); } - cx.notify(); - } - - fn refresh_cached_user_commands(&mut self, cx: &mut Context) { - let Some(registry) = self.slash_command_registry.clone() else { - return; - }; - self.refresh_cached_user_commands_from_registry(®istry, cx); - } - - fn refresh_cached_user_commands_from_registry( - &mut self, - registry: &Entity, - cx: &mut Context, - ) { - let Some(thread_state) = self.as_active_thread_mut() else { - return; - }; - thread_state.refresh_cached_user_commands_from_registry(registry, cx); - cx.notify(); - } - - /// Returns the cached slash commands, if available. - pub fn cached_slash_commands( - &self, - _cx: &App, - ) -> collections::HashMap { - let Some(thread_state) = &self.as_active_thread() else { - return collections::HashMap::default(); - }; - thread_state.cached_user_commands.borrow().clone() - } - - /// Returns the cached slash command errors, if available. - fn cached_slash_command_errors(&self, _cx: &App) -> Vec { - let Some(thread_state) = &self.as_active_thread() else { - return Vec::new(); - }; - thread_state.cached_user_command_errors.borrow().clone() - } - - fn render_thread_error(&mut self, window: &mut Window, cx: &mut Context) -> Option
{ - let content = match self.as_active_thread()?.thread_error.as_ref()? { - ThreadError::Other { message, .. } => { - self.render_any_thread_error(message.clone(), window, cx) - } - ThreadError::Refusal => self.render_refusal_error(cx), - ThreadError::AuthenticationRequired(error) => { - self.render_authentication_required_error(error.clone(), cx) - } - ThreadError::PaymentRequired => self.render_payment_required_error(cx), - }; - - Some(div().child(content)) - } - - fn render_new_version_callout(&self, version: &SharedString, cx: &mut Context) -> Div { - v_flex().w_full().justify_end().child( - h_flex() - .p_2() - .pr_3() - .w_full() - .gap_1p5() - .border_t_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().element_background) - .child( - h_flex() - .flex_1() - .gap_1p5() - .child( - Icon::new(IconName::Download) - .color(Color::Accent) - .size(IconSize::Small), - ) - .child(Label::new("New version available").size(LabelSize::Small)), - ) - .child( - Button::new("update-button", format!("Update to v{}", version)) - .label_size(LabelSize::Small) - .style(ButtonStyle::Tinted(TintColor::Accent)) - .on_click(cx.listener(|this, _, window, cx| { - this.reset(window, cx); - })), - ), - ) } fn current_model_name(&self, cx: &App) -> SharedString { @@ -8224,271 +2125,39 @@ impl AcpServerView { // This provides better clarity about what refused the request if self.as_native_connection(cx).is_some() { self.as_active_thread() - .and_then(|active| active.model_selector.as_ref()) + .and_then(|active| active.read(cx).model_selector.clone()) .and_then(|selector| selector.read(cx).active_model(cx)) .map(|model| model.name.clone()) .unwrap_or_else(|| SharedString::from("The model")) } else { // ACP agent - use the agent name (e.g., "Claude Code", "Gemini CLI") self.agent.name() - } - } - - fn render_refusal_error(&self, cx: &mut Context<'_, Self>) -> Callout { - let model_or_agent_name = self.current_model_name(cx); - let refusal_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 - ); - - Callout::new() - .severity(Severity::Error) - .title("Request Refused") - .icon(IconName::XCircle) - .description(refusal_message.clone()) - .actions_slot(self.create_copy_button(&refusal_message)) - .dismiss_action(self.dismiss_error_button(cx)) - } - - fn set_can_fast_track_queue(&mut self, value: bool) { - if let Some(active) = self.as_active_thread_mut() { - active.can_fast_track_queue = value; - } - } - - fn render_any_thread_error( - &mut self, - error: SharedString, - window: &mut Window, - cx: &mut Context<'_, Self>, - ) -> Callout { - let can_resume = self - .as_active_thread() - .map_or(false, |active| active.thread.read(cx).can_retry(cx)); - - let markdown = if let Some(thread_state) = self.as_active_thread() - && let Some(markdown) = &thread_state.thread_error_markdown - { - markdown.clone() - } else { - let markdown = cx.new(|cx| Markdown::new(error.clone(), None, None, cx)); - if let Some(thread_state) = self.as_active_thread_mut() { - thread_state.thread_error_markdown = Some(markdown.clone()); - } - markdown - }; - - let markdown_style = - MarkdownStyle::themed(MarkdownFont::Agent, window, cx).with_muted_text(cx); - let description = self - .render_markdown(markdown, markdown_style) - .into_any_element(); - - Callout::new() - .severity(Severity::Error) - .icon(IconName::XCircle) - .title("An Error Happened") - .description_slot(description) - .actions_slot( - h_flex() - .gap_0p5() - .when(can_resume, |this| { - this.child( - IconButton::new("retry", IconName::RotateCw) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Retry Generation")) - .on_click(cx.listener(|this, _, _window, cx| { - this.retry_generation(cx); - })), - ) - }) - .child(self.create_copy_button(error.to_string())), - ) - .dismiss_action(self.dismiss_error_button(cx)) - } - - fn render_payment_required_error(&self, cx: &mut Context) -> Callout { - const ERROR_MESSAGE: &str = - "You reached your free usage limit. Upgrade to Zed Pro for more prompts."; - - Callout::new() - .severity(Severity::Error) - .icon(IconName::XCircle) - .title("Free Usage Exceeded") - .description(ERROR_MESSAGE) - .actions_slot( - h_flex() - .gap_0p5() - .child(self.upgrade_button(cx)) - .child(self.create_copy_button(ERROR_MESSAGE)), - ) - .dismiss_action(self.dismiss_error_button(cx)) - } - - fn render_authentication_required_error( - &self, - error: SharedString, - cx: &mut Context, - ) -> Callout { - Callout::new() - .severity(Severity::Error) - .title("Authentication Required") - .icon(IconName::XCircle) - .description(error.clone()) - .actions_slot( - h_flex() - .gap_0p5() - .child(self.authenticate_button(cx)) - .child(self.create_copy_button(error)), - ) - .dismiss_action(self.dismiss_error_button(cx)) - } - - fn create_copy_button(&self, message: impl Into) -> impl IntoElement { - let message = message.into(); - - CopyButton::new("copy-error-message", message).tooltip_label("Copy Error Message") - } - - fn dismiss_error_button(&self, cx: &mut Context) -> impl IntoElement { - IconButton::new("dismiss", IconName::Close) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Dismiss")) - .on_click(cx.listener({ - move |this, _, _, cx| { - this.clear_thread_error(cx); - cx.notify(); - } - })) + } } - fn authenticate_button(&self, cx: &mut Context) -> impl IntoElement { - Button::new("authenticate", "Authenticate") - .label_size(LabelSize::Small) - .style(ButtonStyle::Filled) - .on_click(cx.listener({ - move |this, _, window, cx| { - let agent_name = this.agent.name(); - this.clear_thread_error(cx); - if let Some(message) = this.in_flight_prompt.take() { - this.message_editor.update(cx, |editor, cx| { - editor.set_message(message, window, cx); - }); - } - let this = cx.weak_entity(); - window.defer(cx, |window, cx| { - Self::handle_auth_required( - this, - AuthRequired::new(), - agent_name, - window, - cx, - ); - }) - } - })) + fn create_copy_button(&self, message: impl Into) -> impl IntoElement { + let message = message.into(); + + CopyButton::new("copy-error-message", message).tooltip_label("Copy Error Message") } pub(crate) fn reauthenticate(&mut self, window: &mut Window, cx: &mut Context) { let agent_name = self.agent.name(); - self.clear_thread_error(cx); + if let Some(active) = self.as_active_thread() { + active.update(cx, |active, cx| active.clear_thread_error(cx)); + } let this = cx.weak_entity(); window.defer(cx, |window, cx| { Self::handle_auth_required(this, AuthRequired::new(), agent_name, window, cx); }) } - fn upgrade_button(&self, cx: &mut Context) -> impl IntoElement { - Button::new("upgrade", "Upgrade") - .label_size(LabelSize::Small) - .style(ButtonStyle::Tinted(ui::TintColor::Accent)) - .on_click(cx.listener({ - move |this, _, _, cx| { - this.clear_thread_error(cx); - cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx)); - } - })) - } - pub fn delete_history_entry(&mut self, entry: AgentSessionInfo, cx: &mut Context) { let task = self.history.update(cx, |history, cx| { history.delete_session(&entry.session_id, cx) }); task.detach_and_log_err(cx); } - - /// Returns the currently active editor, either for a message that is being - /// edited or the editor for a new message. - fn active_editor(&self, cx: &App) -> Entity { - if let Some(thread_state) = self.as_active_thread() - && let Some(index) = thread_state.editing_message - && let Some(editor) = thread_state - .entry_view_state - .read(cx) - .entry(index) - .and_then(|entry| entry.message_editor()) - .cloned() - { - editor - } else { - self.message_editor.clone() - } - } - - fn get_agent_message_content( - entries: &[AgentThreadEntry], - entry_index: usize, - cx: &App, - ) -> Option { - let entry = entries.get(entry_index)?; - if matches!(entry, AgentThreadEntry::UserMessage(_)) { - return None; - } - - let start_index = (0..entry_index) - .rev() - .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_)))) - .map(|i| i + 1) - .unwrap_or(0); - - let end_index = (entry_index + 1..entries.len()) - .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_)))) - .map(|i| i - 1) - .unwrap_or(entries.len() - 1); - - let parts: Vec = (start_index..=end_index) - .filter_map(|i| entries.get(i)) - .filter_map(|entry| { - if let AgentThreadEntry::AssistantMessage(message) = entry { - let text: String = message - .chunks - .iter() - .filter_map(|chunk| match chunk { - AssistantMessageChunk::Message { block } => { - let markdown = block.to_markdown(cx); - if markdown.trim().is_empty() { - None - } else { - Some(markdown.to_string()) - } - } - AssistantMessageChunk::Thought { .. } => None, - }) - .collect::>() - .join("\n\n"); - - if text.is_empty() { None } else { Some(text) } - } else { - None - } - }) - .collect(); - - let text = parts.join("\n\n"); - if text.is_empty() { None } else { Some(text) } - } } fn loading_contents_spinner(size: IconSize) -> AnyElement { @@ -8515,7 +2184,7 @@ fn placeholder_text(agent_name: &str, has_commands: bool) -> String { impl Focusable for AcpServerView { fn focus_handle(&self, cx: &App) -> FocusHandle { match self.as_active_thread() { - Some(_) => self.active_editor(cx).focus_handle(cx), + Some(thread) => thread.read(cx).focus_handle(cx), None => self.focus_handle.clone(), } } @@ -8526,8 +2195,10 @@ impl AcpServerView { /// Expands a tool call so its content is visible. /// This is primarily useful for visual testing. pub fn expand_tool_call(&mut self, tool_call_id: acp::ToolCallId, cx: &mut Context) { - if let Some(active) = self.as_active_thread_mut() { - active.expanded_tool_calls.insert(tool_call_id); + if let Some(active) = self.as_active_thread() { + active.update(cx, |active, _cx| { + active.expanded_tool_calls.insert(tool_call_id); + }); cx.notify(); } } @@ -8535,8 +2206,10 @@ impl AcpServerView { /// Expands a subagent card so its content is visible. /// This is primarily useful for visual testing. pub fn expand_subagent(&mut self, session_id: acp::SessionId, cx: &mut Context) { - if let Some(active) = self.as_active_thread_mut() { - active.expanded_subagents.insert(session_id); + if let Some(active) = self.as_active_thread() { + active.update(cx, |active, _cx| { + active.expanded_subagents.insert(session_id); + }); cx.notify(); } } @@ -8546,172 +2219,14 @@ impl Render for AcpServerView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.sync_queued_message_editors(window, cx); - let has_messages = self - .as_active_thread() - .is_some_and(|active| active.list_state.item_count() > 0); - v_flex() .size_full() - .key_context("AcpThread") - .on_action(cx.listener(|this, _: &menu::Cancel, _, cx| { - this.cancel_generation(cx); - })) - .on_action(cx.listener(Self::keep_all)) - .on_action(cx.listener(Self::reject_all)) - .on_action(cx.listener(Self::allow_always)) - .on_action(cx.listener(Self::allow_once)) - .on_action(cx.listener(Self::reject_once)) - .on_action(cx.listener(Self::handle_authorize_tool_call)) - .on_action(cx.listener(Self::handle_select_permission_granularity)) - .on_action(cx.listener(Self::open_permission_dropdown)) - .on_action(cx.listener(Self::open_add_context_menu)) - .on_action(cx.listener(|this, _: &ToggleThinkingMode, _window, cx| { - if let Some(thread) = this.as_native_thread(cx) { - thread.update(cx, |thread, cx| { - thread.set_thinking_enabled(!thread.thinking_enabled(), cx); - }); - } - })) - .on_action(cx.listener(|this, _: &SendNextQueuedMessage, window, cx| { - this.send_queued_message_at_index(0, true, window, cx); - })) - .on_action(cx.listener(|this, _: &RemoveFirstQueuedMessage, _, cx| { - this.remove_from_queue(0, cx); - cx.notify(); - })) - .on_action(cx.listener(|this, _: &EditFirstQueuedMessage, window, cx| { - if let Some(active) = this.as_active_thread() - && let Some(editor) = active.queued_message_editors.first() - { - window.focus(&editor.focus_handle(cx), cx); - } - })) - .on_action(cx.listener(|this, _: &ClearMessageQueue, _, cx| { - this.clear_queue(cx); - if let Some(state) = this.as_active_thread_mut() { - state.can_fast_track_queue = false; - } - cx.notify(); - })) - .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| { - if let Some(config_options_view) = this - .as_active_thread() - .and_then(|active| active.config_options_view.as_ref()) - { - let handled = config_options_view.update(cx, |view, cx| { - view.toggle_category_picker( - acp::SessionConfigOptionCategory::Mode, - window, - cx, - ) - }); - if handled { - return; - } - } - - if let Some(profile_selector) = this - .as_active_thread() - .and_then(|active| active.profile_selector.as_ref()) - { - profile_selector.read(cx).menu_handle().toggle(window, cx); - } else if let Some(mode_selector) = this - .as_active_thread() - .and_then(|active| active.mode_selector.as_ref()) - { - mode_selector.read(cx).menu_handle().toggle(window, cx); - } - })) - .on_action(cx.listener(|this, _: &CycleModeSelector, window, cx| { - if let Some(config_options_view) = this - .as_active_thread() - .and_then(|active| active.config_options_view.as_ref()) - { - let handled = config_options_view.update(cx, |view, cx| { - view.cycle_category_option( - acp::SessionConfigOptionCategory::Mode, - false, - cx, - ) - }); - if handled { - return; - } - } - - if let Some(profile_selector) = this - .as_active_thread() - .and_then(|active| active.profile_selector.as_ref()) - { - profile_selector.update(cx, |profile_selector, cx| { - profile_selector.cycle_profile(cx); - }); - } else if let Some(mode_selector) = this - .as_active_thread() - .and_then(|active| active.mode_selector.as_ref()) - { - mode_selector.update(cx, |mode_selector, cx| { - mode_selector.cycle_mode(window, cx); - }); - } - })) - .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| { - if let Some(config_options_view) = this - .as_active_thread() - .and_then(|active| active.config_options_view.as_ref()) - { - let handled = config_options_view.update(cx, |view, cx| { - view.toggle_category_picker( - acp::SessionConfigOptionCategory::Model, - window, - cx, - ) - }); - if handled { - return; - } - } - - if let Some(model_selector) = this - .as_active_thread() - .and_then(|active| active.model_selector.as_ref()) - { - model_selector - .update(cx, |model_selector, cx| model_selector.toggle(window, cx)); - } - })) - .on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| { - if let Some(config_options_view) = this - .as_active_thread() - .and_then(|active| active.config_options_view.as_ref()) - { - let handled = config_options_view.update(cx, |view, cx| { - view.cycle_category_option( - acp::SessionConfigOptionCategory::Model, - true, - cx, - ) - }); - if handled { - return; - } - } - - if let Some(model_selector) = this - .as_active_thread() - .and_then(|active| active.model_selector.as_ref()) - { - model_selector.update(cx, |model_selector, cx| { - model_selector.cycle_favorite_models(window, cx); - }); - } - })) .track_focus(&self.focus_handle) .bg(cx.theme().colors().panel_background) .child(match &self.server_state { ServerState::Loading { .. } => v_flex() .flex_1() - .child(self.render_recent_history(cx)) + // .child(self.render_recent_history(cx)) .into_any(), ServerState::LoadError(e) => v_flex() .flex_1() @@ -8743,63 +2258,8 @@ impl Render for AcpServerView { cx, )) .into_any_element(), - ServerState::Connected(connected) => v_flex().flex_1().map(|this| { - let this = this.when(connected.current.resumed_without_history, |this| { - this.child(self.render_resume_notice(cx)) - }); - if has_messages { - this.child( - list( - connected.current.list_state.clone(), - cx.processor(|this, index: usize, window, cx| { - let Some((entry, len)) = - this.as_active_thread().and_then(|active| { - let entries = &active.thread.read(cx).entries(); - Some((entries.get(index)?, entries.len())) - }) - else { - return Empty.into_any(); - }; - this.render_entry(index, len, entry, window, cx) - }), - ) - .with_sizing_behavior(gpui::ListSizingBehavior::Auto) - .flex_grow() - .into_any(), - ) - .vertical_scrollbar_for(&connected.current.list_state, window, cx) - .into_any() - } else { - this.child(self.render_recent_history(cx)).into_any() - } - }), - }) - // The activity bar is intentionally rendered outside of the ThreadState::Active match - // above so that the scrollbar doesn't render behind it. The current setup allows - // the scrollbar to stop exactly at the activity bar start. - .when(has_messages, |this| match self.as_active_thread() { - Some(thread) => this.children(self.render_activity_bar(&thread.thread, window, cx)), - _ => this, - }) - .when(self.show_codex_windows_warning, |this| { - this.child(self.render_codex_windows_warning(cx)) - }) - .when_some(self.as_active_thread(), |this, thread_state| { - this.children(thread_state.render_thread_retry_status_callout()) - .children(thread_state.render_command_load_errors(cx)) + ServerState::Connected(connected) => connected.current.clone().into_any_element(), }) - .children(self.render_thread_error(window, cx)) - .when_some( - match has_messages { - true => None, - false => self - .as_active_thread() - .and_then(|active| active.new_server_version_available.as_ref()), - }, - |this, version| this.child(self.render_new_version_callout(version, cx)), - ) - .children(self.render_token_limit_callout(cx)) - .child(self.render_message_editor(window, cx)) } } @@ -8864,16 +2324,14 @@ pub(crate) mod tests { let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; - let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + let message_editor = message_editor(&thread_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Hello", window, cx); }); cx.deactivate_window(); - thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.send(window, cx); - }); + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); @@ -8891,16 +2349,14 @@ pub(crate) mod tests { let (thread_view, cx) = setup_thread_view(StubAgentServer::new(SaboteurAgentConnection), cx).await; - let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + let message_editor = message_editor(&thread_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Hello", window, cx); }); cx.deactivate_window(); - thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.send(window, cx); - }); + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); @@ -8948,7 +2404,7 @@ pub(crate) mod tests { cx.run_until_parked(); // Initially empty because StubAgentConnection.session_list() returns None - thread_view.read_with(cx, |view, _cx| { + active_thread(&thread_view, cx).read_with(cx, |view, _cx| { assert_eq!(view.recent_history_entries.len(), 0); }); @@ -8960,7 +2416,7 @@ pub(crate) mod tests { }); cx.run_until_parked(); - thread_view.read_with(cx, |view, _cx| { + active_thread(&thread_view, cx).read_with(cx, |view, _cx| { assert_eq!(view.recent_history_entries.len(), 1); assert_eq!( view.recent_history_entries[0].session_id, @@ -8976,7 +2432,7 @@ pub(crate) mod tests { }); cx.run_until_parked(); - thread_view.read_with(cx, |view, _cx| { + active_thread(&thread_view, cx).read_with(cx, |view, _cx| { assert_eq!(view.recent_history_entries.len(), 1); assert_eq!( view.recent_history_entries[0].session_id, @@ -9017,10 +2473,10 @@ pub(crate) mod tests { cx.run_until_parked(); - thread_view.read_with(cx, |view, _cx| { + thread_view.read_with(cx, |view, cx| { let state = view.as_active_thread().unwrap(); - assert!(state.resumed_without_history); - assert_eq!(state.list_state.item_count(), 0); + assert!(state.read(cx).resumed_without_history); + assert_eq!(state.read(cx).list_state.item_count(), 0); }); } @@ -9031,22 +2487,20 @@ pub(crate) mod tests { let (thread_view, cx) = setup_thread_view(StubAgentServer::new(RefusalAgentConnection), cx).await; - let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + let message_editor = message_editor(&thread_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Do something harmful", window, cx); }); - thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.send(window, cx); - }); + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); // Check that the refusal error is set - thread_view.read_with(cx, |thread_view, _cx| { + thread_view.read_with(cx, |thread_view, cx| { let state = thread_view.as_active_thread().unwrap(); assert!( - matches!(state.thread_error, Some(ThreadError::Refusal)), + matches!(state.read(cx).thread_error, Some(ThreadError::Refusal)), "Expected refusal error to be set" ); }); @@ -9074,16 +2528,14 @@ pub(crate) mod tests { let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; - let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + let message_editor = message_editor(&thread_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Hello", window, cx); }); cx.deactivate_window(); - thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.send(window, cx); - }); + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); @@ -9102,7 +2554,7 @@ pub(crate) mod tests { add_to_workspace(thread_view.clone(), cx); - let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + let message_editor = message_editor(&thread_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Hello", window, cx); @@ -9112,9 +2564,7 @@ pub(crate) mod tests { // Note: In the test environment, the panel is not actually added to the dock, // so is_agent_panel_hidden will return true - thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.send(window, cx); - }); + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); @@ -9133,7 +2583,7 @@ pub(crate) mod tests { let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; - let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + let message_editor = message_editor(&thread_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Hello", window, cx); }); @@ -9141,9 +2591,7 @@ pub(crate) mod tests { // Deactivate window - should show notification regardless of setting cx.deactivate_window(); - thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.send(window, cx); - }); + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); @@ -9173,16 +2621,14 @@ pub(crate) mod tests { let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; - let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + let message_editor = message_editor(&thread_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Hello", window, cx); }); // Window is active - thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.send(window, cx); - }); + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); @@ -9203,16 +2649,14 @@ pub(crate) mod tests { let weak_view = thread_view.downgrade(); - let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + let message_editor = message_editor(&thread_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Hello", window, cx); }); cx.deactivate_window(); - thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.send(window, cx); - }); + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); @@ -9342,6 +2786,32 @@ pub(crate) mod tests { } } + impl AgentServer for StubAgentServer + where + C: 'static + AgentConnection + Send + Clone, + { + fn logo(&self) -> ui::IconName { + ui::IconName::Ai + } + + fn name(&self) -> SharedString { + "Test".into() + } + + fn connect( + &self, + _root_dir: Option<&Path>, + _delegate: AgentServerDelegate, + _cx: &mut App, + ) -> Task, Option)>> { + Task::ready(Ok((Rc::new(self.connection.clone()), None))) + } + + fn into_any(self: Rc) -> Rc { + self + } + } + #[derive(Clone)] struct StubSessionList { sessions: Vec, @@ -9459,32 +2929,6 @@ pub(crate) mod tests { } } - impl AgentServer for StubAgentServer - where - C: 'static + AgentConnection + Send + Clone, - { - fn logo(&self) -> ui::IconName { - ui::IconName::Ai - } - - fn name(&self) -> SharedString { - "Test".into() - } - - fn connect( - &self, - _root_dir: Option<&Path>, - _delegate: AgentServerDelegate, - _cx: &mut App, - ) -> Task, Option)>> { - Task::ready(Ok((Rc::new(self.connection.clone()), None))) - } - - fn into_any(self: Rc) -> Rc { - self - } - } - #[derive(Clone)] struct SaboteurAgentConnection; @@ -9623,6 +3067,21 @@ pub(crate) mod tests { }); } + fn active_thread( + thread_view: &Entity, + cx: &TestAppContext, + ) -> Entity { + cx.read(|cx| thread_view.read(cx).as_connected().unwrap().current.clone()) + } + + fn message_editor( + thread_view: &Entity, + cx: &TestAppContext, + ) -> Entity { + let thread = active_thread(thread_view, cx); + cx.read(|cx| thread.read(cx).message_editor.clone()) + } + #[gpui::test] async fn test_rewind_views(cx: &mut TestAppContext) { init_test(cx); @@ -9664,8 +3123,8 @@ pub(crate) mod tests { cx.run_until_parked(); let thread = thread_view - .read_with(cx, |view, _| { - view.as_active_thread().map(|r| r.thread.clone()) + .read_with(cx, |view, cx| { + view.as_active_thread().map(|r| r.read(cx).thread.clone()) }) .unwrap(); @@ -9685,14 +3144,14 @@ pub(crate) mod tests { .unwrap(); cx.run_until_parked(); - thread.read_with(cx, |thread, _| { + thread.read_with(cx, |thread, _cx| { assert_eq!(thread.entries().len(), 2); }); thread_view.read_with(cx, |view, cx| { let entry_view_state = view .as_active_thread() - .map(|active| &active.entry_view_state) + .map(|active| active.read(cx).entry_view_state.clone()) .unwrap(); entry_view_state.read_with(cx, |entry_view_state, _| { assert!( @@ -9731,7 +3190,12 @@ pub(crate) mod tests { }); thread_view.read_with(cx, |view, cx| { - let entry_view_state = &view.as_active_thread().unwrap().entry_view_state; + let entry_view_state = view + .as_active_thread() + .unwrap() + .read(cx) + .entry_view_state + .clone(); entry_view_state.read_with(cx, |entry_view_state, _| { assert!( entry_view_state @@ -9767,6 +3231,7 @@ pub(crate) mod tests { thread_view.read_with(cx, |view, cx| { let active = view.as_active_thread().unwrap(); active + .read(cx) .entry_view_state .read_with(cx, |entry_view_state, _| { assert!( @@ -9800,8 +3265,8 @@ pub(crate) mod tests { setup_thread_view(StubAgentServer::new(connection.clone()), cx).await; let thread = thread_view - .read_with(cx, |view, _| { - view.as_active_thread().map(|r| r.thread.clone()) + .read_with(cx, |view, cx| { + view.as_active_thread().map(|r| r.read(cx).thread.clone()) }) .unwrap(); @@ -9822,18 +3287,14 @@ pub(crate) mod tests { cx.run_until_parked(); // Move somewhere else first so we're not trivially already on the last user prompt. - thread_view.update(cx, |view, cx| { + active_thread(&thread_view, cx).update(cx, |view, cx| { view.scroll_to_top(cx); }); cx.run_until_parked(); - thread_view.update(cx, |view, cx| { + active_thread(&thread_view, cx).update(cx, |view, cx| { view.scroll_to_most_recent_user_prompt(cx); - let scroll_top = view - .as_active_thread() - .map(|active| &active.list_state) - .unwrap() - .logical_scroll_top(); + let scroll_top = view.list_state.logical_scroll_top(); // Entries layout is: [User1, Assistant1, User2, Assistant2] assert_eq!(scroll_top.item_ix, 2); }); @@ -9848,13 +3309,9 @@ pub(crate) mod tests { let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; // With no entries, scrolling should be a no-op and must not panic. - thread_view.update(cx, |view, cx| { + active_thread(&thread_view, cx).update(cx, |view, cx| { view.scroll_to_most_recent_user_prompt(cx); - let scroll_top = view - .as_active_thread() - .map(|active| &active.list_state) - .unwrap() - .logical_scroll_top(); + let scroll_top = view.list_state.logical_scroll_top(); assert_eq!(scroll_top.item_ix, 0); }); } @@ -9872,25 +3329,23 @@ pub(crate) mod tests { let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; add_to_workspace(thread_view.clone(), cx); - let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + let message_editor = message_editor(&thread_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Original message to edit", window, cx); }); - thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.send(window, cx); - }); + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); let user_message_editor = thread_view.read_with(cx, |view, cx| { assert_eq!( view.as_active_thread() - .and_then(|active| active.editing_message), + .and_then(|active| active.read(cx).editing_message), None ); view.as_active_thread() - .map(|active| &active.entry_view_state) + .map(|active| &active.read(cx).entry_view_state) .as_ref() .unwrap() .read(cx) @@ -9903,10 +3358,10 @@ pub(crate) mod tests { // Focus cx.focus(&user_message_editor); - thread_view.read_with(cx, |view, _cx| { + thread_view.read_with(cx, |view, cx| { assert_eq!( view.as_active_thread() - .and_then(|active| active.editing_message), + .and_then(|active| active.read(cx).editing_message), Some(0) ); }); @@ -9921,10 +3376,10 @@ pub(crate) mod tests { window.dispatch_action(Box::new(editor::actions::Cancel), cx); }); - thread_view.read_with(cx, |view, _cx| { + thread_view.read_with(cx, |view, cx| { assert_eq!( view.as_active_thread() - .and_then(|active| active.editing_message), + .and_then(|active| active.read(cx).editing_message), None ); }); @@ -9943,7 +3398,7 @@ pub(crate) mod tests { let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; add_to_workspace(thread_view.clone(), cx); - let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + let message_editor = message_editor(&thread_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("", window, cx); }); @@ -9953,12 +3408,13 @@ pub(crate) mod tests { .read(cx) .as_active_thread() .unwrap() + .read(cx) .thread .clone() }); let entries_before = cx.read(|cx| thread.read(cx).entries().len()); - thread_view.update_in(cx, |view, window, cx| { + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| { view.send(window, cx); }); cx.run_until_parked(); @@ -9984,25 +3440,24 @@ pub(crate) mod tests { setup_thread_view(StubAgentServer::new(connection.clone()), cx).await; add_to_workspace(thread_view.clone(), cx); - let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + let message_editor = message_editor(&thread_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Original message to edit", window, cx); }); - thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.send(window, cx); - }); + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); let user_message_editor = thread_view.read_with(cx, |view, cx| { assert_eq!( view.as_active_thread() - .and_then(|active| active.editing_message), + .and_then(|active| active.read(cx).editing_message), None ); assert_eq!( view.as_active_thread() .unwrap() + .read(cx) .thread .read(cx) .entries() @@ -10011,7 +3466,7 @@ pub(crate) mod tests { ); view.as_active_thread() - .map(|active| &active.entry_view_state) + .map(|active| &active.read(cx).entry_view_state) .as_ref() .unwrap() .read(cx) @@ -10044,11 +3499,17 @@ pub(crate) mod tests { thread_view.read_with(cx, |view, cx| { assert_eq!( view.as_active_thread() - .and_then(|active| active.editing_message), + .and_then(|active| active.read(cx).editing_message), None ); - let entries = view.as_active_thread().unwrap().thread.read(cx).entries(); + let entries = view + .as_active_thread() + .unwrap() + .read(cx) + .thread + .read(cx) + .entries(); assert_eq!(entries.len(), 2); assert_eq!( entries[0].to_markdown(cx), @@ -10061,7 +3522,7 @@ pub(crate) mod tests { let entry_view_state = view .as_active_thread() - .map(|active| &active.entry_view_state) + .map(|active| &active.read(cx).entry_view_state) .unwrap(); let new_editor = entry_view_state.read_with(cx, |state, _cx| { assert!(!state.entry(1).unwrap().has_content()); @@ -10082,23 +3543,21 @@ pub(crate) mod tests { setup_thread_view(StubAgentServer::new(connection.clone()), cx).await; add_to_workspace(thread_view.clone(), cx); - let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + let message_editor = message_editor(&thread_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Original message to edit", window, cx); }); - thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.send(window, cx); - }); + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); let (user_message_editor, session_id) = thread_view.read_with(cx, |view, cx| { - let thread = view.as_active_thread().unwrap().thread.read(cx); + let thread = view.as_active_thread().unwrap().read(cx).thread.read(cx); assert_eq!(thread.entries().len(), 1); let editor = view .as_active_thread() - .map(|active| &active.entry_view_state) + .map(|active| &active.read(cx).entry_view_state) .as_ref() .unwrap() .read(cx) @@ -10114,10 +3573,10 @@ pub(crate) mod tests { // Focus cx.focus(&user_message_editor); - thread_view.read_with(cx, |view, _cx| { + thread_view.read_with(cx, |view, cx| { assert_eq!( view.as_active_thread() - .and_then(|active| active.editing_message), + .and_then(|active| active.read(cx).editing_message), Some(0) ); }); @@ -10127,10 +3586,10 @@ pub(crate) mod tests { editor.set_text("Edited message content", window, cx); }); - thread_view.read_with(cx, |view, _cx| { + thread_view.read_with(cx, |view, cx| { assert_eq!( view.as_active_thread() - .and_then(|active| active.editing_message), + .and_then(|active| active.read(cx).editing_message), Some(0) ); }); @@ -10145,10 +3604,10 @@ pub(crate) mod tests { connection.end_turn(session_id, acp::StopReason::EndTurn); }); - thread_view.read_with(cx, |view, _cx| { + thread_view.read_with(cx, |view, cx| { assert_eq!( view.as_active_thread() - .and_then(|active| active.editing_message), + .and_then(|active| active.read(cx).editing_message), Some(0) ); }); @@ -10162,7 +3621,7 @@ pub(crate) mod tests { thread_view .read(cx) .as_active_thread() - .and_then(|active| active.editing_message), + .and_then(|active| active.read(cx).editing_message), Some(0) ); assert_eq!( @@ -10187,16 +3646,20 @@ pub(crate) mod tests { setup_thread_view(StubAgentServer::new(connection.clone()), cx).await; add_to_workspace(thread_view.clone(), cx); - let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + let message_editor = message_editor(&thread_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Hello", window, cx); }); - thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.send(window, cx); - }); + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); let (thread, session_id) = thread_view.read_with(cx, |view, cx| { - let thread = view.as_active_thread().as_ref().unwrap().thread.clone(); + let thread = view + .as_active_thread() + .as_ref() + .unwrap() + .read(cx) + .thread + .clone(); (thread.clone(), thread.read(cx).session_id().clone()) }); @@ -10236,7 +3699,7 @@ pub(crate) mod tests { let focus_handle = setup .thread_view - .read_with(cx, |view, _cx| view.focus_handle.clone()); + .read_with(cx, |view, cx| view.focus_handle(cx)); cx.update(|window, cx| { window.focus(&focus_handle, cx); }); @@ -10284,8 +3747,8 @@ pub(crate) mod tests { setup_thread_view(StubAgentServer::new(StubAgentConnection::new()), cx).await; add_to_workspace(thread_view.clone(), cx); - let thread = thread_view.read_with(cx, |view, _cx| { - view.as_active_thread().unwrap().thread.clone() + let thread = thread_view.read_with(cx, |view, cx| { + view.as_active_thread().unwrap().read(cx).thread.clone() }); thread.read_with(cx, |thread, _cx| { @@ -10318,16 +3781,14 @@ pub(crate) mod tests { setup_thread_view(StubAgentServer::new(connection.clone()), cx).await; add_to_workspace(thread_view.clone(), cx); - let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + let message_editor = message_editor(&thread_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Message 1", window, cx); }); - thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.send(window, cx); - }); + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); let (thread, session_id) = thread_view.read_with(cx, |view, cx| { - let thread = view.as_active_thread().unwrap().thread.clone(); + let thread = view.as_active_thread().unwrap().read(cx).thread.clone(); (thread.clone(), thread.read(cx).session_id().clone()) }); @@ -10350,24 +3811,23 @@ pub(crate) mod tests { assert_eq!( thread.to_markdown(cx), indoc::indoc! {" - ## User + ## User - Message 1 + Message 1 - ## Assistant + ## Assistant - Message 1 resp + Message 1 resp - "} + "} ) }); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Message 2", window, cx); }); - thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.interrupt_and_send(window, cx); - }); + active_thread(&thread_view, cx) + .update_in(cx, |view, window, cx| view.interrupt_and_send(window, cx)); cx.update(|_, cx| { // Simulate a response sent after beginning to cancel @@ -10385,19 +3845,19 @@ pub(crate) mod tests { assert_eq!( thread.to_markdown(cx), indoc::indoc! {" - ## User + ## User - Message 1 + Message 1 - ## Assistant + ## Assistant - Message 1 response + Message 1 response - ## User + ## User - Message 2 + Message 2 - "} + "} ) }); @@ -10418,23 +3878,23 @@ pub(crate) mod tests { assert_eq!( thread.to_markdown(cx), indoc::indoc! {" - ## User + ## User - Message 1 + Message 1 - ## Assistant + ## Assistant - Message 1 response + Message 1 response - ## User + ## User - Message 2 + Message 2 - ## Assistant + ## Assistant - Message 2 response + Message 2 response - "} + "} ) }); } @@ -10451,17 +3911,17 @@ pub(crate) mod tests { let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; add_to_workspace(thread_view.clone(), cx); - let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + let message_editor = message_editor(&thread_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Original message to edit", window, cx) }); - thread_view.update_in(cx, |thread_view, window, cx| thread_view.send(window, cx)); + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); let user_message_editor = thread_view.read_with(cx, |thread_view, cx| { thread_view .as_active_thread() - .map(|active| &active.entry_view_state) + .map(|active| &active.read(cx).entry_view_state) .as_ref() .unwrap() .read(cx) @@ -10473,10 +3933,10 @@ pub(crate) mod tests { }); cx.focus(&user_message_editor); - thread_view.read_with(cx, |view, _cx| { + thread_view.read_with(cx, |view, cx| { assert_eq!( view.as_active_thread() - .and_then(|active| active.editing_message), + .and_then(|active| active.read(cx).editing_message), Some(0) ); }); @@ -10515,7 +3975,7 @@ pub(crate) mod tests { thread_view.update_in(cx, |view, window, cx| { assert_eq!( view.as_active_thread() - .and_then(|active| active.editing_message), + .and_then(|active| active.read(cx).editing_message), Some(0) ); view.insert_selections(window, cx); @@ -10541,7 +4001,7 @@ pub(crate) mod tests { let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; add_to_workspace(thread_view.clone(), cx); - let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + let message_editor = message_editor(&thread_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Can you review this snippet ", window, cx) }); @@ -10574,14 +4034,14 @@ pub(crate) mod tests { thread_view.update_in(cx, |view, window, cx| { assert_eq!( view.as_active_thread() - .and_then(|active| active.editing_message), + .and_then(|active| active.read(cx).editing_message), None ); view.insert_selections(window, cx); }); - thread_view.read_with(cx, |thread_view, cx| { - let text = thread_view.message_editor.read(cx).text(cx); + message_editor.read_with(cx, |editor, cx| { + let text = editor.text(cx); let expected_txt = String::from("Can you review this snippet selection "); assert_eq!(text, expected_txt); @@ -10620,14 +4080,12 @@ pub(crate) mod tests { ); }); - let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + let message_editor = message_editor(&thread_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Run cargo build", window, cx); }); - thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.send(window, cx); - }); + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); @@ -10636,6 +4094,7 @@ pub(crate) mod tests { let thread = thread_view .as_active_thread() .expect("Thread should exist") + .read(cx) .thread .clone(); let thread = thread.read(cx); @@ -10728,14 +4187,12 @@ pub(crate) mod tests { ); }); - let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + let message_editor = message_editor(&thread_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Edit the main file", window, cx); }); - thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.send(window, cx); - }); + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); @@ -10744,6 +4201,7 @@ pub(crate) mod tests { let thread = thread_view .as_active_thread() .expect("Thread should exist") + .read(cx) .thread .clone(); let thread = thread.read(cx); @@ -10816,14 +4274,12 @@ pub(crate) mod tests { ); }); - let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + let message_editor = message_editor(&thread_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Fetch the docs", window, cx); }); - thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.send(window, cx); - }); + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); @@ -10832,6 +4288,7 @@ pub(crate) mod tests { let thread = thread_view .as_active_thread() .expect("Thread should exist") + .read(cx) .thread .clone(); let thread = thread.read(cx); @@ -10905,14 +4362,12 @@ pub(crate) mod tests { ); }); - let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + let message_editor = message_editor(&thread_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Run the deploy script", window, cx); }); - thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.send(window, cx); - }); + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); @@ -10921,6 +4376,7 @@ pub(crate) mod tests { let thread = thread_view .as_active_thread() .expect("Thread should exist") + .read(cx) .thread .clone(); let thread = thread.read(cx); @@ -11004,14 +4460,12 @@ pub(crate) mod tests { ); }); - let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + let message_editor = message_editor(&thread_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Run tests", window, cx); }); - thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.send(window, cx); - }); + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); @@ -11020,6 +4474,7 @@ pub(crate) mod tests { let thread = thread_view .as_active_thread() .expect("Thread should exist") + .read(cx) .thread .clone(); let thread = thread.read(cx); @@ -11047,14 +4502,14 @@ pub(crate) mod tests { // Verify tool call is no longer waiting for confirmation (was authorized) thread_view.read_with(cx, |thread_view, cx| { - let thread = thread_view.as_active_thread().expect("Thread should exist").thread.clone(); - let thread = thread.read(cx); - let tool_call = thread.first_tool_awaiting_confirmation(); - assert!( - tool_call.is_none(), - "Tool call should no longer be waiting for confirmation after AuthorizeToolCall action" - ); - }); + let thread = thread_view.as_active_thread().expect("Thread should exist").read(cx).thread.clone(); + let thread = thread.read(cx); + let tool_call = thread.first_tool_awaiting_confirmation(); + assert!( + tool_call.is_none(), + "Tool call should no longer be waiting for confirmation after AuthorizeToolCall action" + ); + }); } #[gpui::test] @@ -11089,14 +4544,12 @@ pub(crate) mod tests { ); }); - let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + let message_editor = message_editor(&thread_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Install dependencies", window, cx); }); - thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.send(window, cx); - }); + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); @@ -11136,6 +4589,7 @@ pub(crate) mod tests { let thread = thread_view .as_active_thread() .expect("Thread should exist") + .read(cx) .thread .clone(); let thread = thread.read(cx); @@ -11179,21 +4633,22 @@ pub(crate) mod tests { ); }); - let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + let message_editor = message_editor(&thread_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Build the project", window, cx); }); - thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.send(window, cx); - }); + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); // Verify default granularity is the last option (index 2 = "Only this time") - thread_view.read_with(cx, |thread_view, _cx| { + thread_view.read_with(cx, |thread_view, cx| { let state = thread_view.as_active_thread().unwrap(); - let selected = state.selected_permission_granularity.get(&tool_call_id); + let selected = state + .read(cx) + .selected_permission_granularity + .get(&tool_call_id); assert!( selected.is_none(), "Should have no selection initially (defaults to last)" @@ -11215,9 +4670,12 @@ pub(crate) mod tests { cx.run_until_parked(); // Verify the selection was updated - thread_view.read_with(cx, |thread_view, _cx| { + thread_view.read_with(cx, |thread_view, cx| { let state = thread_view.as_active_thread().unwrap(); - let selected = state.selected_permission_granularity.get(&tool_call_id); + let selected = state + .read(cx) + .selected_permission_granularity + .get(&tool_call_id); assert_eq!(selected, Some(&0), "Should have selected index 0"); }); } @@ -11276,14 +4734,12 @@ pub(crate) mod tests { ); }); - let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + let message_editor = message_editor(&thread_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Install dependencies", window, cx); }); - thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.send(window, cx); - }); + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); @@ -11303,8 +4759,8 @@ pub(crate) mod tests { // Simulate clicking the Allow button by dispatching AllowOnce action // which should use the selected granularity - thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.allow_once(&AllowOnce, window, cx); + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| { + view.allow_once(&AllowOnce, window, cx) }); cx.run_until_parked(); @@ -11314,6 +4770,7 @@ pub(crate) mod tests { let thread = thread_view .as_active_thread() .expect("Thread should exist") + .read(cx) .thread .clone(); let thread = thread.read(cx); @@ -11357,21 +4814,19 @@ pub(crate) mod tests { ); }); - let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + let message_editor = message_editor(&thread_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Push changes", window, cx); }); - thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.send(window, cx); - }); + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); // Use default granularity (last option = "Only this time") // Simulate clicking the Deny button - thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.reject_once(&RejectOnce, window, cx); + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| { + view.reject_once(&RejectOnce, window, cx) }); cx.run_until_parked(); @@ -11381,6 +4836,7 @@ pub(crate) mod tests { let thread = thread_view .as_active_thread() .expect("Thread should exist") + .read(cx) .thread .clone(); let thread = thread.read(cx); diff --git a/crates/agent_ui/src/acp/thread_view/active_thread.rs b/crates/agent_ui/src/acp/thread_view/active_thread.rs index bd902f28ccf7e53abf6fc2abd7e62581edf3bf88..6e30c6d2769c6df444b9aeaa35f2d935553f199b 100644 --- a/crates/agent_ui/src/acp/thread_view/active_thread.rs +++ b/crates/agent_ui/src/acp/thread_view/active_thread.rs @@ -1,8 +1,173 @@ +use gpui::List; + use super::*; +#[derive(Default)] +struct ThreadFeedbackState { + feedback: Option, + comments_editor: Option>, +} + +impl ThreadFeedbackState { + pub fn submit( + &mut self, + thread: Entity, + 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, 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 { + 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>, 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, // is some <=> Active | Unauthenticated pub thread: Entity, + pub server_view: WeakEntity, + pub agent_name: SharedString, pub workspace: WeakEntity, pub entry_view_state: Entity, pub title_editor: Option>, @@ -15,12 +180,10 @@ pub struct AcpThreadView { pub(super) thread_error: Option, pub thread_error_markdown: Option>, pub token_limit_callout_dismissed: bool, - pub(super) thread_feedback: ThreadFeedbackState, + thread_feedback: ThreadFeedbackState, pub list_state: ListState, pub prompt_capabilities: Rc>, pub available_commands: Rc>>, - pub cached_user_commands: Rc>>, - pub cached_user_command_errors: Rc>>, /// Tracks which tool calls have their content/output expanded. /// Used for showing/hiding tool call results, terminal output, etc. pub expanded_tool_calls: HashSet, @@ -39,7 +202,6 @@ pub struct AcpThreadView { pub queued_message_editor_subscriptions: Vec, pub last_synced_queue_length: usize, pub turn_fields: TurnFields, - pub command_load_errors_dismissed: bool, pub discarded_partial_edits: HashSet, pub is_loading_contents: bool, pub new_server_version_available: Option, @@ -54,7 +216,21 @@ pub struct AcpThreadView { pub user_interrupted_generation: bool, pub can_fast_track_queue: bool, pub hovered_edited_file_buttons: Option, + pub in_flight_prompt: Option>, pub _subscriptions: Vec, + pub message_editor: Entity, + pub add_context_menu_handle: PopoverMenuHandle, + pub project: WeakEntity, + pub recent_history_entries: Vec, + pub hovered_recent_history_item: Option, + pub show_codex_windows_warning: bool, + pub history: Entity, + 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, + login: Option, + server_view: WeakEntity, + agent_name: SharedString, + agent_display_name: SharedString, workspace: WeakEntity, entry_view_state: Entity, title_editor: Option>, @@ -80,17 +260,84 @@ impl AcpThreadView { list_state: ListState, prompt_capabilities: Rc>, available_commands: Rc>>, - cached_user_commands: Rc>>, - cached_user_command_errors: Rc>>, resumed_without_history: bool, resume_thread_metadata: Option, - subscriptions: Vec, - cx: &App, + project: WeakEntity, + thread_store: Option>, + history: Entity, + prompt_store: Option>, + initial_content: Option, + mut subscriptions: Vec, + window: &mut Window, + cx: &mut Context, ) -> 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, + event: &MessageEditorEvent, + window: &mut Window, + cx: &mut Context, + ) { + 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 { + 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, + event: &EntryViewEvent, + window: &mut Window, + cx: &mut Context, + ) { + 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) -> usize { + pub fn start_turn(&mut self, cx: &mut Context) -> 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, - agent_name: SharedString, - login: Option, - window: &mut Window, - cx: &mut Context, - ) { + pub fn send(&mut self, window: &mut Window, cx: &mut Context) { 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, window: &mut Window, - cx: &mut Context, + cx: &mut Context, ) { 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, Vec>)>>>, window: &mut Window, - cx: &mut Context, + cx: &mut Context, ) { 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, - window: &mut Window, - cx: &mut Context, - ) { + pub fn interrupt_and_send(&mut self, window: &mut Window, cx: &mut Context) { 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, window: &mut Window, - cx: &mut Context, + cx: &mut Context, ) { 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) { + 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.emit_thread_error_telemetry(&error, cx); + self.thread_error = Some(error); + cx.notify(); + } + + fn emit_thread_error_telemetry(&self, error: &ThreadError, cx: &mut Context) { + let (error_kind, acp_error_code, message): (&str, Option, 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) { + pub fn cancel_generation(&mut self, cx: &mut Context) { 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) { + pub fn retry_generation(&mut self, cx: &mut Context) { 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, window: &mut Window, - cx: &mut Context, + cx: &mut Context, ) { 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, window: &mut Window, - cx: &mut Context, + cx: &mut Context, ) { 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, + tracked_buffers: Vec>, + cx: &mut Context, + ) { + 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, + cx: &mut Context, ) -> Option { 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) { + pub fn sync_queue_flag_to_native_thread(&self, cx: &mut Context) { 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, + cx: &mut Context, ) { 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, - cx: &mut Context, + _: &ExpandMessageEditor, + _window: &mut Window, + cx: &mut Context, ) { - 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, - cx: &mut Context, - ) { + pub fn set_editor_is_expanded(&mut self, is_expanded: bool, cx: &mut Context) { 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, event: &EditorEvent, window: &mut Window, - cx: &mut Context, + cx: &mut Context, ) { 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, - ) { + pub fn cancel_editing(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { 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, + cx: &mut Context, ) { 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.authorize_pending_tool_call(acp::PermissionOptionKind::AllowAlways, window, cx); + } + + pub fn allow_once(&mut self, _: &AllowOnce, window: &mut Window, cx: &mut Context) { + self.authorize_pending_with_granularity(true, window, cx); + } + + pub fn reject_once(&mut self, _: &RejectOnce, window: &mut Window, cx: &mut Context) { + 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, + cx: &mut Context, ) -> 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, + ) { + 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, + _window: &mut Window, + cx: &mut Context, ) { 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, + ) -> 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) { + pub fn keep_all(&mut self, _: &KeepAll, _window: &mut Window, cx: &mut Context) { let thread = &self.thread; let telemetry = ActionLogTelemetry::from(thread.read(cx)); let action_log = thread.read(cx).action_log().clone(); @@ -887,7 +1339,7 @@ impl AcpThreadView { }); } - pub fn reject_all(&mut self, cx: &mut Context) { + pub fn reject_all(&mut self, _: &RejectAll, _window: &mut Window, cx: &mut Context) { let thread = &self.thread; let telemetry = ActionLogTelemetry::from(thread.read(cx)); let action_log = thread.read(cx).action_log().clone(); @@ -902,7 +1354,7 @@ impl AcpThreadView { &mut self, buffer: &Entity, window: &mut Window, - cx: &mut Context, + cx: &mut Context, ) { let thread = &self.thread; @@ -919,30 +1371,87 @@ impl AcpThreadView { // thread stuff - pub fn sync_thread( - &mut self, - project: Entity, - window: &mut Window, - cx: &mut Context, - ) { - if !self.is_imported_thread(cx) { - return; - } - - let Some(session_list) = self - .as_native_connection(cx) - .and_then(|connection| connection.session_list(cx)) - .and_then(|list| list.downcast::()) - else { + fn share_thread(&mut self, _window: &mut Window, cx: &mut Context) { + let Some((thread, project)) = self.as_native_thread(cx).zip(self.project.upgrade()) else { return; }; - let thread_store = session_list.thread_store().clone(); let client = project.read(cx).client(); - let session_id = self.thread.read(cx).session_id().clone(); + let workspace = self.workspace.clone(); + let session_id = thread.read(cx).id().to_string(); - cx.spawn_in(window, async move |this, cx| { - let response = client + 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::(), + "Thread shared!", + ) + .on_click( + "Copy URL", + move |_window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + share_url.clone(), + )); + }, + ), + cx, + ); + }); + } + }); + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + pub fn sync_thread( + &mut self, + project: Entity, + server_view: Entity, + window: &mut Window, + cx: &mut Context, + ) { + if !self.is_imported_thread(cx) { + return; + } + + let Some(session_list) = self + .as_native_connection(cx) + .and_then(|connection| connection.session_list(cx)) + .and_then(|list| list.downcast::()) + else { + return; + }; + let thread_store = session_list.thread_store().clone(); + + let client = project.read(cx).client(); + let session_id = self.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(), }) @@ -967,11 +1476,8 @@ impl AcpThreadView { }; this.update_in(cx, |this, window, cx| { - if let Some(thread) = this.as_active_thread_mut() { - let resume_thread_metadata = &mut thread.resume_thread_metadata; - *resume_thread_metadata = Some(thread_metadata); - } - this.reset(window, cx); + this.resume_thread_metadata = Some(thread_metadata); + server_view.update(cx, |server_view, cx| server_view.reset(window, cx)); })?; this.update_in(cx, |this, _window, cx| { @@ -995,11 +1501,7 @@ impl AcpThreadView { .detach_and_log_err(cx); } - pub fn restore_checkpoint( - &mut self, - message_id: &UserMessageId, - cx: &mut Context, - ) { + pub fn restore_checkpoint(&mut self, message_id: &UserMessageId, cx: &mut Context) { self.thread .update(cx, |thread, cx| { thread.restore_checkpoint(message_id.clone(), cx) @@ -1007,116 +1509,46 @@ impl AcpThreadView { .detach_and_log_err(cx); } - pub fn clear_thread_error(&mut self, cx: &mut Context) { + pub fn clear_thread_error(&mut self, cx: &mut Context) { self.thread_error = None; self.thread_error_markdown = None; self.token_limit_callout_dismissed = true; cx.notify(); } - // other - - pub fn refresh_cached_user_commands_from_registry( - &mut self, - registry: &Entity, - cx: &App, - ) { - let (mut commands, mut errors) = registry.read_with(cx, |registry, _| { - (registry.commands().clone(), registry.errors().to_vec()) - }); - let server_command_names = self - .available_commands - .borrow() - .iter() - .map(|command| command.name.clone()) - .collect::>(); - user_slash_command::apply_server_command_conflicts_to_map( - &mut commands, - &mut errors, - &server_command_names, - ); - - self.command_load_errors_dismissed = false; - *self.cached_user_commands.borrow_mut() = commands; - *self.cached_user_command_errors.borrow_mut() = errors; + fn is_following(&self, cx: &App) -> bool { + match self.thread.read(cx).status() { + ThreadStatus::Generating => self + .workspace + .read_with(cx, |workspace, _| { + workspace.is_being_followed(CollaboratorId::Agent) + }) + .unwrap_or(false), + _ => self.should_be_following, + } } - pub fn render_command_load_errors( - &self, - cx: &mut Context, - ) -> Option { - let errors = self.cached_user_command_errors.borrow(); + fn toggle_following(&mut self, window: &mut Window, cx: &mut Context) { + let following = self.is_following(cx); - if self.command_load_errors_dismissed || errors.is_empty() { - return None; + self.should_be_following = !following; + if self.thread.read(cx).status() == ThreadStatus::Generating { + self.workspace + .update(cx, |workspace, cx| { + if following { + workspace.unfollow(CollaboratorId::Agent, window, cx); + } else { + workspace.follow(CollaboratorId::Agent, window, cx); + } + }) + .ok(); } - let workspace = self.workspace.clone(); - - let error_count = errors.len(); - let title = if error_count == 1 { - "Failed to load slash command" - } else { - "Failed to load slash commands" - }; - - Some( - Callout::new() - .icon(IconName::Warning) - .severity(Severity::Warning) - .title(title) - .actions_slot( - IconButton::new("dismiss-command-errors", IconName::Close) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip(Tooltip::text("Dismiss Error")) - .on_click(cx.listener(|this, _, _, cx| { - this.clear_command_load_errors(cx); - })), - ) - .description_slot(v_flex().children(errors.iter().enumerate().map({ - move |(i, error)| { - let path = error.path.clone(); - let workspace = workspace.clone(); - let file_name = error - .path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| error.path.display().to_string()); - let id = ElementId::Name(format!("command-error-{i}").into()); - let label = format!("β€” {}: {}", file_name, error.message); - - Button::new(id, label) - .label_size(LabelSize::Small) - .truncate(true) - .tooltip({ - let message: SharedString = error.message.clone().into(); - let path: SharedString = error.path.display().to_string().into(); - move |_, cx| { - Tooltip::with_meta(message.clone(), None, path.clone(), cx) - } - }) - .on_click({ - move |_, window, cx| { - if let Some(workspace) = workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - workspace - .open_abs_path( - path.clone(), - OpenOptions::default(), - window, - cx, - ) - .detach_and_log_err(cx); - }); - } - } - }) - } - }))), - ) + telemetry::event!("Follow Agent Selected", following = !following); } + // other + pub fn render_thread_retry_status_callout(&self) -> Option { let state = self.thread_retry_status.as_ref()?; @@ -1156,7 +1588,12 @@ impl AcpThreadView { ) } - pub fn handle_open_rules(&mut self, window: &mut Window, cx: &mut Context) { + pub fn handle_open_rules( + &mut self, + _: &ClickEvent, + window: &mut Window, + cx: &mut Context, + ) { let Some(thread) = self.as_native_thread(cx) else { return; }; @@ -1187,4 +1624,5442 @@ impl AcpThreadView { }) .ok(); } + + fn activity_bar_bg(&self, cx: &Context) -> Hsla { + let editor_bg_color = cx.theme().colors().editor_background; + let active_color = cx.theme().colors().element_selected; + editor_bg_color.blend(active_color.opacity(0.3)) + } + + pub fn render_activity_bar( + &self, + window: &mut Window, + cx: &Context, + ) -> Option { + let thread = self.thread.read(cx); + let action_log = thread.action_log(); + let telemetry = ActionLogTelemetry::from(thread); + let changed_buffers = action_log.read(cx).changed_buffers(cx); + let plan = thread.plan(); + let queue_is_empty = !self.has_queued_messages(); + + if changed_buffers.is_empty() && plan.is_empty() && queue_is_empty { + return None; + } + + // Temporarily always enable ACP edit controls. This is temporary, to lessen the + // impact of a nasty bug that causes them to sometimes be disabled when they shouldn't + // be, which blocks you from being able to accept or reject edits. This switches the + // bug to be that sometimes it's enabled when it shouldn't be, which at least doesn't + // block you from using the panel. + let pending_edits = false; + + let plan_expanded = self.plan_expanded; + let edits_expanded = self.edits_expanded; + let queue_expanded = self.queue_expanded; + + v_flex() + .mt_1() + .mx_2() + .bg(self.activity_bar_bg(cx)) + .border_1() + .border_b_0() + .border_color(cx.theme().colors().border) + .rounded_t_md() + .shadow(vec![gpui::BoxShadow { + color: gpui::black().opacity(0.15), + offset: point(px(1.), px(-1.)), + blur_radius: px(3.), + spread_radius: px(0.), + }]) + .when(!plan.is_empty(), |this| { + this.child(self.render_plan_summary(plan, window, cx)) + .when(plan_expanded, |parent| { + parent.child(self.render_plan_entries(plan, window, cx)) + }) + }) + .when(!plan.is_empty() && !changed_buffers.is_empty(), |this| { + this.child(Divider::horizontal().color(DividerColor::Border)) + }) + .when(!changed_buffers.is_empty(), |this| { + this.child(self.render_edits_summary( + &changed_buffers, + edits_expanded, + pending_edits, + cx, + )) + .when(edits_expanded, |parent| { + parent.child(self.render_edited_files( + action_log, + telemetry.clone(), + &changed_buffers, + pending_edits, + cx, + )) + }) + }) + .when(!queue_is_empty, |this| { + this.when(!plan.is_empty() || !changed_buffers.is_empty(), |this| { + this.child(Divider::horizontal().color(DividerColor::Border)) + }) + .child(self.render_message_queue_summary(window, cx)) + .when(queue_expanded, |parent| { + parent.child(self.render_message_queue_entries(window, cx)) + }) + }) + .into_any() + .into() + } + + fn render_edited_files( + &self, + action_log: &Entity, + telemetry: ActionLogTelemetry, + changed_buffers: &BTreeMap, Entity>, + pending_edits: bool, + cx: &Context, + ) -> impl IntoElement { + let editor_bg_color = cx.theme().colors().editor_background; + + // Sort edited files alphabetically for consistency with Git diff view + let mut sorted_buffers: Vec<_> = changed_buffers.iter().collect(); + sorted_buffers.sort_by(|(buffer_a, _), (buffer_b, _)| { + let path_a = buffer_a.read(cx).file().map(|f| f.path().clone()); + let path_b = buffer_b.read(cx).file().map(|f| f.path().clone()); + path_a.cmp(&path_b) + }); + + v_flex() + .id("edited_files_list") + .max_h_40() + .overflow_y_scroll() + .children( + sorted_buffers + .into_iter() + .enumerate() + .flat_map(|(index, (buffer, diff))| { + let file = buffer.read(cx).file()?; + let path = file.path(); + let path_style = file.path_style(cx); + let separator = file.path_style(cx).primary_separator(); + + let file_path = path.parent().and_then(|parent| { + if parent.is_empty() { + None + } else { + Some( + Label::new(format!( + "{}{separator}", + parent.display(path_style) + )) + .color(Color::Muted) + .size(LabelSize::XSmall) + .buffer_font(cx), + ) + } + }); + + let file_name = path.file_name().map(|name| { + Label::new(name.to_string()) + .size(LabelSize::XSmall) + .buffer_font(cx) + .ml_1() + }); + + let full_path = path.display(path_style).to_string(); + + let file_icon = FileIcons::get_icon(path.as_std_path(), cx) + .map(Icon::from_path) + .map(|icon| icon.color(Color::Muted).size(IconSize::Small)) + .unwrap_or_else(|| { + Icon::new(IconName::File) + .color(Color::Muted) + .size(IconSize::Small) + }); + + let file_stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx); + + let buttons = self.render_edited_files_buttons( + index, + buffer, + action_log, + &telemetry, + pending_edits, + editor_bg_color, + cx, + ); + + let element = h_flex() + .group("edited-code") + .id(("file-container", index)) + .relative() + .min_w_0() + .p_1p5() + .gap_2() + .justify_between() + .bg(editor_bg_color) + .when(index < changed_buffers.len() - 1, |parent| { + parent.border_color(cx.theme().colors().border).border_b_1() + }) + .child( + h_flex() + .id(("file-name-path", index)) + .cursor_pointer() + .pr_0p5() + .gap_0p5() + .rounded_xs() + .child(file_icon) + .children(file_name) + .children(file_path) + .child( + DiffStat::new( + "file", + file_stats.lines_added as usize, + file_stats.lines_removed as usize, + ) + .label_size(LabelSize::XSmall), + ) + .hover(|s| s.bg(cx.theme().colors().element_hover)) + .tooltip({ + move |_, cx| { + Tooltip::with_meta( + "Go to File", + None, + full_path.clone(), + cx, + ) + } + }) + .on_click({ + let buffer = buffer.clone(); + cx.listener(move |this, _, window, cx| { + this.open_edited_buffer(&buffer, window, cx); + }) + }), + ) + .child(buttons); + + Some(element) + }), + ) + .into_any_element() + } + + fn render_edited_files_buttons( + &self, + index: usize, + buffer: &Entity, + action_log: &Entity, + telemetry: &ActionLogTelemetry, + pending_edits: bool, + editor_bg_color: Hsla, + cx: &Context, + ) -> impl IntoElement { + h_flex() + .id("edited-buttons-container") + .visible_on_hover("edited-code") + .absolute() + .right_0() + .px_1() + .gap_1() + .bg(editor_bg_color) + .on_hover(cx.listener(move |this, is_hovered, _window, cx| { + if *is_hovered { + this.hovered_edited_file_buttons = Some(index); + } else if this.hovered_edited_file_buttons == Some(index) { + this.hovered_edited_file_buttons = None; + } + cx.notify(); + })) + .child( + Button::new("review", "Review") + .label_size(LabelSize::Small) + .on_click({ + let buffer = buffer.clone(); + cx.listener(move |this, _, window, cx| { + this.open_edited_buffer(&buffer, window, cx); + }) + }), + ) + .child( + Button::new(("reject-file", index), "Reject") + .label_size(LabelSize::Small) + .disabled(pending_edits) + .on_click({ + let buffer = buffer.clone(); + let action_log = action_log.clone(); + let telemetry = telemetry.clone(); + move |_, _, cx| { + action_log.update(cx, |action_log, cx| { + action_log + .reject_edits_in_ranges( + buffer.clone(), + vec![Anchor::min_max_range_for_buffer( + buffer.read(cx).remote_id(), + )], + Some(telemetry.clone()), + cx, + ) + .detach_and_log_err(cx); + }) + } + }), + ) + .child( + Button::new(("keep-file", index), "Keep") + .label_size(LabelSize::Small) + .disabled(pending_edits) + .on_click({ + let buffer = buffer.clone(); + let action_log = action_log.clone(); + let telemetry = telemetry.clone(); + move |_, _, cx| { + action_log.update(cx, |action_log, cx| { + action_log.keep_edits_in_range( + buffer.clone(), + Anchor::min_max_range_for_buffer(buffer.read(cx).remote_id()), + Some(telemetry.clone()), + cx, + ); + }) + } + }), + ) + } + + fn render_message_queue_summary( + &self, + _window: &mut Window, + cx: &Context, + ) -> impl IntoElement { + let queue_count = self.local_queued_messages.len(); + let title: SharedString = if queue_count == 1 { + "1 Queued Message".into() + } else { + format!("{} Queued Messages", queue_count).into() + }; + + h_flex() + .p_1() + .w_full() + .gap_1() + .justify_between() + .when(self.queue_expanded, |this| { + this.border_b_1().border_color(cx.theme().colors().border) + }) + .child( + h_flex() + .id("queue_summary") + .gap_1() + .child(Disclosure::new("queue_disclosure", self.queue_expanded)) + .child(Label::new(title).size(LabelSize::Small).color(Color::Muted)) + .on_click(cx.listener(|this, _, _, cx| { + this.queue_expanded = !this.queue_expanded; + cx.notify(); + })), + ) + .child( + Button::new("clear_queue", "Clear All") + .label_size(LabelSize::Small) + .key_binding(KeyBinding::for_action(&ClearMessageQueue, cx)) + .on_click(cx.listener(|this, _, _, cx| { + this.clear_queue(cx); + this.can_fast_track_queue = false; + cx.notify(); + })), + ) + .into_any_element() + } + + fn clear_queue(&mut self, cx: &mut Context) { + self.local_queued_messages.clear(); + self.sync_queue_flag_to_native_thread(cx); + } + + fn render_plan_summary( + &self, + plan: &Plan, + window: &mut Window, + cx: &Context, + ) -> impl IntoElement { + let plan_expanded = self.plan_expanded; + let stats = plan.stats(); + + let title = if let Some(entry) = stats.in_progress_entry + && !plan_expanded + { + h_flex() + .cursor_default() + .relative() + .w_full() + .gap_1() + .truncate() + .child( + Label::new("Current:") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().colors().text_muted) + .line_clamp(1) + .child(MarkdownElement::new( + entry.content.clone(), + plan_label_markdown_style(&entry.status, window, cx), + )), + ) + .when(stats.pending > 0, |this| { + this.child( + h_flex() + .absolute() + .top_0() + .right_0() + .h_full() + .child(div().min_w_8().h_full().bg(linear_gradient( + 90., + linear_color_stop(self.activity_bar_bg(cx), 1.), + linear_color_stop(self.activity_bar_bg(cx).opacity(0.2), 0.), + ))) + .child( + div().pr_0p5().bg(self.activity_bar_bg(cx)).child( + Label::new(format!("{} left", stats.pending)) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ), + ) + }) + } else { + let status_label = if stats.pending == 0 { + "All Done".to_string() + } else if stats.completed == 0 { + format!("{} Tasks", plan.entries.len()) + } else { + format!("{}/{}", stats.completed, plan.entries.len()) + }; + + h_flex() + .w_full() + .gap_1() + .justify_between() + .child( + Label::new("Plan") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + Label::new(status_label) + .size(LabelSize::Small) + .color(Color::Muted) + .mr_1(), + ) + }; + + h_flex() + .id("plan_summary") + .p_1() + .w_full() + .gap_1() + .when(plan_expanded, |this| { + this.border_b_1().border_color(cx.theme().colors().border) + }) + .child(Disclosure::new("plan_disclosure", plan_expanded)) + .child(title) + .on_click(cx.listener(|this, _, _, cx| { + this.plan_expanded = !this.plan_expanded; + cx.notify(); + })) + .into_any_element() + } + + fn render_plan_entries( + &self, + plan: &Plan, + window: &mut Window, + cx: &Context, + ) -> impl IntoElement { + v_flex() + .id("plan_items_list") + .max_h_40() + .overflow_y_scroll() + .children(plan.entries.iter().enumerate().flat_map(|(index, entry)| { + let element = h_flex() + .py_1() + .px_2() + .gap_2() + .justify_between() + .bg(cx.theme().colors().editor_background) + .when(index < plan.entries.len() - 1, |parent| { + parent.border_color(cx.theme().colors().border).border_b_1() + }) + .child( + h_flex() + .id(("plan_entry", index)) + .gap_1p5() + .max_w_full() + .overflow_x_scroll() + .text_xs() + .text_color(cx.theme().colors().text_muted) + .child(match entry.status { + acp::PlanEntryStatus::InProgress => { + Icon::new(IconName::TodoProgress) + .size(IconSize::Small) + .color(Color::Accent) + .with_rotate_animation(2) + .into_any_element() + } + acp::PlanEntryStatus::Completed => { + Icon::new(IconName::TodoComplete) + .size(IconSize::Small) + .color(Color::Success) + .into_any_element() + } + acp::PlanEntryStatus::Pending | _ => { + Icon::new(IconName::TodoPending) + .size(IconSize::Small) + .color(Color::Muted) + .into_any_element() + } + }) + .child(MarkdownElement::new( + entry.content.clone(), + plan_label_markdown_style(&entry.status, window, cx), + )), + ); + + Some(element) + })) + .into_any_element() + } + + fn render_edits_summary( + &self, + changed_buffers: &BTreeMap, Entity>, + expanded: bool, + pending_edits: bool, + cx: &Context, + ) -> Div { + const EDIT_NOT_READY_TOOLTIP_LABEL: &str = "Wait until file edits are complete."; + + let focus_handle = self.focus_handle(cx); + + h_flex() + .p_1() + .justify_between() + .flex_wrap() + .when(expanded, |this| { + this.border_b_1().border_color(cx.theme().colors().border) + }) + .child( + h_flex() + .id("edits-container") + .cursor_pointer() + .gap_1() + .child(Disclosure::new("edits-disclosure", expanded)) + .map(|this| { + if pending_edits { + this.child( + Label::new(format!( + "Editing {} {}…", + changed_buffers.len(), + if changed_buffers.len() == 1 { + "file" + } else { + "files" + } + )) + .color(Color::Muted) + .size(LabelSize::Small) + .with_animation( + "edit-label", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.3, 0.7)), + |label, delta| label.alpha(delta), + ), + ) + } else { + let stats = DiffStats::all_files(changed_buffers, cx); + let dot_divider = || { + Label::new("β€’") + .size(LabelSize::XSmall) + .color(Color::Disabled) + }; + + this.child( + Label::new("Edits") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child(dot_divider()) + .child( + Label::new(format!( + "{} {}", + changed_buffers.len(), + if changed_buffers.len() == 1 { + "file" + } else { + "files" + } + )) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child(dot_divider()) + .child(DiffStat::new( + "total", + stats.lines_added as usize, + stats.lines_removed as usize, + )) + } + }) + .on_click(cx.listener(|this, _, _, cx| { + this.edits_expanded = !this.edits_expanded; + cx.notify(); + })), + ) + .child( + h_flex() + .gap_1() + .child( + IconButton::new("review-changes", IconName::ListTodo) + .icon_size(IconSize::Small) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |_window, cx| { + Tooltip::for_action_in( + "Review Changes", + &OpenAgentDiff, + &focus_handle, + cx, + ) + } + }) + .on_click(cx.listener(|_, _, window, cx| { + window.dispatch_action(OpenAgentDiff.boxed_clone(), cx); + })), + ) + .child(Divider::vertical().color(DividerColor::Border)) + .child( + Button::new("reject-all-changes", "Reject All") + .label_size(LabelSize::Small) + .disabled(pending_edits) + .when(pending_edits, |this| { + this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL)) + }) + .key_binding( + KeyBinding::for_action_in(&RejectAll, &focus_handle.clone(), cx) + .map(|kb| kb.size(rems_from_px(10.))), + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.reject_all(&RejectAll, window, cx); + })), + ) + .child( + Button::new("keep-all-changes", "Keep All") + .label_size(LabelSize::Small) + .disabled(pending_edits) + .when(pending_edits, |this| { + this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL)) + }) + .key_binding( + KeyBinding::for_action_in(&KeepAll, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(10.))), + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.keep_all(&KeepAll, window, cx); + })), + ), + ) + } + + pub(crate) fn render_message_editor( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let focus_handle = self.message_editor.focus_handle(cx); + let editor_bg_color = cx.theme().colors().editor_background; + let editor_expanded = self.editor_expanded; + let (expand_icon, expand_tooltip) = if editor_expanded { + (IconName::Minimize, "Minimize Message Editor") + } else { + (IconName::Maximize, "Expand Message Editor") + }; + + v_flex() + .on_action(cx.listener(Self::expand_message_editor)) + .p_2() + .gap_2() + .border_t_1() + .border_color(cx.theme().colors().border) + .bg(editor_bg_color) + .when(editor_expanded, |this| { + this.h(vh(0.8, window)).size_full().justify_between() + }) + .child( + v_flex() + .relative() + .size_full() + .pt_1() + .pr_2p5() + .child(self.message_editor.clone()) + .child( + h_flex() + .absolute() + .top_0() + .right_0() + .opacity(0.5) + .hover(|this| this.opacity(1.0)) + .child( + IconButton::new("toggle-height", expand_icon) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip({ + move |_window, cx| { + Tooltip::for_action_in( + expand_tooltip, + &ExpandMessageEditor, + &focus_handle, + cx, + ) + } + }) + .on_click(cx.listener(|this, _, window, cx| { + this.expand_message_editor( + &ExpandMessageEditor, + window, + cx, + ); + })), + ), + ), + ) + .child( + h_flex() + .flex_none() + .flex_wrap() + .justify_between() + .child( + h_flex() + .gap_0p5() + .child(self.render_add_context_button(cx)) + .child(self.render_follow_toggle(cx)) + .children(self.render_thinking_toggle(cx)), + ) + .child( + h_flex() + .gap_1() + .children(self.render_token_usage(cx)) + .children(self.profile_selector.clone()) + .map(|this| { + // Either config_options_view OR (mode_selector + model_selector) + match self.config_options_view.clone() { + Some(config_view) => this.child(config_view), + None => this + .children(self.mode_selector.clone()) + .children(self.model_selector.clone()), + } + }) + .child(self.render_send_button(cx)), + ), + ) + .into_any() + } + + fn render_message_queue_entries( + &self, + _window: &mut Window, + cx: &Context, + ) -> impl IntoElement { + let message_editor = self.message_editor.read(cx); + let focus_handle = message_editor.focus_handle(cx); + + let queued_message_editors = &self.queued_message_editors; + let queue_len = queued_message_editors.len(); + let can_fast_track = self.can_fast_track_queue && queue_len > 0; + + v_flex() + .id("message_queue_list") + .max_h_40() + .overflow_y_scroll() + .children( + queued_message_editors + .iter() + .enumerate() + .map(|(index, editor)| { + let is_next = index == 0; + let (icon_color, tooltip_text) = if is_next { + (Color::Accent, "Next in Queue") + } else { + (Color::Muted, "In Queue") + }; + + let editor_focused = editor.focus_handle(cx).is_focused(_window); + let keybinding_size = rems_from_px(12.); + + h_flex() + .group("queue_entry") + .w_full() + .p_1p5() + .gap_1() + .bg(cx.theme().colors().editor_background) + .when(index < queue_len - 1, |this| { + this.border_b_1() + .border_color(cx.theme().colors().border_variant) + }) + .child( + div() + .id("next_in_queue") + .child( + Icon::new(IconName::Circle) + .size(IconSize::Small) + .color(icon_color), + ) + .tooltip(Tooltip::text(tooltip_text)), + ) + .child(editor.clone()) + .child(if editor_focused { + h_flex() + .gap_1() + .min_w_40() + .child( + IconButton::new(("cancel_edit", index), IconName::Close) + .icon_size(IconSize::Small) + .icon_color(Color::Error) + .tooltip({ + let focus_handle = editor.focus_handle(cx); + move |_window, cx| { + Tooltip::for_action_in( + "Cancel Edit", + &editor::actions::Cancel, + &focus_handle, + cx, + ) + } + }) + .on_click({ + let main_editor = self.message_editor.clone(); + cx.listener(move |_, _, window, cx| { + window.focus(&main_editor.focus_handle(cx), cx); + }) + }), + ) + .child( + IconButton::new(("save_edit", index), IconName::Check) + .icon_size(IconSize::Small) + .icon_color(Color::Success) + .tooltip({ + let focus_handle = editor.focus_handle(cx); + move |_window, cx| { + Tooltip::for_action_in( + "Save Edit", + &Chat, + &focus_handle, + cx, + ) + } + }) + .on_click({ + let main_editor = self.message_editor.clone(); + cx.listener(move |_, _, window, cx| { + window.focus(&main_editor.focus_handle(cx), cx); + }) + }), + ) + .child( + Button::new(("send_now_focused", index), "Send Now") + .label_size(LabelSize::Small) + .style(ButtonStyle::Outlined) + .key_binding( + KeyBinding::for_action_in( + &SendImmediately, + &editor.focus_handle(cx), + cx, + ) + .map(|kb| kb.size(keybinding_size)), + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.send_queued_message_at_index( + index, true, window, cx, + ); + })), + ) + } else { + h_flex() + .gap_1() + .when(!is_next, |this| this.visible_on_hover("queue_entry")) + .child( + IconButton::new(("edit", index), IconName::Pencil) + .icon_size(IconSize::Small) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |_window, cx| { + if is_next { + Tooltip::for_action_in( + "Edit", + &EditFirstQueuedMessage, + &focus_handle, + cx, + ) + } else { + Tooltip::simple("Edit", cx) + } + } + }) + .on_click({ + let editor = editor.clone(); + cx.listener(move |_, _, window, cx| { + window.focus(&editor.focus_handle(cx), cx); + }) + }), + ) + .child( + IconButton::new(("delete", index), IconName::Trash) + .icon_size(IconSize::Small) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |_window, cx| { + if is_next { + Tooltip::for_action_in( + "Remove Message from Queue", + &RemoveFirstQueuedMessage, + &focus_handle, + cx, + ) + } else { + Tooltip::simple( + "Remove Message from Queue", + cx, + ) + } + } + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.remove_from_queue(index, cx); + cx.notify(); + })), + ) + .child( + Button::new(("send_now", index), "Send Now") + .label_size(LabelSize::Small) + .when(is_next && message_editor.is_empty(cx), |this| { + let action: Box = + if can_fast_track { + Box::new(Chat) + } else { + Box::new(SendNextQueuedMessage) + }; + + this.style(ButtonStyle::Outlined).key_binding( + KeyBinding::for_action_in( + action.as_ref(), + &focus_handle.clone(), + cx, + ) + .map(|kb| kb.size(keybinding_size)), + ) + }) + .when(is_next && !message_editor.is_empty(cx), |this| { + this.style(ButtonStyle::Outlined) + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.send_queued_message_at_index( + index, true, window, cx, + ); + })), + ) + }) + }), + ) + .into_any_element() + } + + fn supports_split_token_display(&self, cx: &App) -> bool { + self.as_native_thread(cx) + .and_then(|thread| thread.read(cx).model()) + .is_some_and(|model| model.supports_split_token_display()) + } + + fn render_token_usage(&self, cx: &mut Context) -> Option
{ + let thread = self.thread.read(cx); + let usage = thread.token_usage()?; + let is_generating = thread.status() != ThreadStatus::Idle; + let show_split = self.supports_split_token_display(cx); + + let separator_color = Color::Custom(cx.theme().colors().text_muted.opacity(0.5)); + let token_label = |text: String, animation_id: &'static str| { + Label::new(text) + .size(LabelSize::Small) + .color(Color::Muted) + .map(|label| { + if is_generating { + label + .with_animation( + animation_id, + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.3, 0.8)), + |label, delta| label.alpha(delta), + ) + .into_any() + } else { + label.into_any_element() + } + }) + }; + + if show_split { + let max_output_tokens = self + .as_native_thread(cx) + .and_then(|thread| thread.read(cx).model()) + .and_then(|model| model.max_output_tokens()) + .unwrap_or(0); + + let input = crate::text_thread_editor::humanize_token_count(usage.input_tokens); + let input_max = crate::text_thread_editor::humanize_token_count( + usage.max_tokens.saturating_sub(max_output_tokens), + ); + let output = crate::text_thread_editor::humanize_token_count(usage.output_tokens); + let output_max = crate::text_thread_editor::humanize_token_count(max_output_tokens); + + Some( + h_flex() + .flex_shrink_0() + .gap_1() + .mr_1p5() + .child( + h_flex() + .gap_0p5() + .child( + Icon::new(IconName::ArrowUp) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child(token_label(input, "input-tokens-label")) + .child( + Label::new("/") + .size(LabelSize::Small) + .color(separator_color), + ) + .child( + Label::new(input_max) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child( + h_flex() + .gap_0p5() + .child( + Icon::new(IconName::ArrowDown) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child(token_label(output, "output-tokens-label")) + .child( + Label::new("/") + .size(LabelSize::Small) + .color(separator_color), + ) + .child( + Label::new(output_max) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ), + ) + } else { + let used = crate::text_thread_editor::humanize_token_count(usage.used_tokens); + let max = crate::text_thread_editor::humanize_token_count(usage.max_tokens); + + Some( + h_flex() + .flex_shrink_0() + .gap_0p5() + .mr_1p5() + .child(token_label(used, "used-tokens-label")) + .child( + Label::new("/") + .size(LabelSize::Small) + .color(separator_color), + ) + .child(Label::new(max).size(LabelSize::Small).color(Color::Muted)), + ) + } + } + + fn render_thinking_toggle(&self, cx: &mut Context) -> Option { + if !cx.has_flag::() { + return None; + } + + let thread = self.as_native_thread(cx)?.read(cx); + + let supports_thinking = thread.model()?.supports_thinking(); + if !supports_thinking { + return None; + } + + let thinking = thread.thinking_enabled(); + + let (tooltip_label, icon) = if thinking { + ("Disable Thinking Mode", IconName::ThinkingMode) + } else { + ("Enable Thinking Mode", IconName::ToolThink) + }; + + let focus_handle = self.message_editor.focus_handle(cx); + + Some( + IconButton::new("thinking-mode", icon) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .toggle_state(thinking) + .tooltip(move |_, cx| { + Tooltip::for_action_in(tooltip_label, &ToggleThinkingMode, &focus_handle, cx) + }) + .on_click(cx.listener(move |this, _, _window, cx| { + if let Some(thread) = this.as_native_thread(cx) { + thread.update(cx, |thread, cx| { + thread.set_thinking_enabled(!thread.thinking_enabled(), cx); + }); + } + })), + ) + } + + fn render_send_button(&self, cx: &mut Context) -> AnyElement { + let message_editor = self.message_editor.read(cx); + let is_editor_empty = message_editor.is_empty(cx); + let focus_handle = message_editor.focus_handle(cx); + + let is_generating = self.thread.read(cx).status() != ThreadStatus::Idle; + + if self.is_loading_contents { + div() + .id("loading-message-content") + .px_1() + .tooltip(Tooltip::text("Loading Added Context…")) + .child(loading_contents_spinner(IconSize::default())) + .into_any_element() + } else if is_generating && is_editor_empty { + IconButton::new("stop-generation", IconName::Stop) + .icon_color(Color::Error) + .style(ButtonStyle::Tinted(TintColor::Error)) + .tooltip(move |_window, cx| { + Tooltip::for_action("Stop Generation", &editor::actions::Cancel, cx) + }) + .on_click(cx.listener(|this, _event, _, cx| this.cancel_generation(cx))) + .into_any_element() + } else { + IconButton::new("send-message", IconName::Send) + .style(ButtonStyle::Filled) + .map(|this| { + if is_editor_empty && !is_generating { + this.disabled(true).icon_color(Color::Muted) + } else { + this.icon_color(Color::Accent) + } + }) + .tooltip(move |_window, cx| { + if is_editor_empty && !is_generating { + Tooltip::for_action("Type to Send", &Chat, cx) + } else if is_generating { + let focus_handle = focus_handle.clone(); + + Tooltip::element(move |_window, cx| { + v_flex() + .gap_1() + .child( + h_flex() + .gap_2() + .justify_between() + .child(Label::new("Queue and Send")) + .child(KeyBinding::for_action_in(&Chat, &focus_handle, cx)), + ) + .child( + h_flex() + .pt_1() + .gap_2() + .justify_between() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(Label::new("Send Immediately")) + .child(KeyBinding::for_action_in( + &SendImmediately, + &focus_handle, + cx, + )), + ) + .into_any_element() + })(_window, cx) + } else { + Tooltip::for_action("Send Message", &Chat, cx) + } + }) + .on_click(cx.listener(|this, _, window, cx| { + this.send(window, cx); + })) + .into_any_element() + } + } + + fn render_add_context_button(&mut self, cx: &mut Context) -> impl IntoElement { + let focus_handle = self.message_editor.focus_handle(cx); + let weak_self = cx.weak_entity(); + + PopoverMenu::new("add-context-menu") + .trigger_with_tooltip( + IconButton::new("add-context", IconName::Plus) + .icon_size(IconSize::Small) + .icon_color(Color::Muted), + { + move |_window, cx| { + Tooltip::for_action_in( + "Add Context", + &OpenAddContextMenu, + &focus_handle, + cx, + ) + } + }, + ) + .anchor(gpui::Corner::BottomLeft) + .with_handle(self.add_context_menu_handle.clone()) + .offset(gpui::Point { + x: px(0.0), + y: px(-2.0), + }) + .menu(move |window, cx| { + weak_self + .update(cx, |this, cx| this.build_add_context_menu(window, cx)) + .ok() + }) + } + + fn build_add_context_menu( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + let message_editor = self.message_editor.clone(); + let workspace = self.workspace.clone(); + let supports_images = self.prompt_capabilities.borrow().image; + + let has_editor_selection = workspace + .upgrade() + .and_then(|ws| { + ws.read(cx) + .active_item(cx) + .and_then(|item| item.downcast::()) + }) + .is_some_and(|editor| { + editor.update(cx, |editor, cx| { + editor.has_non_empty_selection(&editor.display_snapshot(cx)) + }) + }); + + let has_terminal_selection = workspace + .upgrade() + .and_then(|ws| ws.read(cx).panel::(cx)) + .is_some_and(|panel| !panel.read(cx).terminal_selections(cx).is_empty()); + + let has_selection = has_editor_selection || has_terminal_selection; + + ContextMenu::build(window, cx, move |menu, _window, _cx| { + menu.key_context("AddContextMenu") + .header("Context") + .item( + ContextMenuEntry::new("Files & Directories") + .icon(IconName::File) + .icon_color(Color::Muted) + .icon_size(IconSize::XSmall) + .handler({ + let message_editor = message_editor.clone(); + move |window, cx| { + message_editor.focus_handle(cx).focus(window, cx); + message_editor.update(cx, |editor, cx| { + editor.insert_context_type("file", window, cx); + }); + } + }), + ) + .item( + ContextMenuEntry::new("Symbols") + .icon(IconName::Code) + .icon_color(Color::Muted) + .icon_size(IconSize::XSmall) + .handler({ + let message_editor = message_editor.clone(); + move |window, cx| { + message_editor.focus_handle(cx).focus(window, cx); + message_editor.update(cx, |editor, cx| { + editor.insert_context_type("symbol", window, cx); + }); + } + }), + ) + .item( + ContextMenuEntry::new("Threads") + .icon(IconName::Thread) + .icon_color(Color::Muted) + .icon_size(IconSize::XSmall) + .handler({ + let message_editor = message_editor.clone(); + move |window, cx| { + message_editor.focus_handle(cx).focus(window, cx); + message_editor.update(cx, |editor, cx| { + editor.insert_context_type("thread", window, cx); + }); + } + }), + ) + .item( + ContextMenuEntry::new("Rules") + .icon(IconName::Reader) + .icon_color(Color::Muted) + .icon_size(IconSize::XSmall) + .handler({ + let message_editor = message_editor.clone(); + move |window, cx| { + message_editor.focus_handle(cx).focus(window, cx); + message_editor.update(cx, |editor, cx| { + editor.insert_context_type("rule", window, cx); + }); + } + }), + ) + .item( + ContextMenuEntry::new("Image") + .icon(IconName::Image) + .icon_color(Color::Muted) + .icon_size(IconSize::XSmall) + .disabled(!supports_images) + .handler({ + let message_editor = message_editor.clone(); + move |window, cx| { + message_editor.focus_handle(cx).focus(window, cx); + message_editor.update(cx, |editor, cx| { + editor.add_images_from_picker(window, cx); + }); + } + }), + ) + .item( + ContextMenuEntry::new("Selection") + .icon(IconName::CursorIBeam) + .icon_color(Color::Muted) + .icon_size(IconSize::XSmall) + .disabled(!has_selection) + .handler({ + move |window, cx| { + window.dispatch_action( + zed_actions::agent::AddSelectionToThread.boxed_clone(), + cx, + ); + } + }), + ) + }) + } + + fn render_follow_toggle(&self, cx: &mut Context) -> impl IntoElement { + let following = self.is_following(cx); + + let tooltip_label = if following { + if self.agent_name == "Zed Agent" { + format!("Stop Following the {}", self.agent_name) + } else { + format!("Stop Following {}", self.agent_name) + } + } else { + if self.agent_name == "Zed Agent" { + format!("Follow the {}", self.agent_name) + } else { + format!("Follow {}", self.agent_name) + } + }; + + IconButton::new("follow-agent", IconName::Crosshair) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .toggle_state(following) + .selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor))) + .tooltip(move |_window, cx| { + if following { + Tooltip::for_action(tooltip_label.clone(), &Follow, cx) + } else { + Tooltip::with_meta( + tooltip_label.clone(), + Some(&Follow), + "Track the agent's location as it reads and edits files.", + cx, + ) + } + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.toggle_following(window, cx); + })) + } +} + +impl AcpThreadView { + pub(crate) fn render_entries(&mut self, cx: &mut Context) -> List { + list( + self.list_state.clone(), + cx.processor(|this, index: usize, window, cx| { + let entries = this.thread.read(cx).entries(); + let Some(entry) = entries.get(index) else { + return Empty.into_any(); + }; + this.render_entry(index, entries.len(), entry, window, cx) + }), + ) + .with_sizing_behavior(gpui::ListSizingBehavior::Auto) + .flex_grow() + } + + fn render_entry( + &self, + entry_ix: usize, + total_entries: usize, + entry: &AgentThreadEntry, + window: &mut Window, + cx: &Context, + ) -> AnyElement { + let is_indented = entry.is_indented(); + let is_first_indented = is_indented + && self + .thread + .read(cx) + .entries() + .get(entry_ix.saturating_sub(1)) + .is_none_or(|entry| !entry.is_indented()); + + let primary = match &entry { + AgentThreadEntry::UserMessage(message) => { + let Some(editor) = self + .entry_view_state + .read(cx) + .entry(entry_ix) + .and_then(|entry| entry.message_editor()) + .cloned() + else { + return Empty.into_any_element(); + }; + + let editing = self.editing_message == Some(entry_ix); + let editor_focus = editor.focus_handle(cx).is_focused(window); + let focus_border = cx.theme().colors().border_focused; + + let rules_item = if entry_ix == 0 { + self.render_rules_item(cx) + } else { + None + }; + + let has_checkpoint_button = message + .checkpoint + .as_ref() + .is_some_and(|checkpoint| checkpoint.show); + + let agent_name = self.agent_name.clone(); + + v_flex() + .id(("user_message", entry_ix)) + .map(|this| { + if is_first_indented { + this.pt_0p5() + } else if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() { + this.pt(rems_from_px(18.)) + } else if rules_item.is_some() { + this.pt_3() + } else { + this.pt_2() + } + }) + .pb_3() + .px_2() + .gap_1p5() + .w_full() + .children(rules_item) + .children(message.id.clone().and_then(|message_id| { + message.checkpoint.as_ref()?.show.then(|| { + h_flex() + .px_3() + .gap_2() + .child(Divider::horizontal()) + .child( + Button::new("restore-checkpoint", "Restore Checkpoint") + .icon(IconName::Undo) + .icon_size(IconSize::XSmall) + .icon_position(IconPosition::Start) + .label_size(LabelSize::XSmall) + .icon_color(Color::Muted) + .color(Color::Muted) + .tooltip(Tooltip::text("Restores all files in the project to the content they had at this point in the conversation.")) + .on_click(cx.listener(move |this, _, _window, cx| { + this.restore_checkpoint(&message_id, cx); + })) + ) + .child(Divider::horizontal()) + }) + })) + .child( + div() + .relative() + .child( + div() + .py_3() + .px_2() + .rounded_md() + .shadow_md() + .bg(cx.theme().colors().editor_background) + .border_1() + .when(is_indented, |this| { + this.py_2().px_2().shadow_sm() + }) + .when(editing && !editor_focus, |this| this.border_dashed()) + .border_color(cx.theme().colors().border) + .map(|this|{ + if editing && editor_focus { + this.border_color(focus_border) + } else if message.id.is_some() { + this.hover(|s| s.border_color(focus_border.opacity(0.8))) + } else { + this + } + }) + .text_xs() + .child(editor.clone().into_any_element()) + ) + .when(editor_focus, |this| { + let base_container = h_flex() + .absolute() + .top_neg_3p5() + .right_3() + .gap_1() + .rounded_sm() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().editor_background) + .overflow_hidden(); + + let is_loading_contents = self.is_loading_contents; + if message.id.is_some() { + this.child( + base_container + .child( + IconButton::new("cancel", IconName::Close) + .disabled(is_loading_contents) + .icon_color(Color::Error) + .icon_size(IconSize::XSmall) + .on_click(cx.listener(Self::cancel_editing)) + ) + .child( + if is_loading_contents { + div() + .id("loading-edited-message-content") + .tooltip(Tooltip::text("Loading Added Context…")) + .child(loading_contents_spinner(IconSize::XSmall)) + .into_any_element() + } else { + IconButton::new("regenerate", IconName::Return) + .icon_color(Color::Muted) + .icon_size(IconSize::XSmall) + .tooltip(Tooltip::text( + "Editing will restart the thread from this point." + )) + .on_click(cx.listener({ + let editor = editor.clone(); + move |this, _, window, cx| { + this.regenerate( + entry_ix, editor.clone(), window, cx, + ); + } + })).into_any_element() + } + ) + ) + } else { + this.child( + base_container + .border_dashed() + .child( + IconButton::new("editing_unavailable", IconName::PencilUnavailable) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .style(ButtonStyle::Transparent) + .tooltip(Tooltip::element({ + move |_, _| { + v_flex() + .gap_1() + .child(Label::new("Unavailable Editing")).child( + div().max_w_64().child( + Label::new(format!( + "Editing previous messages is not available for {} yet.", + agent_name.clone() + )) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .into_any_element() + } + })) + ) + ) + } + }), + ) + .into_any() + } + AgentThreadEntry::AssistantMessage(AssistantMessage { + chunks, + indented: _, + }) => { + let mut is_blank = true; + let is_last = entry_ix + 1 == total_entries; + + let style = MarkdownStyle::themed(MarkdownFont::Agent, window, cx); + let message_body = v_flex() + .w_full() + .gap_3() + .children(chunks.iter().enumerate().filter_map( + |(chunk_ix, chunk)| match chunk { + AssistantMessageChunk::Message { block } => { + block.markdown().and_then(|md| { + let this_is_blank = md.read(cx).source().trim().is_empty(); + is_blank = is_blank && this_is_blank; + if this_is_blank { + return None; + } + + Some( + self.render_markdown(md.clone(), style.clone()) + .into_any_element(), + ) + }) + } + AssistantMessageChunk::Thought { block } => { + block.markdown().and_then(|md| { + let this_is_blank = md.read(cx).source().trim().is_empty(); + is_blank = is_blank && this_is_blank; + if this_is_blank { + return None; + } + Some( + self.render_thinking_block( + entry_ix, + chunk_ix, + md.clone(), + window, + cx, + ) + .into_any_element(), + ) + }) + } + }, + )) + .into_any(); + + if is_blank { + Empty.into_any() + } else { + v_flex() + .px_5() + .py_1p5() + .when(is_last, |this| this.pb_4()) + .w_full() + .text_ui(cx) + .child(self.render_message_context_menu(entry_ix, message_body, cx)) + .into_any() + } + } + AgentThreadEntry::ToolCall(tool_call) => { + let has_terminals = tool_call.terminals().next().is_some(); + + div() + .w_full() + .map(|this| { + if has_terminals { + this.children(tool_call.terminals().map(|terminal| { + self.render_terminal_tool_call( + entry_ix, terminal, tool_call, window, cx, + ) + })) + } else { + this.child(self.render_tool_call(entry_ix, tool_call, window, cx)) + } + }) + .into_any() + } + }; + + let primary = if is_indented { + let line_top = if is_first_indented { + rems_from_px(-12.0) + } else { + rems_from_px(0.0) + }; + + div() + .relative() + .w_full() + .pl_5() + .bg(cx.theme().colors().panel_background.opacity(0.2)) + .child( + div() + .absolute() + .left(rems_from_px(18.0)) + .top(line_top) + .bottom_0() + .w_px() + .bg(cx.theme().colors().border.opacity(0.6)), + ) + .child(primary) + .into_any_element() + } else { + primary + }; + + let needs_confirmation = if let AgentThreadEntry::ToolCall(tool_call) = entry { + matches!( + tool_call.status, + ToolCallStatus::WaitingForConfirmation { .. } + ) + } else { + false + }; + + let thread = self.thread.clone(); + let comments_editor = self.thread_feedback.comments_editor.clone(); + + let primary = if entry_ix == total_entries - 1 { + v_flex() + .w_full() + .child(primary) + .map(|this| { + if needs_confirmation { + this.child(self.render_generating(true, cx)) + } else { + this.child(self.render_thread_controls(&thread, cx)) + } + }) + .when_some(comments_editor, |this, editor| { + this.child(Self::render_feedback_feedback_editor(editor, cx)) + }) + .into_any_element() + } else { + primary + }; + + if let Some(editing_index) = self.editing_message + && editing_index < entry_ix + { + let backdrop = div() + .id(("backdrop", entry_ix)) + .size_full() + .absolute() + .inset_0() + .bg(cx.theme().colors().panel_background) + .opacity(0.8) + .block_mouse_except_scroll() + .on_click(cx.listener(Self::cancel_editing)); + + div() + .relative() + .child(primary) + .child(backdrop) + .into_any_element() + } else { + primary + } + } + + fn render_feedback_feedback_editor(editor: Entity, cx: &Context) -> Div { + h_flex() + .key_context("AgentFeedbackMessageEditor") + .on_action(cx.listener(move |this, _: &menu::Cancel, _, cx| { + this.thread_feedback.dismiss_comments(); + cx.notify(); + })) + .on_action(cx.listener(move |this, _: &menu::Confirm, _window, cx| { + this.submit_feedback_message(cx); + })) + .p_2() + .mb_2() + .mx_5() + .gap_1() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().editor_background) + .child(div().w_full().child(editor)) + .child( + h_flex() + .child( + IconButton::new("dismiss-feedback-message", IconName::Close) + .icon_color(Color::Error) + .icon_size(IconSize::XSmall) + .shape(ui::IconButtonShape::Square) + .on_click(cx.listener(move |this, _, _window, cx| { + this.thread_feedback.dismiss_comments(); + cx.notify(); + })), + ) + .child( + IconButton::new("submit-feedback-message", IconName::Return) + .icon_size(IconSize::XSmall) + .shape(ui::IconButtonShape::Square) + .on_click(cx.listener(move |this, _, _window, cx| { + this.submit_feedback_message(cx); + })), + ), + ) + } + + fn render_thread_controls( + &self, + thread: &Entity, + cx: &Context, + ) -> impl IntoElement { + let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); + if is_generating { + return self.render_generating(false, cx).into_any_element(); + } + + let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .icon_color(Color::Ignored) + .tooltip(Tooltip::text("Open Thread as Markdown")) + .on_click(cx.listener(move |this, _, window, cx| { + if let Some(workspace) = this.workspace.upgrade() { + this.open_thread_as_markdown(workspace, window, cx) + .detach_and_log_err(cx); + } + })); + + let scroll_to_recent_user_prompt = + IconButton::new("scroll_to_recent_user_prompt", IconName::ForwardArrow) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .icon_color(Color::Ignored) + .tooltip(Tooltip::text("Scroll To Most Recent User Prompt")) + .on_click(cx.listener(move |this, _, _, cx| { + this.scroll_to_most_recent_user_prompt(cx); + })); + + let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .icon_color(Color::Ignored) + .tooltip(Tooltip::text("Scroll To Top")) + .on_click(cx.listener(move |this, _, _, cx| { + this.scroll_to_top(cx); + })); + + let show_stats = AgentSettings::get_global(cx).show_turn_stats; + let last_turn_clock = show_stats + .then(|| { + self.turn_fields + .last_turn_duration + .filter(|&duration| duration > STOPWATCH_THRESHOLD) + .map(|duration| { + Label::new(duration_alt_display(duration)) + .size(LabelSize::Small) + .color(Color::Muted) + }) + }) + .flatten(); + + let last_turn_tokens_label = last_turn_clock + .is_some() + .then(|| { + self.turn_fields + .last_turn_tokens + .filter(|&tokens| tokens > TOKEN_THRESHOLD) + .map(|tokens| { + Label::new(format!( + "{} tokens", + crate::text_thread_editor::humanize_token_count(tokens) + )) + .size(LabelSize::Small) + .color(Color::Muted) + }) + }) + .flatten(); + + let mut container = h_flex() + .w_full() + .py_2() + .px_5() + .gap_px() + .opacity(0.6) + .hover(|s| s.opacity(1.)) + .justify_end() + .when( + last_turn_tokens_label.is_some() || last_turn_clock.is_some(), + |this| { + this.child( + h_flex() + .gap_1() + .px_1() + .when_some(last_turn_tokens_label, |this, label| this.child(label)) + .when_some(last_turn_clock, |this, label| this.child(label)), + ) + }, + ); + + if AgentSettings::get_global(cx).enable_feedback + && self.thread.read(cx).connection().telemetry().is_some() + { + let feedback = self.thread_feedback.feedback; + + let tooltip_meta = || { + SharedString::new( + "Rating the thread sends all of your current conversation to the Zed team.", + ) + }; + + container = container + .child( + IconButton::new("feedback-thumbs-up", IconName::ThumbsUp) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .icon_color(match feedback { + Some(ThreadFeedback::Positive) => Color::Accent, + _ => Color::Ignored, + }) + .tooltip(move |window, cx| match feedback { + Some(ThreadFeedback::Positive) => { + Tooltip::text("Thanks for your feedback!")(window, cx) + } + _ => { + Tooltip::with_meta("Helpful Response", None, tooltip_meta(), cx) + } + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.handle_feedback_click(ThreadFeedback::Positive, window, cx); + })), + ) + .child( + IconButton::new("feedback-thumbs-down", IconName::ThumbsDown) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .icon_color(match feedback { + Some(ThreadFeedback::Negative) => Color::Accent, + _ => Color::Ignored, + }) + .tooltip(move |window, cx| match feedback { + Some(ThreadFeedback::Negative) => { + Tooltip::text( + "We appreciate your feedback and will use it to improve in the future.", + )(window, cx) + } + _ => { + Tooltip::with_meta( + "Not Helpful Response", + None, + tooltip_meta(), + cx, + ) + } + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.handle_feedback_click(ThreadFeedback::Negative, window, cx); + })), + ); + } + + if let Some(project) = self.project.upgrade() + && let Some(server_view) = self.server_view.upgrade() + && cx.has_flag::() + && project.read(cx).client().status().borrow().is_connected() + { + let button = if self.is_imported_thread(cx) { + IconButton::new("sync-thread", IconName::ArrowCircle) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .icon_color(Color::Ignored) + .tooltip(Tooltip::text("Sync with source thread")) + .on_click(cx.listener(move |this, _, window, cx| { + this.sync_thread(project.clone(), server_view.clone(), window, cx); + })) + } else { + IconButton::new("share-thread", IconName::ArrowUpRight) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .icon_color(Color::Ignored) + .tooltip(Tooltip::text("Share Thread")) + .on_click(cx.listener(move |this, _, window, cx| { + this.share_thread(window, cx); + })) + }; + + container = container.child(button); + } + + container + .child(open_as_markdown) + .child(scroll_to_recent_user_prompt) + .child(scroll_to_top) + .into_any_element() + } + + pub(crate) fn scroll_to_most_recent_user_prompt(&mut self, cx: &mut Context) { + let entries = self.thread.read(cx).entries(); + if entries.is_empty() { + return; + } + + // Find the most recent user message and scroll it to the top of the viewport. + // (Fallback: if no user message exists, scroll to the bottom.) + if let Some(ix) = entries + .iter() + .rposition(|entry| matches!(entry, AgentThreadEntry::UserMessage(_))) + { + self.list_state.scroll_to(ListOffset { + item_ix: ix, + offset_in_item: px(0.0), + }); + cx.notify(); + } else { + self.scroll_to_bottom(cx); + } + } + + pub fn scroll_to_bottom(&mut self, cx: &mut Context) { + let entry_count = self.thread.read(cx).entries().len(); + self.list_state.reset(entry_count); + cx.notify(); + } + + fn handle_feedback_click( + &mut self, + feedback: ThreadFeedback, + window: &mut Window, + cx: &mut Context, + ) { + self.thread_feedback + .submit(self.thread.clone(), feedback, window, cx); + cx.notify(); + } + + fn submit_feedback_message(&mut self, cx: &mut Context) { + let thread = self.thread.clone(); + self.thread_feedback.submit_comments(thread, cx); + cx.notify(); + } + + pub(crate) fn scroll_to_top(&mut self, cx: &mut Context) { + self.list_state.scroll_to(ListOffset::default()); + cx.notify(); + } + + pub fn open_thread_as_markdown( + &self, + workspace: Entity, + window: &mut Window, + cx: &mut App, + ) -> Task> { + let markdown_language_task = workspace + .read(cx) + .app_state() + .languages + .language_for_name("Markdown"); + + let thread = self.thread.read(cx); + let thread_title = thread.title().to_string(); + let markdown = thread.to_markdown(cx); + + let project = workspace.read(cx).project().clone(); + window.spawn(cx, async move |cx| { + let markdown_language = markdown_language_task.await?; + + let buffer = project + .update(cx, |project, cx| { + project.create_buffer(Some(markdown_language), false, cx) + }) + .await?; + + buffer.update(cx, |buffer, cx| { + buffer.set_text(markdown, cx); + buffer.set_capability(language::Capability::ReadWrite, cx); + }); + + workspace.update_in(cx, |workspace, window, cx| { + let buffer = cx + .new(|cx| MultiBuffer::singleton(buffer, cx).with_title(thread_title.clone())); + + workspace.add_item_to_active_pane( + Box::new(cx.new(|cx| { + let mut editor = + Editor::for_multibuffer(buffer, Some(project.clone()), window, cx); + editor.set_breadcrumb_header(thread_title); + editor + })), + None, + true, + window, + cx, + ); + })?; + anyhow::Ok(()) + }) + } + + fn render_generating(&self, confirmation: bool, cx: &App) -> impl IntoElement { + let show_stats = AgentSettings::get_global(cx).show_turn_stats; + let elapsed_label = show_stats + .then(|| { + self.turn_fields.turn_started_at.and_then(|started_at| { + let elapsed = started_at.elapsed(); + (elapsed > STOPWATCH_THRESHOLD).then(|| duration_alt_display(elapsed)) + }) + }) + .flatten(); + + let is_waiting = confirmation || self.thread.read(cx).has_in_progress_tool_calls(); + + let turn_tokens_label = elapsed_label + .is_some() + .then(|| { + self.turn_fields + .turn_tokens + .filter(|&tokens| tokens > TOKEN_THRESHOLD) + .map(|tokens| crate::text_thread_editor::humanize_token_count(tokens)) + }) + .flatten(); + + let arrow_icon = if is_waiting { + IconName::ArrowUp + } else { + IconName::ArrowDown + }; + + h_flex() + .id("generating-spinner") + .py_2() + .px(rems_from_px(22.)) + .gap_2() + .map(|this| { + if confirmation { + this.child( + h_flex() + .w_2() + .child(SpinnerLabel::sand().size(LabelSize::Small)), + ) + .child( + div().min_w(rems(8.)).child( + LoadingLabel::new("Awaiting Confirmation") + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + } else { + this.child(SpinnerLabel::new().size(LabelSize::Small)) + } + }) + .when_some(elapsed_label, |this, elapsed| { + this.child( + Label::new(elapsed) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }) + .when_some(turn_tokens_label, |this, tokens| { + this.child( + h_flex() + .gap_0p5() + .child( + Icon::new(arrow_icon) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child( + Label::new(format!("{} tokens", tokens)) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + }) + .into_any_element() + } + + fn render_thinking_block( + &self, + entry_ix: usize, + chunk_ix: usize, + chunk: Entity, + window: &Window, + cx: &Context, + ) -> AnyElement { + let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix)); + let card_header_id = SharedString::from("inner-card-header"); + + let key = (entry_ix, chunk_ix); + + let is_open = self.expanded_thinking_blocks.contains(&key); + + let scroll_handle = self + .entry_view_state + .read(cx) + .entry(entry_ix) + .and_then(|entry| entry.scroll_handle_for_assistant_message_chunk(chunk_ix)); + + let thinking_content = { + div() + .id(("thinking-content", chunk_ix)) + .when_some(scroll_handle, |this, scroll_handle| { + this.track_scroll(&scroll_handle) + }) + .text_ui_sm(cx) + .overflow_hidden() + .child(self.render_markdown( + chunk, + MarkdownStyle::themed(MarkdownFont::Agent, window, cx), + )) + }; + + v_flex() + .gap_1() + .child( + h_flex() + .id(header_id) + .group(&card_header_id) + .relative() + .w_full() + .pr_1() + .justify_between() + .child( + h_flex() + .h(window.line_height() - px(2.)) + .gap_1p5() + .overflow_hidden() + .child( + Icon::new(IconName::ToolThink) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child( + div() + .text_size(self.tool_name_font_size()) + .text_color(cx.theme().colors().text_muted) + .child("Thinking"), + ), + ) + .child( + Disclosure::new(("expand", entry_ix), is_open) + .opened_icon(IconName::ChevronUp) + .closed_icon(IconName::ChevronDown) + .visible_on_hover(&card_header_id) + .on_click(cx.listener({ + move |this, _event, _window, cx| { + if is_open { + this.expanded_thinking_blocks.remove(&key); + } else { + this.expanded_thinking_blocks.insert(key); + } + cx.notify(); + } + })), + ) + .on_click(cx.listener(move |this, _event, _window, cx| { + if is_open { + this.expanded_thinking_blocks.remove(&key); + } else { + this.expanded_thinking_blocks.insert(key); + } + cx.notify(); + })), + ) + .when(is_open, |this| { + this.child( + div() + .ml_1p5() + .pl_3p5() + .border_l_1() + .border_color(self.tool_card_border_color(cx)) + .child(thinking_content), + ) + }) + .into_any_element() + } + + fn render_message_context_menu( + &self, + entry_ix: usize, + message_body: AnyElement, + cx: &Context, + ) -> AnyElement { + let entity = cx.entity(); + let workspace = self.workspace.clone(); + + right_click_menu(format!("agent_context_menu-{}", entry_ix)) + .trigger(move |_, _, _| message_body) + .menu(move |window, cx| { + let focus = window.focused(cx); + let entity = entity.clone(); + let workspace = workspace.clone(); + + ContextMenu::build(window, cx, move |menu, _, cx| { + let this = entity.read(cx); + let is_at_top = this.list_state.logical_scroll_top().item_ix == 0; + + let has_selection = this + .thread + .read(cx) + .entries() + .get(entry_ix) + .and_then(|entry| match &entry { + AgentThreadEntry::AssistantMessage(msg) => Some(&msg.chunks), + _ => None, + }) + .map(|chunks| { + chunks.iter().any(|chunk| { + let md = match chunk { + AssistantMessageChunk::Message { block } => block.markdown(), + AssistantMessageChunk::Thought { block } => block.markdown(), + }; + md.map_or(false, |m| m.read(cx).selected_text().is_some()) + }) + }) + .unwrap_or(false); + + let copy_this_agent_response = + ContextMenuEntry::new("Copy This Agent Response").handler({ + let entity = entity.clone(); + move |_, cx| { + entity.update(cx, |this, cx| { + let entries = this.thread.read(cx).entries(); + if let Some(text) = + Self::get_agent_message_content(entries, entry_ix, cx) + { + cx.write_to_clipboard(ClipboardItem::new_string(text)); + } + }); + } + }); + + let scroll_item = if is_at_top { + ContextMenuEntry::new("Scroll to Bottom").handler({ + let entity = entity.clone(); + move |_, cx| { + entity.update(cx, |this, cx| { + this.scroll_to_bottom(cx); + }); + } + }) + } else { + ContextMenuEntry::new("Scroll to Top").handler({ + let entity = entity.clone(); + move |_, cx| { + entity.update(cx, |this, cx| { + this.scroll_to_top(cx); + }); + } + }) + }; + + let open_thread_as_markdown = ContextMenuEntry::new("Open Thread as Markdown") + .handler({ + let entity = entity.clone(); + let workspace = workspace.clone(); + move |window, cx| { + if let Some(workspace) = workspace.upgrade() { + entity + .update(cx, |this, cx| { + this.open_thread_as_markdown(workspace, window, cx) + }) + .detach_and_log_err(cx); + } + } + }); + + menu.when_some(focus, |menu, focus| menu.context(focus)) + .action_disabled_when( + !has_selection, + "Copy Selection", + Box::new(markdown::CopyAsMarkdown), + ) + .item(copy_this_agent_response) + .separator() + .item(scroll_item) + .item(open_thread_as_markdown) + }) + }) + .into_any_element() + } + + fn get_agent_message_content( + entries: &[AgentThreadEntry], + entry_index: usize, + cx: &App, + ) -> Option { + let entry = entries.get(entry_index)?; + if matches!(entry, AgentThreadEntry::UserMessage(_)) { + return None; + } + + let start_index = (0..entry_index) + .rev() + .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_)))) + .map(|i| i + 1) + .unwrap_or(0); + + let end_index = (entry_index + 1..entries.len()) + .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_)))) + .map(|i| i - 1) + .unwrap_or(entries.len() - 1); + + let parts: Vec = (start_index..=end_index) + .filter_map(|i| entries.get(i)) + .filter_map(|entry| { + if let AgentThreadEntry::AssistantMessage(message) = entry { + let text: String = message + .chunks + .iter() + .filter_map(|chunk| match chunk { + AssistantMessageChunk::Message { block } => { + let markdown = block.to_markdown(cx); + if markdown.trim().is_empty() { + None + } else { + Some(markdown.to_string()) + } + } + AssistantMessageChunk::Thought { .. } => None, + }) + .collect::>() + .join("\n\n"); + + if text.is_empty() { None } else { Some(text) } + } else { + None + } + }) + .collect(); + + let text = parts.join("\n\n"); + if text.is_empty() { None } else { Some(text) } + } + + fn render_collapsible_command( + &self, + is_preview: bool, + command_source: &str, + tool_call_id: &acp::ToolCallId, + cx: &Context, + ) -> Div { + let command_group = + SharedString::from(format!("collapsible-command-group-{}", tool_call_id)); + + v_flex() + .group(command_group.clone()) + .bg(self.tool_card_header_bg(cx)) + .child( + v_flex() + .p_1p5() + .when(is_preview, |this| { + this.pt_1().child( + // Wrapping this label on a container with 24px height to avoid + // layout shift when it changes from being a preview label + // to the actual path where the command will run in + h_flex().h_6().child( + Label::new("Run Command") + .buffer_font(cx) + .size(LabelSize::XSmall) + .color(Color::Muted), + ), + ) + }) + .children(command_source.lines().map(|line| { + let text: SharedString = if line.is_empty() { + " ".into() + } else { + line.to_string().into() + }; + + Label::new(text).buffer_font(cx).size(LabelSize::Small) + })) + .child( + div().absolute().top_1().right_1().child( + CopyButton::new("copy-command", command_source.to_string()) + .tooltip_label("Copy Command") + .visible_on_hover(command_group), + ), + ), + ) + } + + fn render_terminal_tool_call( + &self, + entry_ix: usize, + terminal: &Entity, + tool_call: &ToolCall, + window: &Window, + cx: &Context, + ) -> AnyElement { + let terminal_data = terminal.read(cx); + let working_dir = terminal_data.working_dir(); + let command = terminal_data.command(); + let started_at = terminal_data.started_at(); + + let tool_failed = matches!( + &tool_call.status, + ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed + ); + + let output = terminal_data.output(); + let command_finished = output.is_some(); + let truncated_output = + output.is_some_and(|output| output.original_content_len > output.content.len()); + let output_line_count = output.map(|output| output.content_line_count).unwrap_or(0); + + let command_failed = command_finished + && output.is_some_and(|o| o.exit_status.is_some_and(|status| !status.success())); + + let time_elapsed = if let Some(output) = output { + output.ended_at.duration_since(started_at) + } else { + started_at.elapsed() + }; + + let header_id = + SharedString::from(format!("terminal-tool-header-{}", terminal.entity_id())); + let header_group = SharedString::from(format!( + "terminal-tool-header-group-{}", + terminal.entity_id() + )); + let header_bg = cx + .theme() + .colors() + .element_background + .blend(cx.theme().colors().editor_foreground.opacity(0.025)); + let border_color = cx.theme().colors().border.opacity(0.6); + + let working_dir = working_dir + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "current directory".to_string()); + + // Since the command's source is wrapped in a markdown code block + // (```\n...\n```), we need to strip that so we're left with only the + // command's content. + let command_source = command.read(cx).source(); + let command_content = command_source + .strip_prefix("```\n") + .and_then(|s| s.strip_suffix("\n```")) + .unwrap_or(&command_source); + + let command_element = + self.render_collapsible_command(false, command_content, &tool_call.id, cx); + + let is_expanded = self.expanded_tool_calls.contains(&tool_call.id); + + let header = h_flex() + .id(header_id) + .px_1p5() + .pt_1() + .flex_none() + .gap_1() + .justify_between() + .rounded_t_md() + .child( + div() + .id(("command-target-path", terminal.entity_id())) + .w_full() + .max_w_full() + .overflow_x_scroll() + .child( + Label::new(working_dir) + .buffer_font(cx) + .size(LabelSize::XSmall) + .color(Color::Muted), + ), + ) + .when(!command_finished, |header| { + header + .gap_1p5() + .child( + Button::new( + SharedString::from(format!("stop-terminal-{}", terminal.entity_id())), + "Stop", + ) + .icon(IconName::Stop) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Error) + .label_size(LabelSize::Small) + .tooltip(move |_window, cx| { + Tooltip::with_meta( + "Stop This Command", + None, + "Also possible by placing your cursor inside the terminal and using regular terminal bindings.", + cx, + ) + }) + .on_click({ + let terminal = terminal.clone(); + cx.listener(move |this, _event, _window, cx| { + terminal.update(cx, |terminal, cx| { + terminal.stop_by_user(cx); + }); + if AgentSettings::get_global(cx).cancel_generation_on_terminal_stop { + this.cancel_generation(cx); + } + }) + }), + ) + .child(Divider::vertical()) + .child( + Icon::new(IconName::ArrowCircle) + .size(IconSize::XSmall) + .color(Color::Info) + .with_rotate_animation(2) + ) + }) + .when(truncated_output, |header| { + let tooltip = if let Some(output) = output { + if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES { + format!("Output exceeded terminal max lines and was \ + truncated, the model received the first {}.", format_file_size(output.content.len() as u64, true)) + } else { + format!( + "Output is {} long, and to avoid unexpected token usage, \ + only {} was sent back to the agent.", + format_file_size(output.original_content_len as u64, true), + format_file_size(output.content.len() as u64, true) + ) + } + } else { + "Output was truncated".to_string() + }; + + header.child( + h_flex() + .id(("terminal-tool-truncated-label", terminal.entity_id())) + .gap_1() + .child( + Icon::new(IconName::Info) + .size(IconSize::XSmall) + .color(Color::Ignored), + ) + .child( + Label::new("Truncated") + .color(Color::Muted) + .size(LabelSize::XSmall), + ) + .tooltip(Tooltip::text(tooltip)), + ) + }) + .when(time_elapsed > Duration::from_secs(10), |header| { + header.child( + Label::new(format!("({})", duration_alt_display(time_elapsed))) + .buffer_font(cx) + .color(Color::Muted) + .size(LabelSize::XSmall), + ) + }) + .when(tool_failed || command_failed, |header| { + header.child( + div() + .id(("terminal-tool-error-code-indicator", terminal.entity_id())) + .child( + Icon::new(IconName::Close) + .size(IconSize::Small) + .color(Color::Error), + ) + .when_some(output.and_then(|o| o.exit_status), |this, status| { + this.tooltip(Tooltip::text(format!( + "Exited with code {}", + status.code().unwrap_or(-1), + ))) + }), + ) + }) + .child( + Disclosure::new( + SharedString::from(format!( + "terminal-tool-disclosure-{}", + terminal.entity_id() + )), + is_expanded, + ) + .opened_icon(IconName::ChevronUp) + .closed_icon(IconName::ChevronDown) + .visible_on_hover(&header_group) + .on_click(cx.listener({ + let id = tool_call.id.clone(); + move |this, _event, _window, cx| { + if is_expanded { + this.expanded_tool_calls.remove(&id); + } else { + this.expanded_tool_calls.insert(id.clone()); + } + cx.notify(); + } + })), + ); + + let terminal_view = self + .entry_view_state + .read(cx) + .entry(entry_ix) + .and_then(|entry| entry.terminal(terminal)); + + v_flex() + .my_1p5() + .mx_5() + .border_1() + .when(tool_failed || command_failed, |card| card.border_dashed()) + .border_color(border_color) + .rounded_md() + .overflow_hidden() + .child( + v_flex() + .group(&header_group) + .bg(header_bg) + .text_xs() + .child(header) + .child(command_element), + ) + .when(is_expanded && terminal_view.is_some(), |this| { + this.child( + div() + .pt_2() + .border_t_1() + .when(tool_failed || command_failed, |card| card.border_dashed()) + .border_color(border_color) + .bg(cx.theme().colors().editor_background) + .rounded_b_md() + .text_ui_sm(cx) + .h_full() + .children(terminal_view.map(|terminal_view| { + let element = if terminal_view + .read(cx) + .content_mode(window, cx) + .is_scrollable() + { + div().h_72().child(terminal_view).into_any_element() + } else { + terminal_view.into_any_element() + }; + + div() + .on_action(cx.listener(|_this, _: &NewTerminal, window, cx| { + window.dispatch_action(NewThread.boxed_clone(), cx); + cx.stop_propagation(); + })) + .child(element) + .into_any_element() + })), + ) + }) + .into_any() + } + + fn render_tool_call( + &self, + entry_ix: usize, + tool_call: &ToolCall, + window: &Window, + cx: &Context, + ) -> Div { + let has_location = tool_call.locations.len() == 1; + let card_header_id = SharedString::from("inner-tool-call-header"); + + let failed_or_canceled = match &tool_call.status { + ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed => true, + _ => false, + }; + + let needs_confirmation = matches!( + tool_call.status, + ToolCallStatus::WaitingForConfirmation { .. } + ); + let is_terminal_tool = matches!(tool_call.kind, acp::ToolKind::Execute); + + let is_edit = + matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some(); + let is_subagent = tool_call.is_subagent(); + + // For subagent tool calls, render the subagent cards directly without wrapper + if is_subagent { + return self.render_subagent_tool_call(entry_ix, tool_call, window, cx); + } + + let is_cancelled_edit = is_edit && matches!(tool_call.status, ToolCallStatus::Canceled); + let has_revealed_diff = tool_call.diffs().next().is_some_and(|diff| { + self.entry_view_state + .read(cx) + .entry(entry_ix) + .and_then(|entry| entry.editor_for_diff(diff)) + .is_some() + && diff.read(cx).has_revealed_range(cx) + }); + + let use_card_layout = needs_confirmation || is_edit || is_terminal_tool; + + let has_image_content = tool_call.content.iter().any(|c| c.image().is_some()); + let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation; + let mut is_open = self.expanded_tool_calls.contains(&tool_call.id); + + is_open |= needs_confirmation; + + let should_show_raw_input = !is_terminal_tool && !is_edit && !has_image_content; + + let input_output_header = |label: SharedString| { + Label::new(label) + .size(LabelSize::XSmall) + .color(Color::Muted) + .buffer_font(cx) + }; + + let tool_output_display = if is_open { + match &tool_call.status { + ToolCallStatus::WaitingForConfirmation { options, .. } => v_flex() + .w_full() + .children( + tool_call + .content + .iter() + .enumerate() + .map(|(content_ix, content)| { + div() + .child(self.render_tool_call_content( + entry_ix, + content, + content_ix, + tool_call, + use_card_layout, + has_image_content, + failed_or_canceled, + window, + cx, + )) + .into_any_element() + }), + ) + .when(should_show_raw_input, |this| { + let is_raw_input_expanded = + self.expanded_tool_call_raw_inputs.contains(&tool_call.id); + + let input_header = if is_raw_input_expanded { + "Raw Input:" + } else { + "View Raw Input" + }; + + this.child( + v_flex() + .p_2() + .gap_1() + .border_t_1() + .border_color(self.tool_card_border_color(cx)) + .child( + h_flex() + .id("disclosure_container") + .pl_0p5() + .gap_1() + .justify_between() + .rounded_xs() + .hover(|s| s.bg(cx.theme().colors().element_hover)) + .child(input_output_header(input_header.into())) + .child( + Disclosure::new( + ("raw-input-disclosure", entry_ix), + is_raw_input_expanded, + ) + .opened_icon(IconName::ChevronUp) + .closed_icon(IconName::ChevronDown), + ) + .on_click(cx.listener({ + let id = tool_call.id.clone(); + + move |this: &mut Self, _, _, cx| { + if this.expanded_tool_call_raw_inputs.contains(&id) + { + this.expanded_tool_call_raw_inputs.remove(&id); + } else { + this.expanded_tool_call_raw_inputs + .insert(id.clone()); + } + cx.notify(); + } + })), + ) + .when(is_raw_input_expanded, |this| { + this.children(tool_call.raw_input_markdown.clone().map( + |input| { + self.render_markdown( + input, + MarkdownStyle::themed( + MarkdownFont::Agent, + window, + cx, + ), + ) + }, + )) + }), + ) + }) + .child(self.render_permission_buttons( + options, + entry_ix, + tool_call.id.clone(), + cx, + )) + .into_any(), + ToolCallStatus::Pending | ToolCallStatus::InProgress + if is_edit + && tool_call.content.is_empty() + && self.as_native_connection(cx).is_some() => + { + self.render_diff_loading(cx) + } + ToolCallStatus::Pending + | ToolCallStatus::InProgress + | ToolCallStatus::Completed + | ToolCallStatus::Failed + | ToolCallStatus::Canceled => v_flex() + .when(should_show_raw_input, |this| { + this.mt_1p5().w_full().child( + v_flex() + .ml(rems(0.4)) + .px_3p5() + .pb_1() + .gap_1() + .border_l_1() + .border_color(self.tool_card_border_color(cx)) + .child(input_output_header("Raw Input:".into())) + .children(tool_call.raw_input_markdown.clone().map(|input| { + div().id(("tool-call-raw-input-markdown", entry_ix)).child( + self.render_markdown( + input, + MarkdownStyle::themed(MarkdownFont::Agent, window, cx), + ), + ) + })) + .child(input_output_header("Output:".into())), + ) + }) + .children( + tool_call + .content + .iter() + .enumerate() + .map(|(content_ix, content)| { + div().id(("tool-call-output", entry_ix)).child( + self.render_tool_call_content( + entry_ix, + content, + content_ix, + tool_call, + use_card_layout, + has_image_content, + failed_or_canceled, + window, + cx, + ), + ) + }), + ) + .into_any(), + ToolCallStatus::Rejected => Empty.into_any(), + } + .into() + } else { + None + }; + + v_flex() + .map(|this| { + if use_card_layout { + this.my_1p5() + .rounded_md() + .border_1() + .when(failed_or_canceled, |this| this.border_dashed()) + .border_color(self.tool_card_border_color(cx)) + .bg(cx.theme().colors().editor_background) + .overflow_hidden() + } else { + this.my_1() + } + }) + .map(|this| { + if has_location && !use_card_layout { + this.ml_4() + } else { + this.ml_5() + } + }) + .mr_5() + .map(|this| { + if is_terminal_tool { + let label_source = tool_call.label.read(cx).source(); + this.child(self.render_collapsible_command(true, label_source, &tool_call.id, cx)) + } else { + this.child( + h_flex() + .group(&card_header_id) + .relative() + .w_full() + .gap_1() + .justify_between() + .when(use_card_layout, |this| { + this.p_0p5() + .rounded_t(rems_from_px(5.)) + .bg(self.tool_card_header_bg(cx)) + }) + .child(self.render_tool_call_label( + entry_ix, + tool_call, + is_edit, + is_cancelled_edit, + has_revealed_diff, + use_card_layout, + window, + cx, + )) + .when(is_collapsible || failed_or_canceled, |this| { + let diff_for_discard = + if has_revealed_diff && is_cancelled_edit && cx.has_flag::() { + tool_call.diffs().next().cloned() + } else { + None + }; + this.child( + h_flex() + .px_1() + .when_some(diff_for_discard.clone(), |this, _| this.pr_0p5()) + .gap_1() + .when(is_collapsible, |this| { + this.child( + Disclosure::new(("expand-output", entry_ix), is_open) + .opened_icon(IconName::ChevronUp) + .closed_icon(IconName::ChevronDown) + .visible_on_hover(&card_header_id) + .on_click(cx.listener({ + let id = tool_call.id.clone(); + move |this: &mut Self, _, _, cx: &mut Context| { + if is_open { + this + .expanded_tool_calls.remove(&id); + } else { + this.expanded_tool_calls.insert(id.clone()); + } + cx.notify(); + } + })), + ) + }) + .when(failed_or_canceled, |this| { + if is_cancelled_edit && !has_revealed_diff { + this.child( + div() + .id(entry_ix) + .tooltip(Tooltip::text( + "Interrupted Edit", + )) + .child( + Icon::new(IconName::XCircle) + .color(Color::Muted) + .size(IconSize::Small), + ), + ) + } else if is_cancelled_edit { + this + } else { + this.child( + Icon::new(IconName::Close) + .color(Color::Error) + .size(IconSize::Small), + ) + } + }) + .when_some(diff_for_discard, |this, diff| { + let tool_call_id = tool_call.id.clone(); + let is_discarded = self.discarded_partial_edits.contains(&tool_call_id); + this.when(!is_discarded, |this| { + this.child( + IconButton::new( + ("discard-partial-edit", entry_ix), + IconName::Undo, + ) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| Tooltip::with_meta( + "Discard Interrupted Edit", + None, + "You can discard this interrupted partial edit and restore the original file content.", + cx + )) + .on_click(cx.listener({ + let tool_call_id = tool_call_id.clone(); + move |this, _, _window, cx| { + let diff_data = diff.read(cx); + let base_text = diff_data.base_text().clone(); + let buffer = diff_data.buffer().clone(); + buffer.update(cx, |buffer, cx| { + buffer.set_text(base_text.as_ref(), cx); + }); + this.discarded_partial_edits.insert(tool_call_id.clone()); + cx.notify(); + } + })), + ) + }) + }) + + ) + }), + ) + } + }) + .children(tool_output_display) + } + + fn render_permission_buttons( + &self, + options: &PermissionOptions, + entry_ix: usize, + tool_call_id: acp::ToolCallId, + cx: &Context, + ) -> Div { + match options { + PermissionOptions::Flat(options) => { + self.render_permission_buttons_flat(options, entry_ix, tool_call_id, cx) + } + PermissionOptions::Dropdown(options) => { + self.render_permission_buttons_dropdown(options, entry_ix, tool_call_id, cx) + } + } + } + + fn render_permission_buttons_dropdown( + &self, + choices: &[PermissionOptionChoice], + entry_ix: usize, + tool_call_id: acp::ToolCallId, + cx: &Context, + ) -> Div { + let is_first = self + .thread + .read(cx) + .first_tool_awaiting_confirmation() + .is_some_and(|call| call.id == tool_call_id); + + // Get the selected granularity index, defaulting to the 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 dropdown_label: SharedString = selected_choice + .map(|choice| choice.label()) + .unwrap_or_else(|| "Only this time".into()); + + let (allow_option_id, allow_option_kind, deny_option_id, deny_option_kind) = + if let Some(choice) = selected_choice { + ( + choice.allow.option_id.clone(), + choice.allow.kind, + choice.deny.option_id.clone(), + choice.deny.kind, + ) + } else { + ( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + acp::PermissionOptionId::new("deny"), + acp::PermissionOptionKind::RejectOnce, + ) + }; + + h_flex() + .w_full() + .p_1() + .gap_2() + .justify_between() + .border_t_1() + .border_color(self.tool_card_border_color(cx)) + .child( + h_flex() + .gap_0p5() + .child( + Button::new(("allow-btn", entry_ix), "Allow") + .icon(IconName::Check) + .icon_color(Color::Success) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .label_size(LabelSize::Small) + .when(is_first, |this| { + this.key_binding( + KeyBinding::for_action_in( + &AllowOnce as &dyn Action, + &self.focus_handle(cx), + cx, + ) + .map(|kb| kb.size(rems_from_px(10.))), + ) + }) + .on_click(cx.listener({ + let tool_call_id = tool_call_id.clone(); + let option_id = allow_option_id; + let option_kind = allow_option_kind; + move |this, _, window, cx| { + this.authorize_tool_call( + tool_call_id.clone(), + option_id.clone(), + option_kind, + window, + cx, + ); + } + })), + ) + .child( + Button::new(("deny-btn", entry_ix), "Deny") + .icon(IconName::Close) + .icon_color(Color::Error) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .label_size(LabelSize::Small) + .when(is_first, |this| { + this.key_binding( + KeyBinding::for_action_in( + &RejectOnce as &dyn Action, + &self.focus_handle(cx), + cx, + ) + .map(|kb| kb.size(rems_from_px(10.))), + ) + }) + .on_click(cx.listener({ + let tool_call_id = tool_call_id.clone(); + let option_id = deny_option_id; + let option_kind = deny_option_kind; + move |this, _, window, cx| { + this.authorize_tool_call( + tool_call_id.clone(), + option_id.clone(), + option_kind, + window, + cx, + ); + } + })), + ), + ) + .child(self.render_permission_granularity_dropdown( + choices, + dropdown_label, + entry_ix, + tool_call_id, + selected_index, + is_first, + cx, + )) + } + + fn render_permission_granularity_dropdown( + &self, + choices: &[PermissionOptionChoice], + current_label: SharedString, + entry_ix: usize, + tool_call_id: acp::ToolCallId, + selected_index: usize, + is_first: bool, + cx: &Context, + ) -> AnyElement { + let menu_options: Vec<(usize, SharedString)> = choices + .iter() + .enumerate() + .map(|(i, choice)| (i, choice.label())) + .collect(); + + let permission_dropdown_handle = self.permission_dropdown_handle.clone(); + + PopoverMenu::new(("permission-granularity", entry_ix)) + .with_handle(permission_dropdown_handle) + .trigger( + Button::new(("granularity-trigger", entry_ix), current_label) + .icon(IconName::ChevronDown) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .label_size(LabelSize::Small) + .when(is_first, |this| { + this.key_binding( + KeyBinding::for_action_in( + &crate::OpenPermissionDropdown as &dyn Action, + &self.focus_handle(cx), + cx, + ) + .map(|kb| kb.size(rems_from_px(10.))), + ) + }), + ) + .menu(move |window, cx| { + let tool_call_id = tool_call_id.clone(); + let options = menu_options.clone(); + + Some(ContextMenu::build(window, cx, move |mut menu, _, _| { + for (index, display_name) in options.iter() { + let display_name = display_name.clone(); + let index = *index; + let tool_call_id_for_entry = tool_call_id.clone(); + let is_selected = index == selected_index; + + menu = menu.toggleable_entry( + display_name, + is_selected, + IconPosition::End, + None, + move |window, cx| { + window.dispatch_action( + SelectPermissionGranularity { + tool_call_id: tool_call_id_for_entry.0.to_string(), + index, + } + .boxed_clone(), + cx, + ); + }, + ); + } + + menu + })) + }) + .into_any_element() + } + + fn render_permission_buttons_flat( + &self, + options: &[acp::PermissionOption], + entry_ix: usize, + tool_call_id: acp::ToolCallId, + cx: &Context, + ) -> Div { + let is_first = self + .thread + .read(cx) + .first_tool_awaiting_confirmation() + .is_some_and(|call| call.id == tool_call_id); + let mut seen_kinds: ArrayVec = ArrayVec::new(); + + div() + .p_1() + .border_t_1() + .border_color(self.tool_card_border_color(cx)) + .w_full() + .v_flex() + .gap_0p5() + .children(options.iter().map(move |option| { + let option_id = SharedString::from(option.option_id.0.clone()); + Button::new((option_id, entry_ix), option.name.clone()) + .map(|this| { + let (this, action) = match option.kind { + acp::PermissionOptionKind::AllowOnce => ( + this.icon(IconName::Check).icon_color(Color::Success), + Some(&AllowOnce as &dyn Action), + ), + acp::PermissionOptionKind::AllowAlways => ( + this.icon(IconName::CheckDouble).icon_color(Color::Success), + Some(&AllowAlways as &dyn Action), + ), + acp::PermissionOptionKind::RejectOnce => ( + this.icon(IconName::Close).icon_color(Color::Error), + Some(&RejectOnce as &dyn Action), + ), + acp::PermissionOptionKind::RejectAlways | _ => { + (this.icon(IconName::Close).icon_color(Color::Error), None) + } + }; + + let Some(action) = action else { + return this; + }; + + if !is_first || seen_kinds.contains(&option.kind) { + return this; + } + + seen_kinds.push(option.kind); + + this.key_binding( + KeyBinding::for_action_in(action, &self.focus_handle(cx), cx) + .map(|kb| kb.size(rems_from_px(10.))), + ) + }) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .label_size(LabelSize::Small) + .on_click(cx.listener({ + let tool_call_id = tool_call_id.clone(); + let option_id = option.option_id.clone(); + let option_kind = option.kind; + move |this, _, window, cx| { + this.authorize_tool_call( + tool_call_id.clone(), + option_id.clone(), + option_kind, + window, + cx, + ); + } + })) + })) + } + + fn render_diff_loading(&self, cx: &Context) -> AnyElement { + let bar = |n: u64, width_class: &str| { + let bg_color = cx.theme().colors().element_active; + let base = h_flex().h_1().rounded_full(); + + let modified = match width_class { + "w_4_5" => base.w_3_4(), + "w_1_4" => base.w_1_4(), + "w_2_4" => base.w_2_4(), + "w_3_5" => base.w_3_5(), + "w_2_5" => base.w_2_5(), + _ => base.w_1_2(), + }; + + modified.with_animation( + ElementId::Integer(n), + Animation::new(Duration::from_secs(2)).repeat(), + move |tab, delta| { + let delta = (delta - 0.15 * n as f32) / 0.7; + let delta = 1.0 - (0.5 - delta).abs() * 2.; + let delta = ease_in_out(delta.clamp(0., 1.)); + let delta = 0.1 + 0.9 * delta; + + tab.bg(bg_color.opacity(delta)) + }, + ) + }; + + v_flex() + .p_3() + .gap_1() + .rounded_b_md() + .bg(cx.theme().colors().editor_background) + .child(bar(0, "w_4_5")) + .child(bar(1, "w_1_4")) + .child(bar(2, "w_2_4")) + .child(bar(3, "w_3_5")) + .child(bar(4, "w_2_5")) + .into_any_element() + } + + fn render_tool_call_label( + &self, + entry_ix: usize, + tool_call: &ToolCall, + is_edit: bool, + has_failed: bool, + has_revealed_diff: bool, + use_card_layout: bool, + window: &Window, + cx: &Context, + ) -> Div { + let has_location = tool_call.locations.len() == 1; + let is_file = tool_call.kind == acp::ToolKind::Edit && has_location; + + let file_icon = if has_location { + FileIcons::get_icon(&tool_call.locations[0].path, cx) + .map(Icon::from_path) + .unwrap_or(Icon::new(IconName::ToolPencil)) + } else { + Icon::new(IconName::ToolPencil) + }; + + let tool_icon = if is_file && has_failed && has_revealed_diff { + div() + .id(entry_ix) + .tooltip(Tooltip::text("Interrupted Edit")) + .child(DecoratedIcon::new( + file_icon, + Some( + IconDecoration::new( + IconDecorationKind::Triangle, + self.tool_card_header_bg(cx), + cx, + ) + .color(cx.theme().status().warning) + .position(gpui::Point { + x: px(-2.), + y: px(-2.), + }), + ), + )) + .into_any_element() + } else if is_file { + div().child(file_icon).into_any_element() + } else { + div() + .child( + Icon::new(match tool_call.kind { + acp::ToolKind::Read => IconName::ToolSearch, + acp::ToolKind::Edit => IconName::ToolPencil, + acp::ToolKind::Delete => IconName::ToolDeleteFile, + acp::ToolKind::Move => IconName::ArrowRightLeft, + acp::ToolKind::Search => IconName::ToolSearch, + acp::ToolKind::Execute => IconName::ToolTerminal, + acp::ToolKind::Think => IconName::ToolThink, + acp::ToolKind::Fetch => IconName::ToolWeb, + acp::ToolKind::SwitchMode => IconName::ArrowRightLeft, + acp::ToolKind::Other | _ => IconName::ToolHammer, + }) + .size(IconSize::Small) + .color(Color::Muted), + ) + .into_any_element() + }; + + let gradient_overlay = { + div() + .absolute() + .top_0() + .right_0() + .w_12() + .h_full() + .map(|this| { + if use_card_layout { + this.bg(linear_gradient( + 90., + linear_color_stop(self.tool_card_header_bg(cx), 1.), + linear_color_stop(self.tool_card_header_bg(cx).opacity(0.2), 0.), + )) + } else { + this.bg(linear_gradient( + 90., + linear_color_stop(cx.theme().colors().panel_background, 1.), + linear_color_stop( + cx.theme().colors().panel_background.opacity(0.2), + 0., + ), + )) + } + }) + }; + + h_flex() + .relative() + .w_full() + .h(window.line_height() - px(2.)) + .text_size(self.tool_name_font_size()) + .gap_1p5() + .when(has_location || use_card_layout, |this| this.px_1()) + .when(has_location, |this| { + this.cursor(CursorStyle::PointingHand) + .rounded(rems_from_px(3.)) // Concentric border radius + .hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.5))) + }) + .overflow_hidden() + .child(tool_icon) + .child(if has_location { + h_flex() + .id(("open-tool-call-location", entry_ix)) + .w_full() + .map(|this| { + if use_card_layout { + this.text_color(cx.theme().colors().text) + } else { + this.text_color(cx.theme().colors().text_muted) + } + }) + .child( + self.render_markdown( + tool_call.label.clone(), + MarkdownStyle { + prevent_mouse_interaction: true, + ..MarkdownStyle::themed(MarkdownFont::Agent, window, cx) + .with_muted_text(cx) + }, + ), + ) + .tooltip(Tooltip::text("Go to File")) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_tool_call_location(entry_ix, 0, window, cx); + })) + .into_any_element() + } else { + h_flex() + .w_full() + .child(self.render_markdown( + tool_call.label.clone(), + MarkdownStyle::themed(MarkdownFont::Agent, window, cx).with_muted_text(cx), + )) + .into_any() + }) + .when(!is_edit, |this| this.child(gradient_overlay)) + } + + fn open_tool_call_location( + &self, + entry_ix: usize, + location_ix: usize, + window: &mut Window, + cx: &mut Context, + ) -> Option<()> { + let (tool_call_location, agent_location) = self + .thread + .read(cx) + .entries() + .get(entry_ix)? + .location(location_ix)?; + + let project_path = self + .project + .upgrade()? + .read(cx) + .find_project_path(&tool_call_location.path, cx)?; + + let open_task = self + .workspace + .update(cx, |workspace, cx| { + workspace.open_path(project_path, None, true, window, cx) + }) + .log_err()?; + window + .spawn(cx, async move |cx| { + let item = open_task.await?; + + let Some(active_editor) = item.downcast::() else { + return anyhow::Ok(()); + }; + + active_editor.update_in(cx, |editor, window, cx| { + let multibuffer = editor.buffer().read(cx); + let buffer = multibuffer.as_singleton(); + if agent_location.buffer.upgrade() == buffer { + let excerpt_id = multibuffer.excerpt_ids().first().cloned(); + let anchor = + editor::Anchor::in_buffer(excerpt_id.unwrap(), agent_location.position); + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_anchor_ranges([anchor..anchor]); + }) + } else { + let row = tool_call_location.line.unwrap_or_default(); + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([Point::new(row, 0)..Point::new(row, 0)]); + }) + } + })?; + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + + None + } + + fn render_tool_call_content( + &self, + entry_ix: usize, + content: &ToolCallContent, + context_ix: usize, + tool_call: &ToolCall, + card_layout: bool, + is_image_tool_call: bool, + has_failed: bool, + window: &Window, + cx: &Context, + ) -> AnyElement { + match content { + ToolCallContent::ContentBlock(content) => { + if let Some(resource_link) = content.resource_link() { + self.render_resource_link(resource_link, cx) + } else if let Some(markdown) = content.markdown() { + self.render_markdown_output( + markdown.clone(), + tool_call.id.clone(), + context_ix, + card_layout, + window, + cx, + ) + } else if let Some(image) = content.image() { + let location = tool_call.locations.first().cloned(); + self.render_image_output( + entry_ix, + image.clone(), + location, + card_layout, + is_image_tool_call, + cx, + ) + } else { + Empty.into_any_element() + } + } + ToolCallContent::Diff(diff) => { + self.render_diff_editor(entry_ix, diff, tool_call, has_failed, cx) + } + ToolCallContent::Terminal(terminal) => { + self.render_terminal_tool_call(entry_ix, terminal, tool_call, window, cx) + } + ToolCallContent::SubagentThread(_thread) => { + // Subagent threads are rendered by render_subagent_tool_call, not here + Empty.into_any_element() + } + } + } + + fn render_resource_link( + &self, + resource_link: &acp::ResourceLink, + cx: &Context, + ) -> AnyElement { + let uri: SharedString = resource_link.uri.clone().into(); + let is_file = resource_link.uri.strip_prefix("file://"); + + let Some(project) = self.project.upgrade() else { + return Empty.into_any_element(); + }; + + let label: SharedString = if let Some(abs_path) = is_file { + if let Some(project_path) = project + .read(cx) + .project_path_for_absolute_path(&Path::new(abs_path), cx) + && let Some(worktree) = project + .read(cx) + .worktree_for_id(project_path.worktree_id, cx) + { + worktree + .read(cx) + .full_path(&project_path.path) + .to_string_lossy() + .to_string() + .into() + } else { + abs_path.to_string().into() + } + } else { + uri.clone() + }; + + let button_id = SharedString::from(format!("item-{}", uri)); + + div() + .ml(rems(0.4)) + .pl_2p5() + .border_l_1() + .border_color(self.tool_card_border_color(cx)) + .overflow_hidden() + .child( + Button::new(button_id, label) + .label_size(LabelSize::Small) + .color(Color::Muted) + .truncate(true) + .when(is_file.is_none(), |this| { + this.icon(IconName::ArrowUpRight) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + }) + .on_click(cx.listener({ + let workspace = self.workspace.clone(); + move |_, _, window, cx: &mut Context| { + open_link(uri.clone(), &workspace, window, cx); + } + })), + ) + .into_any_element() + } + + fn render_diff_editor( + &self, + entry_ix: usize, + diff: &Entity, + tool_call: &ToolCall, + has_failed: bool, + cx: &Context, + ) -> AnyElement { + let tool_progress = matches!( + &tool_call.status, + ToolCallStatus::InProgress | ToolCallStatus::Pending + ); + + let revealed_diff_editor = if let Some(entry) = + self.entry_view_state.read(cx).entry(entry_ix) + && let Some(editor) = entry.editor_for_diff(diff) + && diff.read(cx).has_revealed_range(cx) + { + Some(editor) + } else { + None + }; + + let show_top_border = !has_failed || revealed_diff_editor.is_some(); + + v_flex() + .h_full() + .when(show_top_border, |this| { + this.border_t_1() + .when(has_failed, |this| this.border_dashed()) + .border_color(self.tool_card_border_color(cx)) + }) + .child(if let Some(editor) = revealed_diff_editor { + editor.into_any_element() + } else if tool_progress && self.as_native_connection(cx).is_some() { + self.render_diff_loading(cx) + } else { + Empty.into_any() + }) + .into_any() + } + + fn render_markdown_output( + &self, + markdown: Entity, + tool_call_id: acp::ToolCallId, + context_ix: usize, + card_layout: bool, + window: &Window, + cx: &Context, + ) -> AnyElement { + let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id)); + + v_flex() + .gap_2() + .map(|this| { + if card_layout { + this.when(context_ix > 0, |this| { + this.pt_2() + .border_t_1() + .border_color(self.tool_card_border_color(cx)) + }) + } else { + this.ml(rems(0.4)) + .px_3p5() + .border_l_1() + .border_color(self.tool_card_border_color(cx)) + } + }) + .text_xs() + .text_color(cx.theme().colors().text_muted) + .child(self.render_markdown( + markdown, + MarkdownStyle::themed(MarkdownFont::Agent, window, cx), + )) + .when(!card_layout, |this| { + this.child( + IconButton::new(button_id, IconName::ChevronUp) + .full_width() + .style(ButtonStyle::Outlined) + .icon_color(Color::Muted) + .on_click(cx.listener({ + move |this: &mut Self, _, _, cx: &mut Context| { + this.expanded_tool_calls.remove(&tool_call_id); + cx.notify(); + } + })), + ) + }) + .into_any_element() + } + + fn render_image_output( + &self, + entry_ix: usize, + image: Arc, + location: Option, + card_layout: bool, + show_dimensions: bool, + cx: &Context, + ) -> AnyElement { + let dimensions_label = if show_dimensions { + let format_name = match image.format() { + gpui::ImageFormat::Png => "PNG", + gpui::ImageFormat::Jpeg => "JPEG", + gpui::ImageFormat::Webp => "WebP", + gpui::ImageFormat::Gif => "GIF", + gpui::ImageFormat::Svg => "SVG", + gpui::ImageFormat::Bmp => "BMP", + gpui::ImageFormat::Tiff => "TIFF", + gpui::ImageFormat::Ico => "ICO", + }; + let dimensions = image::ImageReader::new(std::io::Cursor::new(image.bytes())) + .with_guessed_format() + .ok() + .and_then(|reader| reader.into_dimensions().ok()); + dimensions.map(|(w, h)| format!("{}Γ—{} {}", w, h, format_name)) + } else { + None + }; + + v_flex() + .gap_2() + .map(|this| { + if card_layout { + this + } else { + this.ml(rems(0.4)) + .px_3p5() + .border_l_1() + .border_color(self.tool_card_border_color(cx)) + } + }) + .when(dimensions_label.is_some() || location.is_some(), |this| { + this.child( + h_flex() + .w_full() + .justify_between() + .items_center() + .children(dimensions_label.map(|label| { + Label::new(label) + .size(LabelSize::XSmall) + .color(Color::Muted) + .buffer_font(cx) + })) + .when_some(location, |this, _loc| { + this.child( + Button::new(("go-to-file", entry_ix), "Go to File") + .label_size(LabelSize::Small) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_tool_call_location(entry_ix, 0, window, cx); + })), + ) + }), + ) + }) + .child( + img(image) + .max_w_96() + .max_h_96() + .object_fit(ObjectFit::ScaleDown), + ) + .into_any_element() + } + + fn render_subagent_tool_call( + &self, + entry_ix: usize, + tool_call: &ToolCall, + window: &Window, + cx: &Context, + ) -> Div { + let subagent_threads: Vec<_> = tool_call + .content + .iter() + .filter_map(|c| c.subagent_thread().cloned()) + .collect(); + + let tool_call_status = &tool_call.status; + + v_flex() + .mx_5() + .my_1p5() + .gap_3() + .children( + subagent_threads + .into_iter() + .enumerate() + .map(|(context_ix, thread)| { + self.render_subagent_card( + entry_ix, + context_ix, + &thread, + tool_call_status, + window, + cx, + ) + }), + ) + } + + fn render_subagent_card( + &self, + entry_ix: usize, + context_ix: usize, + thread: &Entity, + tool_call_status: &ToolCallStatus, + window: &Window, + cx: &Context, + ) -> AnyElement { + let thread_read = thread.read(cx); + let session_id = thread_read.session_id().clone(); + let title = thread_read.title(); + let action_log = thread_read.action_log(); + let changed_buffers = action_log.read(cx).changed_buffers(cx); + + let is_expanded = self.expanded_subagents.contains(&session_id); + let files_changed = changed_buffers.len(); + let diff_stats = DiffStats::all_files(&changed_buffers, cx); + + let is_running = matches!( + tool_call_status, + ToolCallStatus::Pending | ToolCallStatus::InProgress + ); + let is_canceled_or_failed = matches!( + tool_call_status, + ToolCallStatus::Canceled | ToolCallStatus::Failed | ToolCallStatus::Rejected + ); + + let card_header_id = + SharedString::from(format!("subagent-header-{}-{}", entry_ix, context_ix)); + let diff_stat_id = SharedString::from(format!("subagent-diff-{}-{}", entry_ix, context_ix)); + + let icon = h_flex().w_4().justify_center().child(if is_running { + SpinnerLabel::new() + .size(LabelSize::Small) + .into_any_element() + } else if is_canceled_or_failed { + Icon::new(IconName::Close) + .size(IconSize::Small) + .color(Color::Error) + .into_any_element() + } else { + Icon::new(IconName::Check) + .size(IconSize::Small) + .color(Color::Success) + .into_any_element() + }); + + let has_expandable_content = thread_read.entries().iter().rev().any(|entry| { + if let AgentThreadEntry::AssistantMessage(msg) = entry { + msg.chunks.iter().any(|chunk| match chunk { + AssistantMessageChunk::Message { block } => block.markdown().is_some(), + AssistantMessageChunk::Thought { block } => block.markdown().is_some(), + }) + } else { + false + } + }); + + v_flex() + .w_full() + .rounded_md() + .border_1() + .border_color(self.tool_card_border_color(cx)) + .overflow_hidden() + .child( + h_flex() + .group(&card_header_id) + .py_1() + .px_1p5() + .w_full() + .gap_1() + .justify_between() + .bg(self.tool_card_header_bg(cx)) + .child( + h_flex() + .gap_1p5() + .child(icon) + .child( + Label::new(title.to_string()) + .size(LabelSize::Small) + .color(Color::Default), + ) + .when(files_changed > 0, |this| { + this.child( + h_flex() + .gap_1() + .child( + Label::new(format!( + "β€” {} {} changed", + files_changed, + if files_changed == 1 { "file" } else { "files" } + )) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child(DiffStat::new( + diff_stat_id.clone(), + diff_stats.lines_added as usize, + diff_stats.lines_removed as usize, + )), + ) + }), + ) + .child( + h_flex() + .gap_1p5() + .when(is_running, |buttons| { + buttons.child( + Button::new( + SharedString::from(format!( + "stop-subagent-{}-{}", + entry_ix, context_ix + )), + "Stop", + ) + .icon(IconName::Stop) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Error) + .label_size(LabelSize::Small) + .tooltip(Tooltip::text("Stop this subagent")) + .on_click({ + let thread = thread.clone(); + cx.listener(move |_this, _event, _window, cx| { + thread.update(cx, |thread, _cx| { + thread.stop_by_user(); + }); + }) + }), + ) + }) + .child( + IconButton::new( + SharedString::from(format!( + "subagent-disclosure-{}-{}", + entry_ix, context_ix + )), + if is_expanded { + IconName::ChevronUp + } else { + IconName::ChevronDown + }, + ) + .shape(IconButtonShape::Square) + .icon_color(Color::Muted) + .icon_size(IconSize::Small) + .disabled(!has_expandable_content) + .when(has_expandable_content, |button| { + button.on_click(cx.listener({ + move |this, _, _, cx| { + if this.expanded_subagents.contains(&session_id) { + this.expanded_subagents.remove(&session_id); + } else { + this.expanded_subagents.insert(session_id.clone()); + } + cx.notify(); + } + })) + }) + .when( + !has_expandable_content, + |button| { + button.tooltip(Tooltip::text("Waiting for content...")) + }, + ), + ), + ), + ) + .when(is_expanded, |this| { + this.child( + self.render_subagent_expanded_content(entry_ix, context_ix, thread, window, cx), + ) + }) + .children( + thread_read + .first_tool_awaiting_confirmation() + .and_then(|tc| { + if let ToolCallStatus::WaitingForConfirmation { options, .. } = &tc.status { + Some(self.render_subagent_pending_tool_call( + entry_ix, + context_ix, + thread.clone(), + tc, + options, + window, + cx, + )) + } else { + None + } + }), + ) + .into_any_element() + } + + fn render_subagent_expanded_content( + &self, + _entry_ix: usize, + _context_ix: usize, + thread: &Entity, + window: &Window, + cx: &Context, + ) -> impl IntoElement { + let thread_read = thread.read(cx); + let session_id = thread_read.session_id().clone(); + let entries = thread_read.entries(); + + // Find the most recent agent message with any content (message or thought) + let last_assistant_markdown = entries.iter().rev().find_map(|entry| { + if let AgentThreadEntry::AssistantMessage(msg) = entry { + msg.chunks.iter().find_map(|chunk| match chunk { + AssistantMessageChunk::Message { block } => block.markdown().cloned(), + AssistantMessageChunk::Thought { block } => block.markdown().cloned(), + }) + } else { + None + } + }); + + let scroll_handle = self + .subagent_scroll_handles + .borrow_mut() + .entry(session_id.clone()) + .or_default() + .clone(); + + scroll_handle.scroll_to_bottom(); + let editor_bg = cx.theme().colors().editor_background; + + let gradient_overlay = { + div().absolute().inset_0().bg(linear_gradient( + 180., + linear_color_stop(editor_bg, 0.), + linear_color_stop(editor_bg.opacity(0.), 0.15), + )) + }; + + div() + .relative() + .w_full() + .max_h_56() + .p_2p5() + .text_ui(cx) + .border_t_1() + .border_color(self.tool_card_border_color(cx)) + .bg(editor_bg.opacity(0.4)) + .overflow_hidden() + .child( + div() + .id(format!("subagent-content-{}", session_id)) + .size_full() + .track_scroll(&scroll_handle) + .when_some(last_assistant_markdown, |this, markdown| { + this.child(self.render_markdown( + markdown, + MarkdownStyle::themed(MarkdownFont::Agent, window, cx), + )) + }), + ) + .child(gradient_overlay) + } + + fn render_subagent_pending_tool_call( + &self, + entry_ix: usize, + context_ix: usize, + subagent_thread: Entity, + tool_call: &ToolCall, + options: &PermissionOptions, + window: &Window, + cx: &Context, + ) -> Div { + let tool_call_id = tool_call.id.clone(); + let is_edit = + matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some(); + let has_image_content = tool_call.content.iter().any(|c| c.image().is_some()); + + v_flex() + .w_full() + .border_t_1() + .border_color(self.tool_card_border_color(cx)) + .child( + self.render_tool_call_label( + entry_ix, tool_call, is_edit, false, // has_failed + false, // has_revealed_diff + true, // use_card_layout + window, cx, + ) + .py_1(), + ) + .children( + tool_call + .content + .iter() + .enumerate() + .map(|(content_ix, content)| { + self.render_tool_call_content( + entry_ix, + content, + content_ix, + tool_call, + true, // card_layout + has_image_content, + false, // has_failed + window, + cx, + ) + }), + ) + .child(self.render_subagent_permission_buttons( + entry_ix, + context_ix, + subagent_thread, + tool_call_id, + options, + cx, + )) + } + + fn render_subagent_permission_buttons( + &self, + entry_ix: usize, + context_ix: usize, + subagent_thread: Entity, + tool_call_id: acp::ToolCallId, + options: &PermissionOptions, + cx: &Context, + ) -> Div { + match options { + PermissionOptions::Flat(options) => self.render_subagent_permission_buttons_flat( + entry_ix, + context_ix, + subagent_thread, + tool_call_id, + options, + cx, + ), + PermissionOptions::Dropdown(options) => self + .render_subagent_permission_buttons_dropdown( + entry_ix, + context_ix, + subagent_thread, + tool_call_id, + options, + cx, + ), + } + } + + fn render_subagent_permission_buttons_flat( + &self, + entry_ix: usize, + context_ix: usize, + subagent_thread: Entity, + tool_call_id: acp::ToolCallId, + options: &[acp::PermissionOption], + cx: &Context, + ) -> Div { + div() + .p_1() + .border_t_1() + .border_color(self.tool_card_border_color(cx)) + .w_full() + .v_flex() + .gap_0p5() + .children(options.iter().map(move |option| { + let option_id = SharedString::from(format!( + "subagent-{}-{}-{}", + entry_ix, context_ix, option.option_id.0 + )); + Button::new((option_id, entry_ix), option.name.clone()) + .map(|this| match option.kind { + acp::PermissionOptionKind::AllowOnce => { + this.icon(IconName::Check).icon_color(Color::Success) + } + acp::PermissionOptionKind::AllowAlways => { + this.icon(IconName::CheckDouble).icon_color(Color::Success) + } + acp::PermissionOptionKind::RejectOnce + | acp::PermissionOptionKind::RejectAlways + | _ => this.icon(IconName::Close).icon_color(Color::Error), + }) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .label_size(LabelSize::Small) + .on_click(cx.listener({ + let subagent_thread = subagent_thread.clone(); + let tool_call_id = tool_call_id.clone(); + let option_id = option.option_id.clone(); + let option_kind = option.kind; + move |this, _, window, cx| { + this.authorize_subagent_tool_call( + subagent_thread.clone(), + tool_call_id.clone(), + option_id.clone(), + option_kind, + window, + cx, + ); + } + })) + })) + } + + fn authorize_subagent_tool_call( + &mut self, + subagent_thread: Entity, + tool_call_id: acp::ToolCallId, + option_id: acp::PermissionOptionId, + option_kind: acp::PermissionOptionKind, + _window: &mut Window, + cx: &mut Context, + ) { + subagent_thread.update(cx, |thread, cx| { + thread.authorize_tool_call(tool_call_id, option_id, option_kind, cx); + }); + } + + fn render_subagent_permission_buttons_dropdown( + &self, + entry_ix: usize, + context_ix: usize, + subagent_thread: Entity, + tool_call_id: acp::ToolCallId, + choices: &[PermissionOptionChoice], + cx: &Context, + ) -> Div { + 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 dropdown_label: SharedString = selected_choice + .map(|choice| choice.label()) + .unwrap_or_else(|| "Only this time".into()); + + let (allow_option_id, allow_option_kind, deny_option_id, deny_option_kind) = + if let Some(choice) = selected_choice { + ( + choice.allow.option_id.clone(), + choice.allow.kind, + choice.deny.option_id.clone(), + choice.deny.kind, + ) + } else { + ( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + acp::PermissionOptionId::new("deny"), + acp::PermissionOptionKind::RejectOnce, + ) + }; + + h_flex() + .w_full() + .p_1() + .gap_2() + .justify_between() + .border_t_1() + .border_color(self.tool_card_border_color(cx)) + .child( + h_flex() + .gap_0p5() + .child( + Button::new( + ( + SharedString::from(format!( + "subagent-allow-btn-{}-{}", + entry_ix, context_ix + )), + entry_ix, + ), + "Allow", + ) + .icon(IconName::Check) + .icon_color(Color::Success) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .label_size(LabelSize::Small) + .on_click(cx.listener({ + let subagent_thread = subagent_thread.clone(); + let tool_call_id = tool_call_id.clone(); + let option_id = allow_option_id; + let option_kind = allow_option_kind; + move |this, _, window, cx| { + this.authorize_subagent_tool_call( + subagent_thread.clone(), + tool_call_id.clone(), + option_id.clone(), + option_kind, + window, + cx, + ); + } + })), + ) + .child( + Button::new( + ( + SharedString::from(format!( + "subagent-deny-btn-{}-{}", + entry_ix, context_ix + )), + entry_ix, + ), + "Deny", + ) + .icon(IconName::Close) + .icon_color(Color::Error) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .label_size(LabelSize::Small) + .on_click(cx.listener({ + let tool_call_id = tool_call_id.clone(); + let option_id = deny_option_id; + let option_kind = deny_option_kind; + move |this, _, window, cx| { + this.authorize_subagent_tool_call( + subagent_thread.clone(), + tool_call_id.clone(), + option_id.clone(), + option_kind, + window, + cx, + ); + } + })), + ), + ) + .child(self.render_subagent_permission_granularity_dropdown( + choices, + dropdown_label, + entry_ix, + context_ix, + tool_call_id, + selected_index, + cx, + )) + } + + fn render_subagent_permission_granularity_dropdown( + &self, + choices: &[PermissionOptionChoice], + current_label: SharedString, + entry_ix: usize, + context_ix: usize, + tool_call_id: acp::ToolCallId, + selected_index: usize, + _cx: &Context, + ) -> AnyElement { + let menu_options: Vec<(usize, SharedString)> = choices + .iter() + .enumerate() + .map(|(i, choice)| (i, choice.label())) + .collect(); + + let permission_dropdown_handle = self.permission_dropdown_handle.clone(); + + PopoverMenu::new(( + SharedString::from(format!( + "subagent-permission-granularity-{}-{}", + entry_ix, context_ix + )), + entry_ix, + )) + .with_handle(permission_dropdown_handle) + .trigger( + Button::new( + ( + SharedString::from(format!( + "subagent-granularity-trigger-{}-{}", + entry_ix, context_ix + )), + entry_ix, + ), + current_label, + ) + .icon(IconName::ChevronDown) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .label_size(LabelSize::Small), + ) + .menu(move |window, cx| { + let tool_call_id = tool_call_id.clone(); + let options = menu_options.clone(); + + Some(ContextMenu::build(window, cx, move |mut menu, _, _| { + for (index, display_name) in options.iter() { + let display_name = display_name.clone(); + let index = *index; + let tool_call_id_for_entry = tool_call_id.clone(); + let is_selected = index == selected_index; + + menu = menu.toggleable_entry( + display_name, + is_selected, + IconPosition::End, + None, + move |window, cx| { + window.dispatch_action( + SelectPermissionGranularity { + tool_call_id: tool_call_id_for_entry.0.to_string(), + index, + } + .boxed_clone(), + cx, + ); + }, + ); + } + + menu + })) + }) + .into_any_element() + } + + fn render_rules_item(&self, cx: &Context) -> Option { + let project_context = self + .as_native_thread(cx)? + .read(cx) + .project_context() + .read(cx); + + let user_rules_text = if project_context.user_rules.is_empty() { + None + } else if project_context.user_rules.len() == 1 { + let user_rules = &project_context.user_rules[0]; + + match user_rules.title.as_ref() { + Some(title) => Some(format!("Using \"{title}\" user rule")), + None => Some("Using user rule".into()), + } + } else { + Some(format!( + "Using {} user rules", + project_context.user_rules.len() + )) + }; + + let first_user_rules_id = project_context + .user_rules + .first() + .map(|user_rules| user_rules.uuid.0); + + let rules_files = project_context + .worktrees + .iter() + .filter_map(|worktree| worktree.rules_file.as_ref()) + .collect::>(); + + let rules_file_text = match rules_files.as_slice() { + &[] => None, + &[rules_file] => Some(format!( + "Using project {:?} file", + rules_file.path_in_worktree + )), + rules_files => Some(format!("Using {} project rules files", rules_files.len())), + }; + + if user_rules_text.is_none() && rules_file_text.is_none() { + return None; + } + + let has_both = user_rules_text.is_some() && rules_file_text.is_some(); + + Some( + h_flex() + .px_2p5() + .child( + Icon::new(IconName::Attach) + .size(IconSize::XSmall) + .color(Color::Disabled), + ) + .when_some(user_rules_text, |parent, user_rules_text| { + parent.child( + h_flex() + .id("user-rules") + .ml_1() + .mr_1p5() + .child( + Label::new(user_rules_text) + .size(LabelSize::XSmall) + .color(Color::Muted) + .truncate(), + ) + .hover(|s| s.bg(cx.theme().colors().element_hover)) + .tooltip(Tooltip::text("View User Rules")) + .on_click(move |_event, window, cx| { + window.dispatch_action( + Box::new(OpenRulesLibrary { + prompt_to_select: first_user_rules_id, + }), + cx, + ) + }), + ) + }) + .when(has_both, |this| { + this.child( + Label::new("β€’") + .size(LabelSize::XSmall) + .color(Color::Disabled), + ) + }) + .when_some(rules_file_text, |parent, rules_file_text| { + parent.child( + h_flex() + .id("project-rules") + .ml_1p5() + .child( + Label::new(rules_file_text) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .hover(|s| s.bg(cx.theme().colors().element_hover)) + .tooltip(Tooltip::text("View Project Rules")) + .on_click(cx.listener(Self::handle_open_rules)), + ) + }) + .into_any(), + ) + } + + fn tool_card_header_bg(&self, cx: &Context) -> Hsla { + cx.theme() + .colors() + .element_background + .blend(cx.theme().colors().editor_foreground.opacity(0.025)) + } + + fn tool_card_border_color(&self, cx: &Context) -> Hsla { + cx.theme().colors().border.opacity(0.8) + } + + fn tool_name_font_size(&self) -> Rems { + rems_from_px(13.) + } + + pub(crate) fn render_thread_error( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option
{ + let content = match self.thread_error.as_ref()? { + ThreadError::Other { message, .. } => { + self.render_any_thread_error(message.clone(), window, cx) + } + ThreadError::Refusal => self.render_refusal_error(cx), + ThreadError::AuthenticationRequired(error) => { + self.render_authentication_required_error(error.clone(), cx) + } + ThreadError::PaymentRequired => self.render_payment_required_error(cx), + }; + + Some(div().child(content)) + } + + fn render_refusal_error(&self, cx: &mut Context<'_, Self>) -> Callout { + let model_or_agent_name = self.current_model_name(cx); + let refusal_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 + ); + + Callout::new() + .severity(Severity::Error) + .title("Request Refused") + .icon(IconName::XCircle) + .description(refusal_message.clone()) + .actions_slot(self.create_copy_button(&refusal_message)) + .dismiss_action(self.dismiss_error_button(cx)) + } + + fn render_authentication_required_error( + &self, + error: SharedString, + cx: &mut Context, + ) -> Callout { + Callout::new() + .severity(Severity::Error) + .title("Authentication Required") + .icon(IconName::XCircle) + .description(error.clone()) + .actions_slot( + h_flex() + .gap_0p5() + .child(self.authenticate_button(cx)) + .child(self.create_copy_button(error)), + ) + .dismiss_action(self.dismiss_error_button(cx)) + } + + fn render_payment_required_error(&self, cx: &mut Context) -> Callout { + const ERROR_MESSAGE: &str = + "You reached your free usage limit. Upgrade to Zed Pro for more prompts."; + + Callout::new() + .severity(Severity::Error) + .icon(IconName::XCircle) + .title("Free Usage Exceeded") + .description(ERROR_MESSAGE) + .actions_slot( + h_flex() + .gap_0p5() + .child(self.upgrade_button(cx)) + .child(self.create_copy_button(ERROR_MESSAGE)), + ) + .dismiss_action(self.dismiss_error_button(cx)) + } + + fn upgrade_button(&self, cx: &mut Context) -> impl IntoElement { + Button::new("upgrade", "Upgrade") + .label_size(LabelSize::Small) + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .on_click(cx.listener({ + move |this, _, _, cx| { + this.clear_thread_error(cx); + cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx)); + } + })) + } + + fn authenticate_button(&self, cx: &mut Context) -> impl IntoElement { + Button::new("authenticate", "Authenticate") + .label_size(LabelSize::Small) + .style(ButtonStyle::Filled) + .on_click(cx.listener({ + move |this, _, window, cx| { + let server_view = this.server_view.clone(); + let agent_name = this.agent_name.clone(); + + this.clear_thread_error(cx); + if let Some(message) = this.in_flight_prompt.take() { + this.message_editor.update(cx, |editor, cx| { + editor.set_message(message, window, cx); + }); + } + window.defer(cx, |window, cx| { + AcpServerView::handle_auth_required( + server_view, + AuthRequired::new(), + agent_name, + window, + cx, + ); + }) + } + })) + } + + fn current_model_name(&self, cx: &App) -> SharedString { + // For native agent (Zed Agent), use the specific model name (e.g., "Claude 3.5 Sonnet") + // For ACP agents, use the agent name (e.g., "Claude Code", "Gemini CLI") + // This provides better clarity about what refused the request + if self.as_native_connection(cx).is_some() { + self.model_selector + .clone() + .and_then(|selector| selector.read(cx).active_model(cx)) + .map(|model| model.name.clone()) + .unwrap_or_else(|| SharedString::from("The model")) + } else { + // ACP agent - use the agent name (e.g., "Claude Code", "Gemini CLI") + self.agent_name.clone() + } + } + + fn render_any_thread_error( + &mut self, + error: SharedString, + window: &mut Window, + cx: &mut Context<'_, Self>, + ) -> Callout { + let can_resume = self.thread.read(cx).can_retry(cx); + + let markdown = if let Some(markdown) = &self.thread_error_markdown { + markdown.clone() + } else { + let markdown = cx.new(|cx| Markdown::new(error.clone(), None, None, cx)); + self.thread_error_markdown = Some(markdown.clone()); + markdown + }; + + let markdown_style = + MarkdownStyle::themed(MarkdownFont::Agent, window, cx).with_muted_text(cx); + let description = self + .render_markdown(markdown, markdown_style) + .into_any_element(); + + Callout::new() + .severity(Severity::Error) + .icon(IconName::XCircle) + .title("An Error Happened") + .description_slot(description) + .actions_slot( + h_flex() + .gap_0p5() + .when(can_resume, |this| { + this.child( + IconButton::new("retry", IconName::RotateCw) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Retry Generation")) + .on_click(cx.listener(|this, _, _window, cx| { + this.retry_generation(cx); + })), + ) + }) + .child(self.create_copy_button(error.to_string())), + ) + .dismiss_action(self.dismiss_error_button(cx)) + } + + fn render_markdown(&self, markdown: Entity, style: MarkdownStyle) -> MarkdownElement { + let workspace = self.workspace.clone(); + MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| { + open_link(text, &workspace, window, cx); + }) + } + + fn create_copy_button(&self, message: impl Into) -> impl IntoElement { + let message = message.into(); + + CopyButton::new("copy-error-message", message).tooltip_label("Copy Error Message") + } + + fn dismiss_error_button(&self, cx: &mut Context) -> impl IntoElement { + IconButton::new("dismiss", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Dismiss")) + .on_click(cx.listener({ + move |this, _, _, cx| { + this.clear_thread_error(cx); + cx.notify(); + } + })) + } + + fn render_resume_notice(_cx: &Context) -> AnyElement { + let description = "This agent does not support viewing previous messages. However, your session will still continue from where you last left off."; + + div() + .px_2() + .pt_2() + .pb_3() + .w_full() + .child( + Callout::new() + .severity(Severity::Info) + .icon(IconName::Info) + .title("Resumed Session") + .description(description), + ) + .into_any_element() + } + + fn update_recent_history_from_cache( + &mut self, + history: &Entity, + cx: &mut Context, + ) { + self.recent_history_entries = history.read(cx).get_recent_sessions(3); + self.hovered_recent_history_item = None; + cx.notify(); + } + + fn render_empty_state_section_header( + &self, + label: impl Into, + action_slot: Option, + cx: &mut Context, + ) -> impl IntoElement { + div().pl_1().pr_1p5().child( + h_flex() + .mt_2() + .pl_1p5() + .pb_1() + .w_full() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child( + Label::new(label.into()) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .children(action_slot), + ) + } + + fn render_recent_history(&self, cx: &mut Context) -> AnyElement { + let render_history = !self.recent_history_entries.is_empty(); + + v_flex() + .size_full() + .when(render_history, |this| { + let recent_history = self.recent_history_entries.clone(); + this.justify_end().child( + v_flex() + .child( + self.render_empty_state_section_header( + "Recent", + Some( + Button::new("view-history", "View All") + .style(ButtonStyle::Subtle) + .label_size(LabelSize::Small) + .key_binding( + KeyBinding::for_action_in( + &OpenHistory, + &self.focus_handle(cx), + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(move |_event, window, cx| { + window.dispatch_action(OpenHistory.boxed_clone(), cx); + }) + .into_any_element(), + ), + cx, + ), + ) + .child(v_flex().p_1().pr_1p5().gap_1().children({ + let supports_delete = self.history.read(cx).supports_delete(); + recent_history + .into_iter() + .enumerate() + .map(move |(index, entry)| { + // TODO: Add keyboard navigation. + let is_hovered = + self.hovered_recent_history_item == Some(index); + crate::acp::thread_history::AcpHistoryEntryElement::new( + entry, + self.server_view.clone(), + ) + .hovered(is_hovered) + .supports_delete(supports_delete) + .on_hover(cx.listener(move |this, is_hovered, _window, cx| { + if *is_hovered { + this.hovered_recent_history_item = Some(index); + } else if this.hovered_recent_history_item == Some(index) { + this.hovered_recent_history_item = None; + } + cx.notify(); + })) + .into_any_element() + }) + })), + ) + }) + .into_any() + } + + fn render_codex_windows_warning(&self, cx: &mut Context) -> Callout { + Callout::new() + .icon(IconName::Warning) + .severity(Severity::Warning) + .title("Codex on Windows") + .description("For best performance, run Codex in Windows Subsystem for Linux (WSL2)") + .actions_slot( + Button::new("open-wsl-modal", "Open in WSL") + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .on_click(cx.listener({ + move |_, _, _window, cx| { + #[cfg(windows)] + _window.dispatch_action( + zed_actions::wsl_actions::OpenWsl::default().boxed_clone(), + cx, + ); + cx.notify(); + } + })), + ) + .dismiss_action( + IconButton::new("dismiss", IconName::Close) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Dismiss Warning")) + .on_click(cx.listener({ + move |this, _, _, cx| { + this.show_codex_windows_warning = false; + cx.notify(); + } + })), + ) + } + + fn render_new_version_callout(&self, version: &SharedString, cx: &mut Context) -> Div { + v_flex().w_full().justify_end().child( + h_flex() + .p_2() + .pr_3() + .w_full() + .gap_1p5() + .border_t_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().element_background) + .child( + h_flex() + .flex_1() + .gap_1p5() + .child( + Icon::new(IconName::Download) + .color(Color::Accent) + .size(IconSize::Small), + ) + .child(Label::new("New version available").size(LabelSize::Small)), + ) + .child( + Button::new("update-button", format!("Update to v{}", version)) + .label_size(LabelSize::Small) + .style(ButtonStyle::Tinted(TintColor::Accent)) + .on_click(cx.listener(|this, _, window, cx| { + this.server_view + .update(cx, |view, cx| view.reset(window, cx)) + .ok(); + })), + ), + ) + } + + fn render_token_limit_callout(&self, cx: &mut Context) -> Option { + if self.token_limit_callout_dismissed { + return None; + } + + let token_usage = self.thread.read(cx).token_usage()?; + let ratio = token_usage.ratio(); + + let (severity, icon, title) = match ratio { + acp_thread::TokenUsageRatio::Normal => return None, + acp_thread::TokenUsageRatio::Warning => ( + Severity::Warning, + IconName::Warning, + "Thread reaching the token limit soon", + ), + acp_thread::TokenUsageRatio::Exceeded => ( + Severity::Error, + IconName::XCircle, + "Thread reached the token limit", + ), + }; + + let description = "To continue, start a new thread from a summary."; + + Some( + Callout::new() + .severity(severity) + .icon(icon) + .title(title) + .description(description) + .actions_slot( + h_flex().gap_0p5().child( + Button::new("start-new-thread", "Start New Thread") + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + let session_id = this.thread.read(cx).session_id().clone(); + window.dispatch_action( + crate::NewNativeAgentThreadFromSummary { + from_session_id: session_id, + } + .boxed_clone(), + cx, + ); + })), + ), + ) + .dismiss_action(self.dismiss_error_button(cx)), + ) + } + + fn open_permission_dropdown( + &mut self, + _: &crate::OpenPermissionDropdown, + window: &mut Window, + cx: &mut Context, + ) { + self.permission_dropdown_handle.clone().toggle(window, cx); + } + + fn open_add_context_menu( + &mut self, + _action: &OpenAddContextMenu, + window: &mut Window, + cx: &mut Context, + ) { + let menu_handle = self.add_context_menu_handle.clone(); + window.defer(cx, move |window, cx| { + menu_handle.toggle(window, cx); + }); + } +} + +impl Render for AcpThreadView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let has_messages = self.list_state.item_count() > 0; + + let conversation = v_flex().flex_1().map(|this| { + let this = this.when(self.resumed_without_history, |this| { + this.child(Self::render_resume_notice(cx)) + }); + if has_messages { + let list_state = self.list_state.clone(); + this.child(self.render_entries(cx)) + .vertical_scrollbar_for(&list_state, window, cx) + .into_any() + } else { + this.child(self.render_recent_history(cx)).into_any() + } + }); + + v_flex() + .key_context("AcpThread") + .on_action(cx.listener(|this, _: &menu::Cancel, _, cx| { + this.cancel_generation(cx); + })) + .on_action(cx.listener(Self::keep_all)) + .on_action(cx.listener(Self::reject_all)) + .on_action(cx.listener(Self::allow_always)) + .on_action(cx.listener(Self::allow_once)) + .on_action(cx.listener(Self::reject_once)) + .on_action(cx.listener(Self::handle_authorize_tool_call)) + .on_action(cx.listener(Self::handle_select_permission_granularity)) + .on_action(cx.listener(Self::open_permission_dropdown)) + .on_action(cx.listener(Self::open_add_context_menu)) + .on_action(cx.listener(|this, _: &ToggleThinkingMode, _window, cx| { + if let Some(thread) = this.as_native_thread(cx) { + thread.update(cx, |thread, cx| { + thread.set_thinking_enabled(!thread.thinking_enabled(), cx); + }); + } + })) + .on_action(cx.listener(|this, _: &SendNextQueuedMessage, window, cx| { + this.send_queued_message_at_index(0, true, window, cx); + })) + .on_action(cx.listener(|this, _: &RemoveFirstQueuedMessage, _, cx| { + this.remove_from_queue(0, cx); + cx.notify(); + })) + .on_action(cx.listener(|this, _: &EditFirstQueuedMessage, window, cx| { + if let Some(editor) = this.queued_message_editors.first() { + window.focus(&editor.focus_handle(cx), cx); + } + })) + .on_action(cx.listener(|this, _: &ClearMessageQueue, _, cx| { + this.local_queued_messages.clear(); + this.sync_queue_flag_to_native_thread(cx); + this.can_fast_track_queue = false; + cx.notify(); + })) + .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| { + if let Some(config_options_view) = this.config_options_view.clone() { + let handled = config_options_view.update(cx, |view, cx| { + view.toggle_category_picker( + acp::SessionConfigOptionCategory::Mode, + window, + cx, + ) + }); + if handled { + return; + } + } + + if let Some(profile_selector) = this.profile_selector.clone() { + profile_selector.read(cx).menu_handle().toggle(window, cx); + } else if let Some(mode_selector) = this.mode_selector.clone() { + mode_selector.read(cx).menu_handle().toggle(window, cx); + } + })) + .on_action(cx.listener(|this, _: &CycleModeSelector, window, cx| { + if let Some(config_options_view) = this.config_options_view.clone() { + let handled = config_options_view.update(cx, |view, cx| { + view.cycle_category_option( + acp::SessionConfigOptionCategory::Mode, + false, + cx, + ) + }); + if handled { + return; + } + } + + if let Some(profile_selector) = this.profile_selector.clone() { + profile_selector.update(cx, |profile_selector, cx| { + profile_selector.cycle_profile(cx); + }); + } else if let Some(mode_selector) = this.mode_selector.clone() { + mode_selector.update(cx, |mode_selector, cx| { + mode_selector.cycle_mode(window, cx); + }); + } + })) + .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| { + if let Some(config_options_view) = this.config_options_view.clone() { + let handled = config_options_view.update(cx, |view, cx| { + view.toggle_category_picker( + acp::SessionConfigOptionCategory::Model, + window, + cx, + ) + }); + if handled { + return; + } + } + + if let Some(model_selector) = this.model_selector.clone() { + model_selector + .update(cx, |model_selector, cx| model_selector.toggle(window, cx)); + } + })) + .on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| { + if let Some(config_options_view) = this.config_options_view.clone() { + let handled = config_options_view.update(cx, |view, cx| { + view.cycle_category_option( + acp::SessionConfigOptionCategory::Model, + true, + cx, + ) + }); + if handled { + return; + } + } + + if let Some(model_selector) = this.model_selector.clone() { + model_selector.update(cx, |model_selector, cx| { + model_selector.cycle_favorite_models(window, cx); + }); + } + })) + .size_full() + .child(conversation) + .children(self.render_activity_bar(window, cx)) + .when(self.show_codex_windows_warning, |this| { + this.child(self.render_codex_windows_warning(cx)) + }) + .children(self.render_thread_retry_status_callout()) + .children(self.render_thread_error(window, cx)) + .when_some( + match has_messages { + true => None, + false => self.new_server_version_available.clone(), + }, + |this, version| this.child(self.render_new_version_callout(&version, cx)), + ) + .children(self.render_token_limit_callout(cx)) + .child(self.render_message_editor(window, cx)) + } +} + +pub(crate) fn open_link( + url: SharedString, + workspace: &WeakEntity, + window: &mut Window, + cx: &mut App, +) { + let Some(workspace) = workspace.upgrade() else { + cx.open_url(&url); + return; + }; + + if let Some(mention) = MentionUri::parse(&url, workspace.read(cx).path_style(cx)).log_err() { + workspace.update(cx, |workspace, cx| match mention { + MentionUri::File { abs_path } => { + let project = workspace.project(); + let Some(path) = + project.update(cx, |project, cx| project.find_project_path(abs_path, cx)) + else { + return; + }; + + workspace + .open_path(path, None, true, window, cx) + .detach_and_log_err(cx); + } + MentionUri::PastedImage => {} + MentionUri::Directory { abs_path } => { + let project = workspace.project(); + let Some(entry_id) = project.update(cx, |project, cx| { + let path = project.find_project_path(abs_path, cx)?; + project.entry_for_path(&path, cx).map(|entry| entry.id) + }) else { + return; + }; + + project.update(cx, |_, cx| { + cx.emit(project::Event::RevealInProjectPanel(entry_id)); + }); + } + MentionUri::Symbol { + abs_path: path, + line_range, + .. + } + | MentionUri::Selection { + abs_path: Some(path), + line_range, + } => { + let project = workspace.project(); + let Some(path) = + project.update(cx, |project, cx| project.find_project_path(path, cx)) + else { + return; + }; + + let item = workspace.open_path(path, None, true, window, cx); + window + .spawn(cx, async move |cx| { + let Some(editor) = item.await?.downcast::() else { + return Ok(()); + }; + let range = + Point::new(*line_range.start(), 0)..Point::new(*line_range.start(), 0); + editor + .update_in(cx, |editor, window, cx| { + editor.change_selections( + SelectionEffects::scroll(Autoscroll::center()), + window, + cx, + |s| s.select_ranges(vec![range]), + ); + }) + .ok(); + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + MentionUri::Selection { abs_path: None, .. } => {} + MentionUri::Thread { id, name } => { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.open_thread( + AgentSessionInfo { + session_id: id, + cwd: None, + title: Some(name.into()), + updated_at: None, + meta: None, + }, + window, + cx, + ) + }); + } + } + MentionUri::TextThread { path, .. } => { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel + .open_saved_text_thread(path.as_path().into(), window, cx) + .detach_and_log_err(cx); + }); + } + } + MentionUri::Rule { id, .. } => { + let PromptId::User { uuid } = id else { + return; + }; + window.dispatch_action( + Box::new(OpenRulesLibrary { + prompt_to_select: Some(uuid.0), + }), + cx, + ) + } + MentionUri::Fetch { url } => { + cx.open_url(url.as_str()); + } + MentionUri::Diagnostics { .. } => {} + MentionUri::TerminalSelection { .. } => {} + }) + } else { + cx.open_url(&url); + } } diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index f176d0e2c4fb854a7822ec68ddbefbf7fed51e38..8f540cd1dee8582b40c2a72550c37f6cd87463c0 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -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) { - 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 { @@ -1185,22 +1191,15 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { - 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() diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 2996069af931733a82bcb4d58ba4a09b7bc2f401..ae4f521cec77af7d030fff803ecd57f575e4ab9f 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -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; diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index 0195522df6b1801f6200f09bfc0a255963470695..faa65768b04c75a89c2490b45e58a335fa993a21 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -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, pub description: Arc, 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 }, - /// User-defined command that failed to load - UserDefinedError { error_message: Arc }, } pub trait PromptCompletionProviderDelegate: Send + Sync + 'static { @@ -215,18 +200,6 @@ pub trait PromptCompletionProviderDelegate: Send + Sync + 'static { fn available_commands(&self, cx: &App) -> Vec; 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> { - 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> { - None - } } pub struct PromptCompletionProvider { @@ -803,111 +776,11 @@ impl PromptCompletionProvider { fn search_slash_commands(&self, query: String, cx: &mut App) -> Task> { let commands = self.source.available_commands(cx); - let server_command_names = commands - .iter() - .map(|command| command.name.as_ref().to_string()) - .collect::>(); - - // Try to use cached user commands and errors first - let cached_user_commands = if cx.has_flag::() { - self.source.cached_user_commands(cx) - } else { - None - }; - - let cached_user_command_errors = if cx.has_flag::() { - 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::() { - let workspace = self.workspace.upgrade(); - let fs = workspace - .as_ref() - .map(|w| w.read(cx).project().read(cx).fs().clone()); - let roots: Vec = 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, - Vec, - ) = 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 = 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 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 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(); diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs index 4970ee25860a2408a6a0f9a5f4648d70fe1cc573..63f04048b77beb4c2daceae542b343a54d6aad7d 100644 --- a/crates/agent_ui/src/text_thread_editor.rs +++ b/crates/agent_ui/src/text_thread_editor.rs @@ -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, - ) { - use editor::{DiffHunkKey, StoredReviewComment}; - use git_ui::project_diff::ProjectDiff; - - // Find the ProjectDiff item - let Some(project_diff) = workspace.items_of_type::(cx).next() else { - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - "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)> = - 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)> - // Convert to Vec for processing - let comments: Vec = all_comments - .into_iter() - .flat_map(|(_, comments)| comments) - .collect(); - - if comments.is_empty() { - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - "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::(cx) else { - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - "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::(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::(cx) else { - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - "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::(), - "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, - > = 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>, diff --git a/crates/agent_ui/src/user_slash_command.rs b/crates/agent_ui/src/user_slash_command.rs deleted file mode 100644 index 15df0e410a0faf4cb93ec0b375f289d1601c5fd0..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/user_slash_command.rs +++ /dev/null @@ -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 { - 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, - /// Errors encountered while loading commands - pub errors: Vec, -} - -/// 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, - /// The template content from the file - pub template: Arc, - /// The namespace (subdirectory path, if any), used for description display - pub namespace: Option>, - /// 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, - commands: HashMap, - errors: Vec, - worktree_roots: Vec, - _watch_task: Option>, -} - -impl EventEmitter for SlashCommandRegistry {} - -#[allow(dead_code)] -impl SlashCommandRegistry { - /// Creates a new registry and starts loading commands. - pub fn new(fs: Arc, worktree_roots: Vec, cx: &mut Context) -> 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 { - &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, cx: &mut Context) { - 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) { - 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) { - 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, - worktree_roots: &[PathBuf], -) -> CommandLoadResult { - let mut result = CommandLoadResult::default(); - let mut seen_commands: HashMap = 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, - 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, - 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, - base_path: &Path, - file_path: &Path, - scope: CommandScope, -) -> Result> { - 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 { - 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, - errors: &mut Vec, - server_command_names: &HashSet, -) { - 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, - errors: &mut Vec, - server_command_names: &HashSet, -) { - 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> { - 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>> { - 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::() { - 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 { - 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 { - 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, -) -> Result> { - 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> = 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> = 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> = 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> = 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> = 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> = 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> = (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> = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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") - ); - } -} diff --git a/crates/agent_ui_v2/src/agent_thread_pane.rs b/crates/agent_ui_v2/src/agent_thread_pane.rs index d04d636b775bb981cc90c7611b20ec062235bad6..8959d45721981aa9955cf79d9330ce38e9255ba4 100644 --- a/crates/agent_ui_v2/src/agent_thread_pane.rs +++ b/crates/agent_ui_v2/src/agent_thread_pane.rs @@ -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; } diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index 3d4221d4816461f546d167d0cdbe24d1647541ba..ebe67c43ab7232a6c8ed57bb0a982706490c451e 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -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 { diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index b092da8027b5f023e634033b2a2498dba2adcceb..503bd6e92db511ddb65df71fd91960c0f69b2b71 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -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"))?;