#![allow(dead_code)] //! Implementation of "AI blocks" used to render AI queries and outputs in the blocklist. pub mod cli; pub mod cli_controller; pub mod compact_agent_input; pub(super) mod find; pub mod keyboard_navigable_buttons; pub mod model; pub mod number_shortcut_buttons; pub mod numbered_button; pub mod pending_user_query_block; pub mod secret_redaction; pub mod status_bar; pub mod toggleable_items; pub mod view_impl; use std::cell::OnceCell; use std::cmp::Ordering; use std::collections::{HashMap, HashSet}; use std::ops::Range; use std::path::Path; #[cfg(feature = "local_fs")] use std::path::PathBuf; use std::rc::Rc; use std::sync::Arc; use ai::agent::action::{AskUserQuestionItem, InsertReviewComment, RunAgentsRequest}; use base64::Engine as _; use chrono::Duration; use cli_controller::{CLISubagentController, CLISubagentEvent}; use find::FindState; use galaxy_agent_core::RuntimeActivityStatus; use galaxy_core::features::FeatureFlag; use galaxy_core::ui::theme::color::internal_colors; use galaxy_core::ui::theme::Fill; use indexmap::IndexMap; use itertools::Itertools; use model::AIBlockOutputStatus; use parking_lot::{FairMutex, Mutex, RwLock}; use pathfinder_color::ColorU; use pathfinder_geometry::vector::vec2f; pub use pending_user_query_block::{PendingUserQueryBlock, PendingUserQueryBlockEvent}; #[cfg(not(target_family = "wasm"))] use repo_metadata::repositories::DetectedRepositories; use secret_redaction::*; use serde::Serialize; use settings::Setting as _; use warp_editor::content::buffer::InitialBufferState; #[cfg(feature = "local_fs")] use warp_editor::content::edit::resolve_asset_source_relative_to_directory; use warp_editor::render::element::VerticalExpansionBehavior; use warp_util::local_or_remote_path::LocalOrRemotePath; use warp_util::path::ShellFamily; use warpui::assets::asset_cache::AssetCache; use warpui::clipboard::ClipboardContent; use warpui::elements::{ get_rich_content_position_id, ClippedScrollStateHandle, MainAxisAlignment, MainAxisSize, MouseStateHandle, SecretRange, SelectionBound, SelectionHandle, TableStateHandle, }; use warpui::image_cache::ImageType; use warpui::keymap::FixedBinding; use warpui::r#async::{SpawnedFutureHandle, Timer}; use warpui::text::SelectionType; use warpui::ui_components::button::{ButtonVariant, TextAndIcon, TextAndIconAlignment}; use warpui::ui_components::components::{UiComponent, UiComponentStyles}; use warpui::ui_components::radio_buttons::RadioButtonStateHandle; use warpui::{ AppContext, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, WeakViewHandle, WindowId, }; #[cfg(feature = "agent_mode_debug")] use self::code_diff_view::FileDiff; use self::model::{AIBlockModel, AIBlockModelHelper}; use super::action_model::{ AIActionStatus, BlocklistAIActionEvent, RequestFileEditsFormatKind, StartAgentExecutorEvent, }; use super::code_block::CodeSnippetButtonHandles; use super::controller::ClientIdentifiers; use super::inline_action::code_diff_view::{ CodeDiffState, CodeDiffView, CodeDiffViewAction, CodeDiffViewEvent, }; use super::inline_action::requested_action::{CTRL_C_KEYSTROKE, ENTER_KEYSTROKE}; use super::inline_action::requested_command_attribution::is_command_copied_from_document; use super::permissions::is_agent_mode_autonomy_allowed; use super::suggested_agent_mode_workflow_modal::SuggestedAgentModeWorkflowAndId; use super::suggested_rule_modal::SuggestedRuleAndId; use super::telemetry_banner::should_collect_ai_ugc_telemetry; use super::{ BlocklistAIActionModel, BlocklistAIController, BlocklistAIHistoryEvent, BlocklistAIHistoryModel, BlocklistAIPermissions, ResponseStreamId, }; use crate::ai::agent::conversation::AIConversationId; use crate::ai::agent::redaction::redact_secrets; use crate::ai::agent::telemetry::ForTelemetry as _; use crate::ai::agent::{ AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, AIAgentAttachment, AIAgentCitation, AIAgentContext, AIAgentInput, AIAgentOutput, AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentTextSection, AIIdentifiers, CancellationReason, CreateDocumentsRequest, CreateDocumentsResult, DocumentToCreate, EditDocumentsResult, MessageId, PassiveSuggestionTrigger, ProgrammingLanguage, RenderableAIError, RequestCommandOutputResult, RequestFileEditsResult, SearchCodebaseResult, ServerOutputId, SubagentCall, SubagentType, SuggestPromptRequest, SuggestPromptResult, SuggestedLoggingId, SummarizationType, TodoOperation, }; use crate::ai::agent_conversations_model::{AgentConversationsModel, AgentConversationsModelEvent}; use crate::ai::ai_document_view::DEFAULT_PLANNING_DOCUMENT_TITLE; use crate::ai::ambient_agents::AmbientAgentTaskId; use crate::ai::blocklist::action_model::NewConversationDecision; use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewEntryOrigin}; use crate::ai::blocklist::block::keyboard_navigable_buttons::{ KeyboardNavigableButtonBuilder, KeyboardNavigableButtons, }; use crate::ai::blocklist::context_model::AttachmentType; use crate::ai::blocklist::inline_action::ask_user_question_view::{ self, AskUserQuestionView, AskUserQuestionViewEvent, }; use crate::ai::blocklist::inline_action::aws_bedrock_credentials_error::{ AwsBedrockCredentialsErrorEvent, AwsBedrockCredentialsErrorView, }; use crate::ai::blocklist::inline_action::code_diff_view; use crate::ai::blocklist::inline_action::code_diff_view::convert_file_edits_to_file_diffs; use crate::ai::blocklist::inline_action::requested_command::{ self, RequestedActionViewType, RequestedCommand, RequestedCommandView, RequestedCommandViewEvent, }; use crate::ai::blocklist::inline_action::run_agents_card_view::{ self, RunAgentsCardView, RunAgentsCardViewEvent, }; use crate::ai::blocklist::inline_action::search_codebase::{ SearchCodebaseView, SearchCodebaseViewEvent, }; use crate::ai::blocklist::inline_action::suggested_unit_tests::{ SuggestedUnitTestsEvent, SuggestedUnitTestsView, }; use crate::ai::blocklist::inline_action::summarization::SummarizationView; use crate::ai::blocklist::inline_action::web_fetch::WebFetchView; use crate::ai::blocklist::inline_action::web_search::WebSearchView; use crate::ai::blocklist::permissions::{ CommandExecutionPermission, CommandExecutionPermissionDeniedReason, }; use crate::ai::blocklist::suggestion_chip_view::{SuggestedChipViewEvent, SuggestionChipView}; use crate::ai::blocklist::{ BlocklistAIContextEvent, BlocklistAIContextModel, SuggestionDismissButtonTheme, }; use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentModel, AIDocumentVersion}; use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; use crate::ai::facts::{AIFact, AIMemory, CloudAIFactModel}; use crate::ai::get_relevant_files::controller::{ GetRelevantFilesController, GetRelevantFilesControllerEvent, }; #[cfg(feature = "local_fs")] use crate::ai::skills::SkillOpenOrigin; use crate::ai::skills::{SkillManager, SkillTelemetryEvent}; use crate::ai::{AIRequestUsageModel, AIRequestUsageModelEvent}; use crate::auth::{AuthStateProvider, UserUid}; use crate::cloud_object::model::generic_string_model::GenericStringObjectId; use crate::cloud_object::model::persistence::CloudModel; use crate::code::editor::comment_editor::create_readonly_comment_markdown_editor; use crate::code::editor::view::{CodeEditorEvent, CodeEditorRenderOptions, CodeEditorView}; use crate::code::editor_management::CodeSource; use crate::code_review::comment_rendering::{CommentViewCard, HeaderClickHandler}; use crate::code_review::comments::{ attach_pending_imported_comments, convert_insert_review_comments, AttachedReviewComment, CommentId, CommentOrigin, }; use crate::code_review::telemetry_event::CodeReviewPaneEntrypoint; use crate::code_review::CodeReviewTelemetryEvent; use crate::editor::InteractionState; use crate::notebooks::editor::model::FileLinkResolutionContext; use crate::notebooks::editor::view::{EditorViewEvent, RichTextEditorView}; use crate::server::ids::SyncId; use crate::server::telemetry::{ AgentModeRewindEntrypoint, AutonomySettingToggleSource, InteractionSource, TelemetryEvent, }; use crate::settings::{ AISettings, AISettingsChangedEvent, AgentModeCodingPermissionsType, FontSettings, InputModeSettings, InputModeSettingsChangedEvent, InputSettings, OrchestrationMessageDisplayMode, }; use crate::settings_view::SettingsSection; use crate::terminal::find::TerminalFindModel; use crate::terminal::model::secrets::RichContentSecretTooltipInfo; use crate::terminal::model::session::active_session::{ActiveSession, ActiveSessionEvent}; use crate::terminal::model::BlockId; use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher}; use crate::terminal::safe_mode_settings::{ get_secret_obfuscation_mode, SafeModeSettings, SafeModeSettingsChangedEvent, }; use crate::terminal::view::ambient_agent::{AmbientAgentViewModel, AmbientAgentViewModelEvent}; use crate::terminal::view::{ CodeDiffAction, RichContentLink, RichContentLinkTooltipInfo, TerminalAction, }; use crate::terminal::{ShellLaunchData, TerminalModel, TerminalView}; use crate::ui_components::icons::Icon; use crate::util::link_detection::*; #[cfg(feature = "local_fs")] use crate::util::openable_file_type::{is_supported_image_file, FileTarget}; use crate::view_components::action_button::{ ActionButton, ActionButtonTheme, ButtonSize, KeystrokeSource, NakedTheme, PrimaryTheme, SecondaryTheme, }; use crate::view_components::compactible_action_button::CompactibleActionButton; use crate::view_components::find::FindEvent; use crate::view_components::DismissibleToast; use crate::workspace::{ForkAIConversationParams, ForkedConversationDestination, WorkspaceAction}; use crate::workspaces::user_profiles::{UserProfileWithUID, UserProfiles}; use crate::workspaces::user_workspaces::UserWorkspaces; use crate::{ report_error, report_if_error, send_telemetry_from_ctx, AIAgentTodoList, Appearance, FileEdit, LLMPreferences, PrivacySettings, ToastStack, }; /// The default display name used for the user if they have no associated display name. const DEFAULT_USER_DISPLAY_NAME: &str = "User"; const HAS_PENDING_ACTION: &str = "HasPendingAction"; const DISPATCHED_REQUESTED_EDIT_KEYMAP_CONTEXT: &str = "PendingAIRequestedEdits"; const AUTO_EXPAND_REQUESTED_COMMAND_DELAY: std::time::Duration = std::time::Duration::from_millis(3000); pub const RICH_CONTENT_SECRET_FIRST_CHAR_POSITION_ID: &str = "ai_block:rich_content_secret_first_char_position"; #[derive(Clone, Debug, PartialEq, Eq)] struct UserAvatarInfo { display_name: String, profile_image_path: Option, } fn current_user_avatar_info(app: &AppContext) -> UserAvatarInfo { let auth_state = AuthStateProvider::as_ref(app).get().clone(); UserAvatarInfo { display_name: auth_state .username_for_display() .unwrap_or_else(|| DEFAULT_USER_DISPLAY_NAME.to_owned()), profile_image_path: auth_state.user_photo_url(), } } fn non_empty_photo_url(photo_url: &str) -> Option { (!photo_url.is_empty()).then(|| photo_url.to_string()) } fn display_name_for_user_profile(profile: &UserProfileWithUID) -> String { profile .display_name .as_ref() .filter(|name| !name.is_empty()) .or_else(|| (!profile.email.is_empty()).then_some(&profile.email)) .cloned() .unwrap_or_else(|| profile.firebase_uid.to_string()) } fn user_avatar_info_for_conversation_creator( creator: Option<&UserProfileWithUID>, creator_uid: Option<&str>, fallback: UserAvatarInfo, app: &AppContext, ) -> UserAvatarInfo { if let Some(creator) = creator { return UserAvatarInfo { display_name: display_name_for_user_profile(creator), profile_image_path: non_empty_photo_url(&creator.photo_url), }; } if let Some(creator_uid) = creator_uid { if let Some(profile) = UserProfiles::as_ref(app).profile_for_uid(UserUid::new(creator_uid)) { return UserAvatarInfo { display_name: profile.displayable_identifier(), profile_image_path: non_empty_photo_url(&profile.photo_url), }; } } fallback } fn user_avatar_info_for_ai_block( model: &dyn AIBlockModel, app: &AppContext, ) -> UserAvatarInfo { let fallback = current_user_avatar_info(app); let server_metadata = model .conversation(app) .and_then(|conversation| conversation.server_metadata()); user_avatar_info_for_conversation_creator( server_metadata.and_then(|metadata| metadata.creator.as_ref()), server_metadata.and_then(|metadata| metadata.metadata.creator_uid.as_deref()), fallback, app, ) } pub fn init(app: &mut AppContext) { use galaxyui::keymap::macros::*; app.register_fixed_bindings([ FixedBinding::new( "enter", AIBlockAction::ExecuteNextPendingAction, id!(AIBlock::ui_name()) & id!(HAS_PENDING_ACTION), ), FixedBinding::new( "numpadenter", AIBlockAction::ExecuteNextPendingAction, id!(AIBlock::ui_name()) & id!(HAS_PENDING_ACTION), ), ]); ask_user_question_view::init(app); code_diff_view::init(app); requested_command::init(app); run_agents_card_view::init(app); cli::init(app); } #[cfg(feature = "local_fs")] impl AIBlock { fn detected_file_path_target_override(&self, absolute_path: &Path) -> Option { is_supported_image_file(absolute_path).then_some(FileTarget::SystemGeneric) } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FinishReason { Complete, /// The block was finished with an error. Error, /// The block was finished with manual cancellation. Cancelled, /// The block was finished due to the requested command being cancelled via ctrl-c. CancelledDuringRequestedCommandExecution, } #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum TextLocation { Output { section_index: usize, /// Note that this does ***not*** correspond to the frame index of a text frame after layout; /// Instead, it represents the line index of the `FormattedTextLine` after markdown parsing. line_index: usize, }, Query { input_index: usize, }, Action { action_index: usize, line_index: usize, }, } #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum AIBlockResponseRating { Positive, Negative, } impl AIBlockResponseRating { pub fn name(&self) -> &'static str { match self { AIBlockResponseRating::Positive => "positive", AIBlockResponseRating::Negative => "negative", } } } #[derive(Clone)] struct ActionButtons { run_button: CompactibleActionButton, cancel_button: CompactibleActionButton, } /// Like `SecondaryTheme` but with grey text instead of white. struct RewindButtonTheme; impl ActionButtonTheme for RewindButtonTheme { fn background(&self, hovered: bool, appearance: &Appearance) -> Option { if hovered { Some(appearance.theme().surface_3()) } else { None } } fn text_color( &self, _hovered: bool, _background: Option, appearance: &Appearance, ) -> ColorU { appearance .theme() .sub_text_color(appearance.theme().surface_2()) .into_solid() } fn border(&self, appearance: &Appearance) -> Option { Some(internal_colors::neutral_4(appearance.theme())) } } #[derive(Clone, Default)] pub(super) struct TableSectionHandles { pub scroll_handle: ClippedScrollStateHandle, pub state_handle: TableStateHandle, } #[derive(Default)] pub(super) struct AIBlockStateHandles { normal_response_code_snippet_buttons: Vec, table_section_handles: Vec, /// Per-image `MouseStateHandle` for hover tooltips on rendered markdown /// images. These must persist across frames — `tool_tip_on_element` /// wraps the image in a `Hoverable` whose `is_hovered()` state would /// reset every frame if we built the handle inline. image_section_tooltip_handles: Vec, /// Only applies to text selections made at the `AIBlock` level. Child views of the `AIBlock` /// are responsible for managing their own text selection states. selection_handle: SelectionHandle, /// Mouse state handle for interacting with the attached blocks. attached_blocks_chip_state_handle: MouseStateHandle, /// Mouse state handle for the continue conversation button continue_conversation_handle: MouseStateHandle, /// Mouse state handle for the resume conversation button resume_conversation_handle: MouseStateHandle, /// Mouse state handle for the fork conversation button fork_conversation_handle: MouseStateHandle, /// Mouse state handles per citation. /// A given citation should only appear once per block. footer_citation_chip_handles: HashMap, orchestration_navigation_card_handles: HashMap, /// Persistent mouse-state handles per received-message transcript row, /// used by the clickable sender avatar. pub(super) transcript_avatar_handles: HashMap, references_section_collapsible_handle: MouseStateHandle, autoread_files_speedbump_checkbox_handle: MouseStateHandle, codebase_search_speedbump_option_handles: Vec, codebase_search_speedbump_radio_button_handle: RadioButtonStateHandle, manage_autonomy_settings_link_handle: MouseStateHandle, ask_user_question_speedbump_settings_link_handle: MouseStateHandle, /// Mouse state handles for rating the AI block. thumbs_up_handle: MouseStateHandle, thumbs_down_handle: MouseStateHandle, /// Mouse state handle for the overflow menu button overflow_menu_handle: MouseStateHandle, menu_accept_button_handle: MouseStateHandle, menu_reject_button_handle: MouseStateHandle, /// Mouse state handle for the debug ID copy button debug_copy_button_handle: MouseStateHandle, /// Mouse state handle for the submit issue button submit_issue_button_handle: MouseStateHandle, /// Mouse state handle for the invalid API key button invalid_api_key_button_handle: MouseStateHandle, /// Mouse state handle for AI document created block ai_document_handle: MouseStateHandle, /// Per-action mouse state handles for the 'open skill' button shown on /// ReadSkill and ReadFiles action banners. Keyed by action id so that /// multiple skill banners in the same block don't share hover/click state. skill_button_handles: HashMap, usage_button_handle: MouseStateHandle, subagent_panel_states: HashMap, } #[derive(Default, Clone, Debug)] pub struct DirectoryContext { pub pwd: Option, pub home_dir: Option, } /// Convenience wrapper around a [`CodeDiffView`]. /// TODO(Simon): For consistency with other inline actions, let's move this to code_diff_view.rs. #[derive(Debug)] pub(crate) struct RequestedEdit { view: ViewHandle, } impl RequestedEdit { fn new(view: ViewHandle) -> Self { Self { view } } } #[derive(Clone, Debug, Default)] pub enum AutonomySettingSpeedbump { /// There's no speedbump to show. #[default] None, /// Show a checkbox-based speedbump for auto-executing read-only commands. ShouldShowForAutoexecutingReadonlyCommands { /// Which action this corresponds to. action_id: AIAgentActionId, /// Whether the setting in the speedbump is checked or not. checked: bool, /// Whether or not the speedbump is actually shown. /// /// Set at render-time. shown: Arc>, }, /// Show a checkbox-based speedbump for file access. ShouldShowForFileAccess { /// Which action this corresponds to. action_id: AIAgentActionId, /// Whether the setting in the speedbump is checked or not. checked: bool, /// Whether or not the speedbump is actually shown. /// /// Set at render-time. shown: Arc>, }, /// Show a radio-button-based speedbump for file access during codebase search. ShouldShowForCodebaseSearchFileAccess { /// Which action this corresponds to. action_id: AIAgentActionId, /// Which radio option is selected. /// 0 => Always allow file access. /// 1 => Allowlist this repo for file access. selected_option: Option, /// Whether or not the speedbump is actually shown. /// /// Set at render-time. shown: Arc>, }, /// Show an informational footer for execution profile command autoexecution settings. ShouldShowForProfileCommandAutoexecution { /// Which action this corresponds to. action_id: AIAgentActionId, /// Whether or not the speedbump is actually shown. /// /// Set at render-time. shown: Arc>, }, /// Show a one-shot dropdown-based speedbump for ask-user-question permission. ShouldShowForAskUserQuestion { /// Which action this corresponds to. action_id: AIAgentActionId, /// Whether or not the speedbump is actually shown. /// /// Set at render-time. shown: Arc>, }, } /// State for the todo list preview element in the AI block. /// Displayed whenever an agent creates a todo list. struct TodoListElementState { header_toggle_mouse_state: MouseStateHandle, is_expanded: bool, } impl Default for TodoListElementState { fn default() -> Self { Self { header_toggle_mouse_state: MouseStateHandle::new(Default::default()), is_expanded: true, } } } pub(super) struct ImportedCommentElementState { pub(super) open_in_github_button: Option>, pub(super) open_in_code_review_button: ViewHandle, pub(super) chevron_button: ViewHandle, pub(super) header_click_handler: HeaderClickHandler, } impl ImportedCommentElementState { fn new( action_id: AIAgentActionId, comment_index: usize, html_url: Option, ctx: &mut ViewContext, ) -> Self { let open_in_github_button = html_url.map(|url| { ctx.add_typed_action_view(move |_| { ActionButton::new("", NakedTheme) .with_icon(Icon::Github) .with_size(ButtonSize::Small) .with_tooltip("Open in GitHub") .on_click({ let url = url.clone(); move |ctx| { ctx.dispatch_typed_action(AIBlockAction::OpenCommentInGitHub { url: url.clone(), }); } }) }) }); let action_id_for_open_button = action_id.clone(); let open_in_code_review_button = ctx.add_typed_action_view(move |_| { ActionButton::new("Open in code review", SecondaryTheme) .with_size(ButtonSize::Small) .on_click(move |ctx| { ctx.dispatch_typed_action(AIBlockAction::OpenImportedCommentInCodeReview { action_id: action_id_for_open_button.clone(), comment_index, }); }) }); let chevron_button = ctx.add_view(|_| { ActionButton::new("", NakedTheme) .with_icon(Icon::ChevronDown) .with_size(ButtonSize::Small) }); let header_click_handler = HeaderClickHandler { mouse_state: MouseStateHandle::default(), on_click: Rc::new(move |ctx| { ctx.dispatch_typed_action(AIBlockAction::ToggleImportedCommentCollapsed { action_id: action_id.clone(), comment_index, }); }), }; Self { open_in_github_button, open_in_code_review_button, chevron_button, header_click_handler, } } } pub(super) struct ImportedCommentGroup { repo_path: LocalOrRemotePath, base_branch: Option, cards: Vec, element_states: Vec, } impl ImportedCommentGroup { fn new( repo_path: LocalOrRemotePath, base_branch: Option, cards: Vec, element_states: Vec, ) -> Self { Self { repo_path, base_branch, cards, element_states, } } fn card_mut(&mut self, comment_index: usize) -> Option<&mut CommentViewCard> { self.cards.get_mut(comment_index) } fn set_buttons_disabled(&self, should_disable: bool, ctx: &mut ViewContext) { for state in &self.element_states { set_imported_comment_button_disabled( &state.open_in_code_review_button, should_disable, Some(&self.repo_path), ctx, ); } } } pub(super) struct CommentElementState { pub(super) header_toggle_mouse_state: MouseStateHandle, pub(super) maximize_minimize_button: ViewHandle, pub(super) rich_text_editor: ViewHandle, pub(super) is_expanded: bool, } impl CommentElementState { fn new(comment_id: CommentId, content: &str, ctx: &mut ViewContext) -> Self { let action_button = ctx.add_view(move |_| { ActionButton::new("", NakedTheme) .with_icon(Icon::Maximize) .on_click(move |ctx| { ctx.dispatch_typed_action(AIBlockAction::CommentExpanded { id: comment_id }); }) .with_size(ButtonSize::Small) }); let rich_text_editor = create_readonly_comment_markdown_editor( content, true, /* disable_scrolling */ None, /* allow comments to expand to full width */ ctx, ); CommentElementState { header_toggle_mouse_state: MouseStateHandle::default(), maximize_minimize_button: action_button, rich_text_editor, is_expanded: false, } } } /// Expansion state for collapsible blocks with streamed content. pub enum CollapsibleExpansionState { Expanded { /// Whether streaming/generation is complete is_finished: bool, /// Whether to auto-scroll to bottom during streaming. /// Disabled when user manually scrolls to read previous content. scroll_pinned_to_bottom: bool, }, Collapsed, } /// State for collapsible elements with scroll support. pub struct CollapsibleElementState { pub expansion_toggle_mouse_state: MouseStateHandle, pub expansion_state: CollapsibleExpansionState, pub scroll_state: ClippedScrollStateHandle, last_known_is_finished: bool, user_toggled_while_streaming: bool, } impl Default for CollapsibleElementState { fn default() -> Self { Self { expansion_toggle_mouse_state: MouseStateHandle::new(Default::default()), expansion_state: CollapsibleExpansionState::Expanded { is_finished: false, scroll_pinned_to_bottom: true, }, scroll_state: ClippedScrollStateHandle::new(), last_known_is_finished: false, user_toggled_while_streaming: false, } } } impl CollapsibleElementState { fn collapsed() -> Self { Self { expansion_state: CollapsibleExpansionState::Collapsed, ..Default::default() } } fn expand(&mut self) { self.expansion_state = CollapsibleExpansionState::Expanded { is_finished: self.last_known_is_finished, scroll_pinned_to_bottom: true, }; } /// Mirrors the latest streamed finished state into the cached state and any expanded UI. fn sync_finished_state(&mut self, is_finished: bool) { self.last_known_is_finished = is_finished; if let CollapsibleExpansionState::Expanded { is_finished: expanded_is_finished, .. } = &mut self.expansion_state { *expanded_is_finished = is_finished; } } fn finish_reasoning(&mut self, app: &AppContext) { let should_auto_collapse = self.should_auto_collapse_on_finish(); let thinking_mode = AISettings::as_ref(app).thinking_display_mode; self.sync_finished_state(true); if !thinking_mode.should_keep_expanded() && should_auto_collapse { self.expansion_state = CollapsibleExpansionState::Collapsed; } else if let CollapsibleExpansionState::Expanded { scroll_pinned_to_bottom, .. } = &mut self.expansion_state { *scroll_pinned_to_bottom = false; } } fn finish_summarization(&mut self) { let should_auto_collapse = matches!( self.expansion_state, CollapsibleExpansionState::Expanded { is_finished: false, .. } ); self.sync_finished_state(true); if should_auto_collapse { self.expansion_state = CollapsibleExpansionState::Collapsed; } } fn sync_runtime_activity(&mut self, is_streaming: bool, is_finished: bool, has_output: bool) { if is_streaming && has_output && !self.user_toggled_while_streaming && matches!(self.expansion_state, CollapsibleExpansionState::Collapsed) { self.expand(); } self.sync_finished_state(is_finished); if is_finished { if let CollapsibleExpansionState::Expanded { scroll_pinned_to_bottom, .. } = &mut self.expansion_state { *scroll_pinned_to_bottom = false; } } } /// Applies orchestration message display behavior after streaming finishes. fn finish_orchestration_message(&mut self, display_mode: OrchestrationMessageDisplayMode) { let should_auto_collapse = self.should_auto_collapse_on_finish(); self.sync_finished_state(true); if display_mode.should_collapse_agent_message_body_on_finish() && should_auto_collapse { self.expansion_state = CollapsibleExpansionState::Collapsed; } else if let CollapsibleExpansionState::Expanded { scroll_pinned_to_bottom, .. } = &mut self.expansion_state { *scroll_pinned_to_bottom = false; } } fn toggle_expansion(&mut self) { if !self.last_known_is_finished { self.user_toggled_while_streaming = true; } if matches!( self.expansion_state, CollapsibleExpansionState::Expanded { .. } ) { self.expansion_state = CollapsibleExpansionState::Collapsed; } else { self.expand(); } } fn should_auto_collapse_on_finish(&self) -> bool { !self.user_toggled_while_streaming && matches!( self.expansion_state, CollapsibleExpansionState::Expanded { is_finished: false, scroll_pinned_to_bottom: true } ) } } const RECEIVED_MESSAGE_COLLAPSIBLE_ID_PREFIX: &str = "received-message:"; pub(crate) fn received_message_collapsible_id(message_id: &str) -> MessageId { MessageId::new(format!( "{RECEIVED_MESSAGE_COLLAPSIBLE_ID_PREFIX}{message_id}" )) } fn default_collapsible_state_for_orchestration_action( action: &AIAgentActionType, display_mode: OrchestrationMessageDisplayMode, ) -> Option { match action { AIAgentActionType::StartAgent { .. } => Some(CollapsibleElementState::default()), AIAgentActionType::SendMessageToAgent { .. } => { Some(default_orchestration_collapsible_state( display_mode.should_expand_agent_message_body(), )) } _ => None, } } fn default_collapsible_state_for_orchestration_message( display_mode: OrchestrationMessageDisplayMode, ) -> CollapsibleElementState { default_orchestration_collapsible_state(display_mode.should_expand_agent_message_body()) } fn default_orchestration_collapsible_state(expanded: bool) -> CollapsibleElementState { if expanded { CollapsibleElementState::default() } else { CollapsibleElementState::collapsed() } } fn history_event_affects_conversation( event: &BlocklistAIHistoryEvent, conversation_id: AIConversationId, ) -> bool { match event { BlocklistAIHistoryEvent::StartedNewConversation { new_conversation_id, .. } => *new_conversation_id == conversation_id, BlocklistAIHistoryEvent::CreatedSubtask { conversation_id: event_conversation_id, .. } | BlocklistAIHistoryEvent::AppendedExchange { conversation_id: event_conversation_id, .. } | BlocklistAIHistoryEvent::UpdatedStreamingExchange { conversation_id: event_conversation_id, .. } | BlocklistAIHistoryEvent::UpdatedConversationStatus { conversation_id: event_conversation_id, .. } | BlocklistAIHistoryEvent::SetActiveConversation { conversation_id: event_conversation_id, .. } | BlocklistAIHistoryEvent::ClearedActiveConversation { conversation_id: event_conversation_id, .. } | BlocklistAIHistoryEvent::RemoveConversation { conversation_id: event_conversation_id, .. } | BlocklistAIHistoryEvent::DeletedConversation { conversation_id: event_conversation_id, .. } | BlocklistAIHistoryEvent::UpdatedConversationMetadata { conversation_id: event_conversation_id, .. } | BlocklistAIHistoryEvent::UpdatedConversationTitle { conversation_id: event_conversation_id, .. } | BlocklistAIHistoryEvent::UpdatedConversationArtifacts { conversation_id: event_conversation_id, .. } | BlocklistAIHistoryEvent::ConversationServerTokenAssigned { conversation_id: event_conversation_id, .. } | BlocklistAIHistoryEvent::ConversationTransferredBetweenTerminalSurfaces { conversation_id: event_conversation_id, .. } | BlocklistAIHistoryEvent::NewConversationRequestComplete { conversation_id: event_conversation_id, .. } | BlocklistAIHistoryEvent::OrchestrationConfigUpdated { conversation_id: event_conversation_id, .. } | BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { conversation_id: event_conversation_id, } | BlocklistAIHistoryEvent::LocalSharedSessionEstablished { conversation_id: event_conversation_id, .. } => *event_conversation_id == conversation_id, BlocklistAIHistoryEvent::ReassignedExchange { new_conversation_id, .. } => *new_conversation_id == conversation_id, BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface { active_conversation_id, cleared_conversation_ids, .. } => { *active_conversation_id == Some(conversation_id) || cleared_conversation_ids.contains(&conversation_id) } BlocklistAIHistoryEvent::SplitConversation { old_conversation_id, new_conversation_id, .. } => *old_conversation_id == conversation_id || *new_conversation_id == conversation_id, BlocklistAIHistoryEvent::RestoredConversations { conversation_ids, .. } => conversation_ids.contains(&conversation_id), BlocklistAIHistoryEvent::UpgradedTask { .. } | BlocklistAIHistoryEvent::UpdatedTodoList { .. } | BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. } => false, } } pub struct AIBlock { model: Rc>, terminal_model: Arc>, client_ids: ClientIdentifiers, profile_image_path: Option, user_display_name: String, /// Only applies to text selections made at the `AIBlock` level. Child views of the `AIBlock` /// are responsible for managing their own text selection states. We need this in an RwLock so /// that the SelectableArea can modify this synchronously when a selection ends. This is necessary /// so the terminal view can read the updated value when the selection ends in the copy-on-select case. selected_text: Arc>>, state_handles: AIBlockStateHandles, controller: ModelHandle, active_session: ModelHandle, terminal_view_id: EntityId, window_id: galaxyui::WindowId, /// The current working directory at the time the AI block was created. Note that this /// is different from `directory_context`, which represents the directory-related contexts /// to render the block header. `directory_context` might be empty if the input type does not /// require context (e.g. ActionResult). current_working_directory: Option, /// The shell launch data at the time the AI block was created. shell_launch_data: Option, action_model: ModelHandle, context_model: ModelHandle, /// The IDs of requested blocking actions rendered in this block. requested_action_ids: HashSet, /// Map from a requested command action ID to its view handle and status. requested_commands: HashMap, /// Map from a requested MCP tool call action ID to its view handle and status. requested_mcp_tools: HashMap, /// Map from a requested edit action ID to its view handle and status. /// Uses IndexMap to preserve insertion order for correct revert ordering. requested_edits: IndexMap, /// Map from a search codebase action ID to its view handle and status. search_codebase_view: HashMap>, /// Map from web search message IDs to their view handles. web_search_views: HashMap>, /// Map from web fetch message IDs to their view handles. web_fetch_views: HashMap>, /// Map from summarization message IDs to their view handles. summarization_views: HashMap>, /// Map from todo list IDs to their states. todo_list_states: HashMap, comment_states: HashMap, /// Map from collapsible block message IDs (reasoning or summarization) to their states. collapsible_block_states: HashMap, /// Map from suggested prompt action ID to its view handle and status. unit_tests_suggestions: HashMap>, /// Task to auto expand an executed requested command or requested action after it has /// been running for a while. This applies to both the [`RequestedCommandView`] and /// non-[`View`] inline actions. auto_expand_requested_command_timer_handle: Option, time_to_first_token: OnceCell, time_to_last_token: Option, /// The number of blocks that were attached as context to this AI block's query. num_attached_context_blocks: usize, /// Whether selected text was attached as context to this AI block's query. has_attached_context_selected_text: bool, /// This is only set to Some if no new content is expected to be received and all /// requested actions and requested commands are completed or cancelled. finish_reason: Option, directory_context: DirectoryContext, view_id: EntityId, detected_links_state: DetectedLinksState, find_state: FindState, find_model: ModelHandle, /// The CodeEditorViews associated with this block. code_editor_views: Vec, secret_redaction_state: SecretRedactionState, /// Whether or not the references section at the bottom of the block is toggled open. is_references_section_open: bool, /// A speedbump, or notice, about an autonomy setting. /// /// Assumes we only have 1 action per AI block. autonomy_setting_speedbump: AutonomySettingSpeedbump, /// The suggested rules to render in the block. suggested_rules: Vec>, /// The suggested agent mode workflows to render in the block. suggested_agent_mode_workflow: Option>, manage_rules_button: ViewHandle, action_buttons: HashMap, /// A user menu presenting an accept and reject choice. /// /// This UI is used for the new conversation suggestion multi-select. keyboard_navigable_buttons: Option>, /// The thumbs up/down rating of the AI block response. response_rating: OnceCell, /// The number of requests that have been refunded. /// Right now, this happens when a user thumbs down a response. request_refunded_count: Option, /// Requested commands that were auto-expanded, /// and should thus be auto-collapsed when the block is finished. requested_commands_to_auto_collapse: HashSet, review_changes_button: ViewHandle, open_all_comments_button: ViewHandle, dismiss_suggestion_button: ViewHandle, disable_rule_suggestions_button: ViewHandle, /// Rewind button to revert to before this block. rewind_button: ViewHandle, /// Per-action button components for "View screenshot" buttons on UseComputer actions. view_screenshot_buttons: HashMap, /// Stores the last command that was right-clicked by a child component. /// When set, CopyCommand will copy this specific command instead of all commands. last_right_clicked_command: Option, /// Controller for reading/modifying `AgentView` state for this terminal pane (e.g. if there is /// an active agent view or not, which affects whether or not this block should be hidden). /// /// Only used when `FeatureFlag::AgentView` is enabled. agent_view_controller: ModelHandle, ambient_agent_view_model: Option>, /// View for AWS Bedrock credentials error, created lazily when the error occurs. aws_bedrock_credentials_error_view: Option>, imported_comments: HashMap, has_imported_comments: bool, /// Per-action `RunAgentsCardView`, lazily created. run_agents_card_views: HashMap>, /// Handle for the background link detection task, kept so we can abort a previous /// detection when a new one is spawned (e.g. on shell data change). link_detection_handle: Option, /// Cache of resolved code block file paths, keyed by the original path from the AI output. /// Populated by the background file path detection task so that `render_code_output_section` /// does not need to call `fs::metadata` on every render. #[cfg(feature = "local_fs")] resolved_code_block_paths: HashMap>, #[cfg(feature = "local_fs")] resolved_blocklist_image_sources: view_impl::common::ResolvedBlocklistImageSources, terminal_view_handle: WeakViewHandle, ask_user_question_view: Option>, is_usage_footer_expanded: bool, } struct EmbeddedCodeEditorView { view: ViewHandle, language: Option, length: usize, } impl AIBlock { #[allow(clippy::too_many_arguments)] pub fn new( model: Rc>, terminal_model: Arc>, client_ids: ClientIdentifiers, controller: ModelHandle, get_relevant_files_controller: ModelHandle, current_working_directory: Option, shell_launch_data: Option, action_model: ModelHandle, context_model: ModelHandle, find_model: ModelHandle, active_session: ModelHandle, cli_subagent_controller: &ModelHandle, model_event_dispatcher: &ModelHandle, agent_view_controller: ModelHandle, ambient_agent_view_model: Option>, terminal_view_handle: WeakViewHandle, terminal_view_id: EntityId, ctx: &mut ViewContext, ) -> Self { let user_avatar_info = user_avatar_info_for_ai_block(model.as_ref(), ctx); let num_attached_context_blocks = num_attached_context_blocks(model.inputs_to_render(ctx)); let has_attached_context_selected_text = has_attached_context_selected_text(model.inputs_to_render(ctx)); let (pwd, home_dir) = model .inputs_to_render(ctx) .iter() .find_map(AIAgentInput::context) .and_then(|context| { context.iter().find_map(|agent_context| { if let AIAgentContext::Directory { pwd, home_dir, .. } = agent_context { Some((pwd.clone(), home_dir.clone())) } else { None } }) }) .unwrap_or((None, None)); let font_settings_handle = FontSettings::handle(ctx); ctx.subscribe_to_model(&font_settings_handle, |_, _, _, ctx| { ctx.notify(); }); ctx.subscribe_to_model( &AISettings::handle(ctx), move |me, settings_model, event, ctx| match event { AISettingsChangedEvent::AgentModeExecuteReadonlyCommands { .. } => { if let AutonomySettingSpeedbump::ShouldShowForAutoexecutingReadonlyCommands { checked, .. } = &mut me.autonomy_setting_speedbump { *checked = *settings_model .as_ref(ctx) .agent_mode_execute_read_only_commands; } else { me.autonomy_setting_speedbump = AutonomySettingSpeedbump::None; } ctx.notify(); } AISettingsChangedEvent::AgentModeCodingPermissions { .. } => { match &mut me.autonomy_setting_speedbump { AutonomySettingSpeedbump::ShouldShowForFileAccess { checked, .. } => { *checked = matches!( *settings_model.as_ref(ctx).agent_mode_coding_permissions, AgentModeCodingPermissionsType::AlwaysAllowReading ); } AutonomySettingSpeedbump::ShouldShowForCodebaseSearchFileAccess { selected_option, .. } => { *selected_option = match *settings_model.as_ref(ctx).agent_mode_coding_permissions { AgentModeCodingPermissionsType::AlwaysAllowReading => Some(0), AgentModeCodingPermissionsType::AllowReadingSpecificFiles => { Some(1) } AgentModeCodingPermissionsType::AlwaysAskBeforeReading => None, }; } _ => {} } ctx.notify(); } AISettingsChangedEvent::ThinkingDisplayMode { .. } | AISettingsChangedEvent::OrchestrationMessageDisplayMode { .. } => { ctx.notify(); } _ => {} }, ); ctx.subscribe_to_model( &AgentConversationsModel::handle(ctx), |me, _, event, ctx| { me.handle_agent_conversations_model_event(event, ctx); }, ); let safe_mode_settings = SafeModeSettings::handle(ctx); ctx.subscribe_to_model(&safe_mode_settings, |me, _, event, ctx| { me.handle_safe_mode_settings_changed_event(event, ctx) }); ctx.subscribe_to_model(&AIExecutionProfilesModel::handle(ctx), |me, _, _, ctx| { let terminal_view_id = me.terminal_view_id; if let Some(view) = me.ask_user_question_view.clone() { view.update(ctx, |v, ctx| { v.refresh_speedbump_dropdown_selection(terminal_view_id, ctx); }); } }); let detected_links_state: DetectedLinksState = Default::default(); let secret_redaction_state = SecretRedactionState::default(); ctx.subscribe_to_model(&find_model, |me, _, event, ctx| match event { FindEvent::UpdatedFocusedMatch => { me.handle_find_match_focus_change(ctx); ctx.notify(); } FindEvent::RanFind => ctx.notify(), }); // Input Mode affects styling of AI blocks -- in particular, the spacing and border between // a user query block and a subsequent action (e.g. requested command, suggested code diff) // block. ctx.subscribe_to_model( &InputModeSettings::handle(ctx), |_, _, event, ctx| match event { InputModeSettingsChangedEvent::InputModeState { .. } => ctx.notify(), }, ); Self::register_action_model_subscription(&action_model, ctx); Self::register_start_agent_executor_subscription(&action_model, ctx); ctx.subscribe_to_model(&active_session, |me, _, event, ctx| match event { ActiveSessionEvent::UpdatedPwd => { me.update_imported_comments_disabled_state(ctx); } ActiveSessionEvent::Bootstrapped => {} }); ctx.subscribe_to_model(&get_relevant_files_controller, |me, _, event, ctx| { if let GetRelevantFilesControllerEvent::Success { action_id, .. } = event { if me.requested_action_ids.contains(action_id) { ctx.notify(); } } }); let manage_rules_button = ctx.add_typed_action_view(|_| { ActionButton::new("Manage rules", NakedTheme) .on_click(|ctx| ctx.dispatch_typed_action(AIBlockAction::OpenAIFactCollection)) }); ctx.subscribe_to_model(&AIRequestUsageModel::handle(ctx), |me, _, event, ctx| { if let AIRequestUsageModelEvent::RequestBonusRefunded { requests_refunded, server_conversation_id, request_id, } = event { let server_conversation_token = BlocklistAIHistoryModel::as_ref(ctx) .conversation(&me.client_ids.conversation_id) .and_then(|conversation| conversation.server_conversation_token()) .cloned(); let server_output_id = me.model.server_output_id(ctx); if let (Some(server_conversation_token), Some(server_output_id)) = (server_conversation_token, server_output_id) { if request_id.eq(server_output_id.to_string().as_str()) && server_conversation_id.eq(server_conversation_token.as_str()) { me.request_refunded_count = Some(*requests_refunded); ctx.notify(); } } } }); // Note: UpdatedStreamingExchange is handled by the dedicated on_updated_output() // callback in model_impl.rs, so we don't need to respond to it here. ctx.subscribe_to_model( &BlocklistAIHistoryModel::handle(ctx), |me, _, event, ctx| { if me .state_handles .subagent_panel_states .values() .any(|state| history_event_affects_conversation(event, state.conversation_id)) { // Child conversations live on a different terminal // surface, so they bypass the parent block's normal // terminal-surface filter. Repaint the inline transcript // and status whenever one of those children changes. ctx.notify(); } if event .terminal_surface_id() .is_none_or(|id| id == me.terminal_view_id) { match event { BlocklistAIHistoryEvent::AppendedExchange { .. } | BlocklistAIHistoryEvent::UpdatedTodoList { .. } | BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. } | BlocklistAIHistoryEvent::StartedNewConversation { .. } | BlocklistAIHistoryEvent::SetActiveConversation { .. } => { ctx.notify(); } _ => {} } } }, ); ctx.subscribe_to_model( cli_subagent_controller, move |me, _, event, ctx| match event { CLISubagentEvent::SpawnedSubagent { initial_requested_command_action_id: Some(initial_requested_command_action_id), .. } => { me.expand_requested_command_view(initial_requested_command_action_id, ctx); } CLISubagentEvent::FinishedSubagent { initial_requested_command_action_id: Some(initial_requested_command_action_id), .. } => { me.collapse_requested_command_view(initial_requested_command_action_id, ctx); } CLISubagentEvent::UpdatedControl { requested_command_action_id: Some(requested_command_action_id), .. } => { if let Some(requested_command_view) = me.requested_commands.get(requested_command_action_id) { requested_command_view .view .update(ctx, |_, ctx| ctx.notify()); } } _ => {} }, ); ctx.subscribe_to_model(model_event_dispatcher, |me, _, event, ctx| { if let ModelEvent::BlockCompleted(block_completed_event) = event { let terminal_model = me.terminal_model.lock(); if terminal_model .block_list() .block_with_id(&block_completed_event.block_id) .and_then(|block| block.agent_interaction_metadata()) .is_some_and(|metadata| { metadata .requested_command_action_id() .is_some_and(|id| me.requested_action_ids.contains(id)) }) { ctx.notify(); } } }); if FeatureFlag::AgentView.is_enabled() { ctx.subscribe_to_model(&agent_view_controller, |_, _, _, ctx| ctx.notify()); } // Handoff prep emits ambient-agent events before submit, while still composing. // Only the run lifecycle events can change `is_cloud_agent_pre_first_exchange` // for this block's footer. if let Some(ambient_agent_view_model) = ambient_agent_view_model.as_ref() { ctx.subscribe_to_model(ambient_agent_view_model, |_, _, event, ctx| match event { AmbientAgentViewModelEvent::DispatchedAgent | AmbientAgentViewModelEvent::FollowupDispatched | AmbientAgentViewModelEvent::SessionReady { .. } | AmbientAgentViewModelEvent::ExecutionSessionReady { .. } | AmbientAgentViewModelEvent::Failed { .. } | AmbientAgentViewModelEvent::NeedsGithubAuth | AmbientAgentViewModelEvent::Cancelled | AmbientAgentViewModelEvent::HarnessCommandStarted { .. } => ctx.notify(), _ => {} }); } ctx.subscribe_to_model(&context_model, |_, _, event, ctx| { if let BlocklistAIContextEvent::UpdatedPendingContext { .. } = event { ctx.notify(); } }); let review_changes_button = ctx.add_typed_action_view(|_| { ActionButton::new("Review changes", SecondaryTheme) .with_icon(Icon::Diff) .with_size(ButtonSize::Small) .on_click(|ctx| { ctx.dispatch_typed_action(AIBlockAction::ToggleCodeReviewPane); }) }); let open_all_comments_button = ctx.add_typed_action_view(|_| { ActionButton::new("Open all in code review", SecondaryTheme) .with_size(ButtonSize::Small) .on_click(|ctx| { ctx.dispatch_typed_action(AIBlockAction::OpenAllImportedCommentsInCodeReview); }) }); let dismiss_suggestion_button = ctx.add_typed_action_view(|_| { ActionButton::new("Dismiss", SuggestionDismissButtonTheme) .with_icon(Icon::X) .with_size(ButtonSize::Small) .on_click(|ctx| { ctx.dispatch_typed_action(AIBlockAction::DismissSuggestionsSection); }) }); let disable_rule_suggestions_button = ctx.add_typed_action_view(|_| { ActionButton::new("Don't show again", SuggestionDismissButtonTheme) .with_size(ButtonSize::Small) .on_click(|ctx| { ctx.dispatch_typed_action(AIBlockAction::DisableRuleSuggestions); }) }); let ai_block_view_id = ctx.view_id(); let exchange_id = client_ids.client_exchange_id; let conversation_id = client_ids.conversation_id; let rewind_button = ctx.add_typed_action_view(|_| { ActionButton::new("Rewind", RewindButtonTheme) .with_size(ButtonSize::XSmall) .with_tooltip("Rewind to before this block") .on_click(move |ctx| { ctx.dispatch_typed_action(TerminalAction::RewindAIConversation { ai_block_view_id, exchange_id, conversation_id, entrypoint: AgentModeRewindEntrypoint::Button, }); }) }); let comment_data = model .inputs_to_render(ctx) .iter() .find_map(|input| match input { AIAgentInput::CodeReview { review_comments, .. } => Some(&review_comments.comments), _ => None, }) .into_iter() .flatten() .map(|comment| (comment.id, comment.content.clone())) .collect_vec(); let mut comment_states = HashMap::new(); for (id, content) in comment_data { let state = CommentElementState::new(id, &content, ctx); ctx.subscribe_to_view(&state.rich_text_editor, |me, view, event, ctx| { if matches!(event, EditorViewEvent::TextSelectionChanged) && view.as_ref(ctx).selected_text(ctx).is_some() { me.clear_other_selections(Some(view.id()), ctx.window_id(), ctx); ctx.emit(AIBlockEvent::ChildViewTextSelected); } }); comment_states.insert(id, state); } let mut me = Self { model, terminal_model, client_ids, profile_image_path: user_avatar_info.profile_image_path, user_display_name: user_avatar_info.display_name, controller, action_model, context_model, current_working_directory, shell_launch_data, requested_action_ids: Default::default(), auto_expand_requested_command_timer_handle: None, selected_text: Arc::new(RwLock::new(None)), window_id: ctx.window_id(), state_handles: Default::default(), time_to_first_token: OnceCell::new(), time_to_last_token: None, num_attached_context_blocks, has_attached_context_selected_text, finish_reason: None, directory_context: DirectoryContext { pwd, home_dir }, view_id: ctx.view_id(), detected_links_state, code_editor_views: Default::default(), requested_commands: Default::default(), requested_mcp_tools: Default::default(), requested_edits: Default::default(), todo_list_states: Default::default(), comment_states, collapsible_block_states: Default::default(), unit_tests_suggestions: Default::default(), secret_redaction_state, find_state: FindState::default(), find_model, is_references_section_open: false, active_session, autonomy_setting_speedbump: Default::default(), suggested_rules: Default::default(), suggested_agent_mode_workflow: Default::default(), manage_rules_button, keyboard_navigable_buttons: None, response_rating: OnceCell::new(), terminal_view_id, request_refunded_count: None, action_buttons: Default::default(), search_codebase_view: Default::default(), web_search_views: Default::default(), web_fetch_views: Default::default(), summarization_views: Default::default(), requested_commands_to_auto_collapse: Default::default(), review_changes_button, open_all_comments_button, dismiss_suggestion_button, disable_rule_suggestions_button, rewind_button, view_screenshot_buttons: Default::default(), last_right_clicked_command: None, agent_view_controller, ambient_agent_view_model, aws_bedrock_credentials_error_view: None, imported_comments: Default::default(), has_imported_comments: false, run_agents_card_views: Default::default(), link_detection_handle: None, #[cfg(feature = "local_fs")] resolved_code_block_paths: Default::default(), #[cfg(feature = "local_fs")] resolved_blocklist_image_sources: Default::default(), terminal_view_handle, ask_user_question_view: None, is_usage_footer_expanded: false, }; me.run_secret_redaction_on_user_query(me.client_ids.conversation_id, ctx); me.spawn_link_detection(ctx); // Create summarization view immediately if this block has a SummarizeConversation input, // so the "Summarizing..." UI appears before the API response starts streaming. me.maybe_create_summarization_view_from_input(ctx); if me.model.status(ctx).is_streaming() { me.model .on_updated_output(Box::new(Self::on_output_status_update), ctx); } else if let Some(output) = me.model.status(ctx).output_to_render() { // "Simulate" receiving this output if output is already complete. let output = output.get(); me.handle_updated_output(&output, ctx); me.handle_complete_output(&output, ctx); } match me.model.status(ctx) { AIBlockOutputStatus::Complete { .. } => { me.finish(FinishReason::Complete, ctx); } AIBlockOutputStatus::Failed { error, .. } => { me.maybe_create_aws_bedrock_credentials_error_view(&error, ctx); me.finish(FinishReason::Error, ctx); } AIBlockOutputStatus::Cancelled { .. } => { me.finish(FinishReason::Cancelled, ctx); } AIBlockOutputStatus::PartiallyReceived { .. } | AIBlockOutputStatus::Pending => (), } me } /// Update this block's directory context (pwd and home_dir) after creation. pub fn update_directory_context( &mut self, pwd: Option, home_dir: Option, ctx: &mut ViewContext, ) { if self.directory_context.pwd == pwd && self.directory_context.home_dir == home_dir { return; } self.directory_context.pwd = pwd; self.directory_context.home_dir = home_dir; ctx.notify(); } /// Set the shell launch data for this block, re-running link detection on the /// user query and output. This is used to populate shell launch data on restored /// AI blocks after the session finishes bootstrapping. pub fn set_shell_launch_data( &mut self, shell_launch_data: Option, ctx: &mut ViewContext, ) { if self.shell_launch_data == shell_launch_data { return; } self.shell_launch_data = shell_launch_data; // Re-run secret redaction on the user query with the new shell launch data. self.run_secret_redaction_on_user_query(self.client_ids.conversation_id, ctx); // Re-run link detection with the new shell launch data. self.spawn_link_detection(ctx); ctx.notify(); } fn run_secret_redaction_on_user_query( &mut self, conversation_id: AIConversationId, app: &AppContext, ) { self.detected_links_state .detected_links_by_location .retain(|location, _| !matches!(location, TextLocation::Query { .. })); if self .detected_links_state .currently_hovered_link_location .as_ref() .is_some_and(|location| matches!(location.location, TextLocation::Query { .. })) { self.detected_links_state.currently_hovered_link_location = None; } if self .detected_links_state .link_location_open_tooltip .as_ref() .is_some_and(|location| matches!(location.location, TextLocation::Query { .. })) { self.detected_links_state.link_location_open_tooltip = None; } self.secret_redaction_state.clear_user_query_locations(); let initial_conversation_query = BlocklistAIHistoryModel::as_ref(app) .conversation(&conversation_id) .and_then(|conversation| conversation.initial_user_query()); let secret_redaction_mode = get_secret_obfuscation_mode(app); for (input_index, input) in self.model.inputs_to_render(app).iter().enumerate() { let Some(query) = input.display_user_query(initial_conversation_query.as_ref()) else { continue; }; if secret_redaction_mode.should_redact_secret() { self.secret_redaction_state.run_redaction_for_location( &query, TextLocation::Query { input_index }, secret_redaction_mode.is_visually_obfuscated(), ); } } } /// Detects all links (URLs + file paths) in both the output and user query inputs. /// Reads the current output from the model internally. /// /// On `local_fs`, this spawns a background task via `spawn_blocking` to avoid blocking /// the main thread with filesystem I/O (file path detection + code block path resolution). /// On other targets (e.g. WASM), `spawn_blocking` is unavailable so detection runs /// synchronously. This is fine because it's only URL detection which is cheap. fn spawn_link_detection(&mut self, ctx: &mut ViewContext) { if let Some(handle) = self.link_detection_handle.take() { handle.abort(); } let shared_output = self.model.status(ctx).output_to_render(); let output_guard = shared_output.as_ref().map(|o| o.get()); let output = output_guard.as_deref(); let (mut texts, hyperlinks) = match output { Some(output) => collect_output_data_for_link_detection( output, self.current_working_directory.as_ref(), self.shell_launch_data.as_ref(), ), None => (Vec::new(), Vec::new()), }; // Include user query texts for link detection. let initial_conversation_query = BlocklistAIHistoryModel::as_ref(ctx) .conversation(&self.client_ids.conversation_id) .and_then(|conversation| conversation.initial_user_query()); for (input_index, input) in self.model.inputs_to_render(ctx).iter().enumerate() { if let Some(query) = input.display_user_query(initial_conversation_query.as_ref()) { texts.push((query, TextLocation::Query { input_index })); } } #[cfg(feature = "local_fs")] { // Collect code block paths that need resolution for the render cache. let code_block_paths: Vec = output .map(|output| { output .all_text() .flat_map(|text| text.sections.iter()) .filter_map(|section| { if let AIAgentTextSection::Code { source: Some(source), .. } = section { source.path() } else { None } }) .collect() }) .unwrap_or_default(); let image_sources: Vec = output .map(|output| { output .all_text() .flat_map(|text| text.sections.iter()) .filter_map(|section| { if let AIAgentTextSection::Image { image } = section { Some(image.source.clone()) } else { None } }) .unique() .collect() }) .unwrap_or_default(); let cwd = self.current_working_directory.clone(); let shell_data = self.shell_launch_data.clone(); self.link_detection_handle = Some(ctx.spawn( async move { tokio::task::spawn_blocking(move || { let all_links = detect_all_links( &texts, hyperlinks, cwd.as_ref(), shell_data.as_ref(), ); // Resolve code block paths for the render cache. let mut resolved_paths: HashMap> = HashMap::new(); if let Some(home_dir) = dirs::home_dir() { for path in &code_block_paths { let resolved = crate::ai::blocklist::block::view_impl::common::resolve_absolute_file_path( path.clone(), cwd.as_ref(), shell_data.as_ref(), home_dir.clone(), ); resolved_paths.insert(path.clone(), resolved); } } let mut resolved_image_sources = HashMap::new(); for source in image_sources { resolved_image_sources.insert( source.clone(), Some( resolve_asset_source_relative_to_directory( &source, cwd.as_deref().map(Path::new), ) .with_local_file_content_version(), ), ); } (all_links, resolved_paths, resolved_image_sources) }) .await }, |me, result, ctx| { if let Ok((all_links, resolved_paths, resolved_image_sources)) = result { me.detected_links_state.replace_all_links(all_links); me.resolved_code_block_paths = resolved_paths; me.resolved_blocklist_image_sources = resolved_image_sources; ctx.notify(); } me.link_detection_handle = None; }, )); } #[cfg(not(feature = "local_fs"))] { // No filesystem I/O, so detection is cheap and runs synchronously. let all_links = detect_all_links(&texts, hyperlinks, None, None); self.detected_links_state.replace_all_links(all_links); } } /// Updates which conversation this block is pointing to pub fn reset_conversation_id( &mut self, new_conversation_id: AIConversationId, new_model: Rc>, ctx: &mut ViewContext, ) { let conversation_contains_exchange = BlocklistAIHistoryModel::as_ref(ctx) .conversation(&new_conversation_id) .map(|conversation| { conversation .exchange_with_id(self.client_ids.client_exchange_id) .is_some() }) .unwrap_or(false); if !conversation_contains_exchange { log::error!( "Reassigning AIBlock to a new conversation but the new conversation does not contain the AI exchange" ); return; } self.client_ids.conversation_id = new_conversation_id; self.model = new_model; let user_avatar_info = user_avatar_info_for_ai_block(self.model.as_ref(), ctx); self.profile_image_path = user_avatar_info.profile_image_path; self.user_display_name = user_avatar_info.display_name; self.run_secret_redaction_on_user_query(new_conversation_id, ctx); // Re-detect all links for the new conversation. self.spawn_link_detection(ctx); ctx.notify(); } pub fn contains_actions(&self) -> bool { !self.requested_action_ids.is_empty() } pub fn contains_action(&self, action_id: &AIAgentActionId) -> bool { self.requested_action_ids.contains(action_id) } pub fn contains_action_result(&self, action_id: &AIAgentActionId, app: &AppContext) -> bool { self.model.inputs_to_render(app).iter().any(|input| { input .action_result() .is_some_and(|result| result.id == *action_id) }) } fn on_output_status_update(&mut self, ctx: &mut ViewContext) { if let Some(latency) = self.model.time_since_request_start(ctx) { // Since this is a OnceCell, we'll only set time_to_first_token to the // latency of the first output received. if self.time_to_first_token.set(latency).is_ok() { BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _ctx| { history.set_exchange_time_to_first_token( self.client_ids.conversation_id, self.client_ids.client_exchange_id, latency.num_milliseconds(), ); }); } self.time_to_last_token = Some(latency); } let was_autodetected_ai_query = self.model.was_autodetected_ai_query(ctx); let client_exchange_id = self.client_ids.client_exchange_id.to_string(); let conversation_id = self.client_ids.conversation_id; let time_to_first_token_ms = self .time_to_first_token .get() .map(|duration| duration.num_milliseconds() as u128); let time_to_last_token_ms = self .time_to_last_token .map(|duration| duration.num_milliseconds() as u128); let status = self.model.status(ctx); let is_udi_enabled = InputSettings::as_ref(ctx).is_universal_developer_input_enabled(ctx); log::info!( "[bedrock-debug] on_output_status_update: status={}", match &status { AIBlockOutputStatus::Pending => "Pending".to_string(), AIBlockOutputStatus::PartiallyReceived { output } => format!( "PartiallyReceived(messages={})", output.get().messages.len() ), AIBlockOutputStatus::Complete { output } => format!("Complete(messages={})", output.get().messages.len()), AIBlockOutputStatus::Cancelled { .. } => "Cancelled".to_string(), AIBlockOutputStatus::Failed { .. } => "Failed".to_string(), } ); match status { AIBlockOutputStatus::Pending => { self.requested_action_ids.clear(); self.secret_redaction_state.reset(); } AIBlockOutputStatus::PartiallyReceived { output } => { let output = output.get(); self.handle_updated_output(&output, ctx); } AIBlockOutputStatus::Complete { output } => { let output = output.get(); let server_output_id = self.model.server_output_id(ctx); self.handle_updated_output(&output, ctx); self.handle_complete_output(&output, ctx); send_telemetry_from_ctx!( TelemetryEvent::AgentModeCreatedAIBlock { client_exchange_id, was_autodetected_ai_query, conversation_id, time_to_first_token_ms, time_to_last_token_ms, server_output_id, was_user_facing_error: false, cancelled: false, is_udi_enabled, }, ctx ); } AIBlockOutputStatus::Cancelled { partial_output, .. } => { if let Some(output) = partial_output.as_ref() { let output = output.get(); self.handle_updated_output(&output, ctx); } self.spawn_link_detection(ctx); self.finish(FinishReason::Cancelled, ctx); let server_output_id = self.model.server_output_id(ctx); send_telemetry_from_ctx!( TelemetryEvent::AgentModeCreatedAIBlock { client_exchange_id, conversation_id, was_autodetected_ai_query, time_to_first_token_ms, time_to_last_token_ms, server_output_id, was_user_facing_error: false, cancelled: true, is_udi_enabled, }, ctx ); } AIBlockOutputStatus::Failed { error, .. } => { let server_output_id = self.model.server_output_id(ctx); send_telemetry_from_ctx!( TelemetryEvent::AgentModeCreatedAIBlock { client_exchange_id, was_autodetected_ai_query, conversation_id, time_to_first_token_ms, time_to_last_token_ms, server_output_id, was_user_facing_error: true, cancelled: false, is_udi_enabled, }, ctx ); self.maybe_create_aws_bedrock_credentials_error_view(&error, ctx); // There are no actions to be taken in this block, it is finished. self.finish(FinishReason::Error, ctx); } } ctx.emit(AIBlockEvent::AIOutputUpdated); ctx.notify(); } fn handle_updated_output(&mut self, output: &AIAgentOutput, ctx: &mut ViewContext) { // Ensure ui state handles are initialized for todo operation output messages. for message in &output.messages { if let AIAgentOutputMessageType::TodoOperation(TodoOperation::UpdateTodos { .. }) = &message.message { self.todo_list_states.entry(message.id.clone()).or_default(); } } if FeatureFlag::WebSearchUI.is_enabled() { // Handle WebSearch messages self.handle_web_search_messages(&output.messages, ctx); } if FeatureFlag::WebFetchUI.is_enabled() { // Handle WebFetch messages self.handle_web_fetch_messages(&output.messages, ctx); } self.fetch_conversation_search_agent_run_titles(output, ctx); for action in output.actions() { let new_action_ids: HashSet = output.actions().map(|action| action.id.clone()).collect(); #[cfg(feature = "integration_tests")] { // Log action IDs that were cached from a previous version of `output` that are not // in the updated `output` to assist with debugging evals. // // This is a short-term means to observe if the requested_action_ids logic is // faulty. // TODO(zachbai): Remove caching of action ids on the block entirely. let invalid_action_ids = self .requested_action_ids .difference(&new_action_ids) .collect_vec(); if !invalid_action_ids.is_empty() { log::warn!("AIBlock has invalid cached action IDs: {invalid_action_ids:?}"); } } self.requested_action_ids = new_action_ids; if !self.action_buttons.contains_key(&action.id) { let run_button = CompactibleActionButton::new( "Run".to_string(), Some(KeystrokeSource::Fixed(ENTER_KEYSTROKE.clone())), ButtonSize::InlineActionHeader, AIBlockAction::ExecuteRequestedAction { action_id: action.id.clone(), }, Icon::Play, Arc::new(PrimaryTheme), ctx, ); let cancel_button = CompactibleActionButton::new( "Cancel".to_string(), Some(KeystrokeSource::Fixed(CTRL_C_KEYSTROKE.clone())), ButtonSize::InlineActionHeader, AIBlockAction::CancelRequestedAction { action_id: action.id.clone(), }, Icon::X, Arc::new(NakedTheme), ctx, ); self.action_buttons.insert( action.id.clone(), ActionButtons { run_button, cancel_button, }, ); } if matches!(&action.action, AIAgentActionType::StartAgent { .. }) { self.state_handles .orchestration_navigation_card_handles .entry(action.id.clone()) .or_default(); } if matches!( &action.action, AIAgentActionType::ReadSkill(_) | AIAgentActionType::ReadFiles(_) ) { self.state_handles .skill_button_handles .entry(action.id.clone()) .or_default(); } if let AIAgentActionType::RunAgents(req) = &action.action { self.ensure_run_agents_card_view(&action.id, req, ctx); } // Ensure a button component exists for UseComputer actions. if matches!(&action.action, AIAgentActionType::UseComputer(_)) { self.view_screenshot_buttons .entry(action.id.clone()) .or_default(); } match action { AIAgentAction { id: action_id, action: AIAgentActionType::RequestFileEdits { title, file_edits }, .. } => { self.ensure_requested_edit_view( action_id, title, file_edits.clone(), output.server_output_id.clone(), ctx, ); } AIAgentAction { id: action_id, action: AIAgentActionType::RequestCommandOutput { command, citations, .. }, .. } => { self.handle_requested_command_stream_update(action_id, command, citations, ctx); } AIAgentAction { id: action_id, action: AIAgentActionType::CallMCPTool { server_id, name, input, }, .. } => { // Coerce the display value the same way dispatch does, so the // rendered MCP tool call detail shows `5` instead of `5.0` for // integer-typed fields. The raw `input` from the stream has // `f64` values because `structpb.NumberValue` erases the // integer/float distinction; see `coerce_integer_args` for // the full rationale. let display_input = match input { serde_json::Value::Object(map) => { let mut map = map.clone(); if let Some(schema) = crate::ai::mcp::TemplatableMCPServerManager::as_ref(ctx) .tool_input_schema(*server_id, name.as_str()) { crate::ai::blocklist::action_model::coerce_integer_args( &mut map, &schema, ); } serde_json::Value::Object(map) } other => other.clone(), }; let command_text = if display_input.is_null() { format!("MCP Tool: {name}") } else { format!("MCP Tool: {name} ({display_input})") }; self.handle_mcp_tool_stream_update( action_id, &command_text, name.to_string(), display_input, ctx, ); } AIAgentAction { id: action_id, action: AIAgentActionType::CreateDocuments(CreateDocumentsRequest { documents }), .. } => { self.handle_create_documents_stream_update(action_id, documents, ctx); } AIAgentAction { id: action_id, action: AIAgentActionType::AskUserQuestion { questions }, .. } if FeatureFlag::AskUserQuestion.is_enabled() => { self.handle_ask_user_question_stream_update(action_id, questions, ctx); } AIAgentAction { id: action_id, action: AIAgentActionType::SuggestNewConversation { .. }, .. } => { let start_new_conversation_button_text = "Start a new conversation".to_owned(); let continue_current_conversation_button_text = "Continue current conversation".to_owned(); let server_output_id = self.model.server_output_id(ctx); let accept_action = AIBlockAction::StartNewConversationButtonClicked { action_id: action_id.clone(), server_output_id: server_output_id.clone(), }; let reject_action = AIBlockAction::ContinueCurrentConversationButtonClicked { action_id: action_id.clone(), server_output_id: server_output_id.clone(), }; self.set_keyboard_navigable_buttons( start_new_conversation_button_text, continue_current_conversation_button_text, accept_action, reject_action, ctx, ); } _ => (), } } // Build the views and stream new content for suggested code snippets. output .all_text() .flat_map(|text| text.sections.iter()) .filter_map(|section| match section { AIAgentTextSection::Code { code, language, source, } => Some((code, language, source)), _ => None, }) .enumerate() .for_each(|(index, (code, language, source))| { self.handle_code_section_stream_update(index, code, language, source, ctx); }); // Register the mouse state handles for citations. for citation in &output.citations { self.state_handles .footer_citation_chip_handles .entry(citation.clone()) .or_default(); } // Also register handles for memory citations derived from fetched_memories, // which are synthesized at render time and never go through output.citations. // Only register for the first exchange since that's the only one that shows them. if let Some(conversation) = self.model.conversation(ctx) { let is_first_exchange = conversation .first_exchange() .map(|e| Some(e.id) == self.model.exchange_id(ctx)) .unwrap_or(false); if is_first_exchange { for memory in conversation.fetched_memories() { if memory.memory_store_id.is_empty() || memory.memory_id.is_empty() { continue; } self.state_handles .footer_citation_chip_handles .entry(AIAgentCitation::AgentMemory { memory_store_id: memory.memory_store_id.clone(), memory_id: memory.memory_id.clone(), content: memory.content.clone(), }) .or_default(); } } } // Register element state for reasoning messages and track summarization timing. for message in &output.messages { if let AIAgentOutputMessageType::RuntimeActivity(activity) = &message.message { let is_streaming = matches!( activity.status, Some(RuntimeActivityStatus::Pending | RuntimeActivityStatus::InProgress) ); let is_finished = matches!( activity.status, Some(RuntimeActivityStatus::Completed | RuntimeActivityStatus::Failed) ); let has_output = activity .output .as_deref() .is_some_and(|output| !output.is_empty()); let state = self .collapsible_block_states .entry(message.id.clone()) .or_insert_with(|| { if is_streaming && has_output { CollapsibleElementState::default() } else { CollapsibleElementState::collapsed() } }); state.sync_runtime_activity(is_streaming, is_finished, has_output); } if let AIAgentOutputMessageType::Reasoning { finished_duration, .. } = &message.message { let entry = self .collapsible_block_states .entry(message.id.clone()) .or_default(); if finished_duration.is_some() { entry.finish_reasoning(ctx); } else { entry.sync_finished_state(false); } } // Track summarization start time and token count when summarization message arrives if let AIAgentOutputMessageType::Summarization { finished_duration, summarization_type, .. } = &message.message { // Only track conversation summarization, not tool call result summarization if matches!(summarization_type, SummarizationType::ConversationSummary) { let entry = self .collapsible_block_states .entry(message.id.clone()) .or_default(); if finished_duration.is_some() { entry.finish_summarization(); } else { entry.sync_finished_state(false); } } } // Register element state for debug output messages - start collapsed if matches!( &message.message, AIAgentOutputMessageType::DebugOutput { .. } ) { self.collapsible_block_states .entry(message.id.clone()) .or_insert_with(CollapsibleElementState::collapsed); } // Register collapsible state for orchestration action messages. let orchestration_message_display_mode = AISettings::as_ref(ctx).orchestration_message_display_mode; match &message.message { AIAgentOutputMessageType::Action(AIAgentAction { action, .. }) => { if let Some(state) = default_collapsible_state_for_orchestration_action( action, orchestration_message_display_mode, ) { self.collapsible_block_states .entry(message.id.clone()) .or_insert(state); } } AIAgentOutputMessageType::MessagesReceivedFromAgents { messages } => { for received_message in messages { let collapsible_id = received_message_collapsible_id(&received_message.message_id); self.collapsible_block_states .entry(collapsible_id.clone()) .or_insert_with(|| { default_collapsible_state_for_orchestration_message( orchestration_message_display_mode, ) }); self.state_handles .transcript_avatar_handles .entry(collapsible_id) .or_default(); } } _ => {} } } if get_secret_obfuscation_mode(ctx).should_redact_secret() { self.secret_redaction_state .run_incremental_redaction_on_partial_output( output, get_secret_obfuscation_mode(ctx).is_visually_obfuscated(), ); } } fn fetch_conversation_search_agent_run_titles( &self, output: &AIAgentOutput, ctx: &mut ViewContext, ) { for task_id in Self::conversation_search_agent_run_ids(output) { AgentConversationsModel::handle(ctx).update(ctx, |model, ctx| { model.get_or_async_fetch_task_data(&task_id, ctx); }); } } fn handle_agent_conversations_model_event( &mut self, event: &AgentConversationsModelEvent, ctx: &mut ViewContext, ) { // Conversation-search labels may render with an agent-run fallback before the // async task title fetch completes. `TasksUpdated` is the signal that the real // title may now be cached, so only notify blocks that render those labels. if matches!(event, AgentConversationsModelEvent::TasksUpdated) && self.output_references_agent_run_for_conversation_search(ctx) { ctx.notify(); } } fn output_references_agent_run_for_conversation_search(&self, app: &AppContext) -> bool { self.model .status(app) .output_to_render() .is_some_and(|output| { !Self::conversation_search_agent_run_ids(&output.get()).is_empty() }) } fn conversation_search_agent_run_ids(output: &AIAgentOutput) -> Vec { let mut task_ids = HashSet::new(); for message in &output.messages { let AIAgentOutputMessageType::Subagent(SubagentCall { subagent_type: SubagentType::ConversationSearch { agent_run_id: Some(agent_run_id), .. }, .. }) = &message.message else { continue; }; if let Ok(task_id) = agent_run_id.parse() { task_ids.insert(task_id); } } task_ids.into_iter().collect() } fn set_keyboard_navigable_buttons( &mut self, accept_text: String, reject_text: String, accept_action: AIBlockAction, reject_action: AIBlockAction, ctx: &mut ViewContext, ) { let accept_button_handle = self.state_handles.menu_accept_button_handle.clone(); let reject_button_handle = self.state_handles.menu_reject_button_handle.clone(); let buttons = vec![ KeyboardNavigableButtonBuilder::new( move |is_selected, app| { let appearance = Appearance::handle(app).as_ref(app); let mut button = appearance .ui_builder() .button(ButtonVariant::Secondary, accept_button_handle.clone()) .with_style(UiComponentStyles { font_size: Some(appearance.monospace_font_size()), ..UiComponentStyles::default() }) .with_hovered_styles(UiComponentStyles { font_size: Some(appearance.monospace_font_size()), ..UiComponentStyles::default() }); if is_selected { let selected_styles = UiComponentStyles { border_color: Some(appearance.theme().accent().into()), border_width: Some(1.0), background: Some(appearance.theme().surface_2().into()), ..UiComponentStyles::default() }; button = button.with_style(selected_styles); button = button.with_text_and_icon_label(TextAndIcon::new( TextAndIconAlignment::TextFirst, accept_text.clone(), Icon::CornerDownLeft.to_galaxyui_icon(appearance.theme().foreground()), MainAxisSize::Max, MainAxisAlignment::SpaceBetween, vec2f( appearance.monospace_font_size(), appearance.monospace_font_size(), ), )); } else { button = button.with_text_label(accept_text.clone()); } button }, move |ctx: &mut ViewContext| { ctx.dispatch_typed_action(&accept_action); }, ), KeyboardNavigableButtonBuilder::new( move |is_selected, app| { let appearance = Appearance::handle(app).as_ref(app); let mut button = appearance .ui_builder() .button(ButtonVariant::Secondary, reject_button_handle.clone()) .with_style(UiComponentStyles { font_size: Some(appearance.monospace_font_size()), ..UiComponentStyles::default() }) .with_hovered_styles(UiComponentStyles { font_size: Some(appearance.monospace_font_size()), ..UiComponentStyles::default() }); if is_selected { let selected_styles = UiComponentStyles { border_color: Some(appearance.theme().accent().into()), border_width: Some(1.0), background: Some(appearance.theme().surface_2().into()), ..UiComponentStyles::default() }; button = button.with_style(selected_styles); button = button.with_text_and_icon_label(TextAndIcon::new( TextAndIconAlignment::TextFirst, reject_text.clone(), Icon::CornerDownLeft.to_galaxyui_icon(appearance.theme().foreground()), MainAxisSize::Max, MainAxisAlignment::SpaceBetween, vec2f( appearance.monospace_font_size(), appearance.monospace_font_size(), ), )); } else { button = button.with_text_label(reject_text.clone()); } button }, move |ctx: &mut ViewContext| { ctx.dispatch_typed_action(&reject_action); }, ), ]; let menu = ctx.add_typed_action_view(|_| KeyboardNavigableButtons::new(buttons)); self.keyboard_navigable_buttons = Some(menu); } /// Applies final display behavior to orchestration message bodies. fn finish_orchestration_message_collapsible_states( &mut self, output: &AIAgentOutput, ctx: &mut ViewContext, ) { let display_mode = AISettings::as_ref(ctx).orchestration_message_display_mode; for message in &output.messages { match &message.message { AIAgentOutputMessageType::Action(AIAgentAction { action: AIAgentActionType::SendMessageToAgent { .. }, .. }) => { self.collapsible_block_states .entry(message.id.clone()) .or_insert_with(|| { default_orchestration_collapsible_state( display_mode.should_expand_agent_message_body(), ) }) .finish_orchestration_message(display_mode); } AIAgentOutputMessageType::MessagesReceivedFromAgents { messages } => { for received_message in messages { let collapsible_id = received_message_collapsible_id(&received_message.message_id); self.collapsible_block_states .entry(collapsible_id) .or_insert_with(|| { default_collapsible_state_for_orchestration_message(display_mode) }) .finish_orchestration_message(display_mode); } } AIAgentOutputMessageType::Text(_) | AIAgentOutputMessageType::Reasoning { .. } | AIAgentOutputMessageType::Summarization { .. } | AIAgentOutputMessageType::Subagent(_) | AIAgentOutputMessageType::RuntimeActivity(_) | AIAgentOutputMessageType::Action(_) | AIAgentOutputMessageType::TodoOperation(_) | AIAgentOutputMessageType::WebSearch(_) | AIAgentOutputMessageType::WebFetch(_) | AIAgentOutputMessageType::CommentsAddressed { .. } | AIAgentOutputMessageType::DebugOutput { .. } | AIAgentOutputMessageType::ArtifactCreated(_) | AIAgentOutputMessageType::SkillInvoked(_) | AIAgentOutputMessageType::EventsFromAgents { .. } => {} } } } fn handle_complete_output(&mut self, output: &AIAgentOutput, ctx: &mut ViewContext) { self.finish_orchestration_message_collapsible_states(output, ctx); let mut suggestions = BlocklistAIHistoryModel::as_ref(ctx) .existing_suggestions_for_conversation(self.client_ids.conversation_id) .cloned() .unwrap_or_default(); if let Some(output_suggestions) = &output.suggestions { suggestions.extend(output_suggestions); } if FeatureFlag::SuggestedRules.is_enabled() && AISettings::as_ref(ctx).is_rule_suggestions_enabled(ctx) { // Ensure we don't suggest rules that were already suggested and saved by checking the logging id. let existing_suggestions = self .suggested_rules .iter() .map(|rule| rule.read(ctx, |rule, _| rule.logging_id())) .collect_vec(); let existing_rules: HashSet = { CloudModel::as_ref(ctx) .get_all_objects_of_type::() .filter_map(|fact| { let AIFact::Memory(AIMemory { suggested_logging_id, .. }) = fact.model().string_model.clone(); suggested_logging_id }) .collect() }; for rule in suggestions.rules.into_iter() { if existing_rules.contains(&rule.logging_id) || existing_suggestions.contains(&rule.logging_id) { continue; } let rule_view = ctx.add_typed_action_view(|ctx| SuggestionChipView::new_rule_chip(rule, ctx)); ctx.subscribe_to_view(&rule_view, |_me, _view, event, ctx| match event { SuggestedChipViewEvent::OpenAIFactCollection { sync_id } => { ctx.emit(AIBlockEvent::OpenAIFactCollection { sync_id: *sync_id }); } SuggestedChipViewEvent::ShowSuggestedRuleDialog { rule_and_id } => { ctx.emit(AIBlockEvent::OpenSuggestedRuleDialog { rule_and_id: rule_and_id.clone(), }); } _ => {} }); self.suggested_rules.push(rule_view); } } // Only show the agent mode workflow if there are no rules. if FeatureFlag::SuggestedAgentModeWorkflows.is_enabled() && self.suggested_rules.is_empty() { if let Some(workflow) = suggestions.agent_mode_workflows.first() { let workflow_view = ctx.add_typed_action_view(|ctx| { SuggestionChipView::new_agent_mode_workflow_chip(workflow.clone(), ctx) }); ctx.subscribe_to_view(&workflow_view, |_me, _view, event, ctx| match event { SuggestedChipViewEvent::OpenWorkflow { sync_id } => { ctx.emit(AIBlockEvent::OpenWorkflow { sync_id: *sync_id }); } SuggestedChipViewEvent::ShowSuggestedAgentModeWorkflowModal { workflow_and_id, } => { ctx.emit(AIBlockEvent::OpenSuggestedAgentModeWorkflowModal { workflow_and_id: workflow_and_id.clone(), }); } _ => {} }); self.suggested_agent_mode_workflow = Some(workflow_view); } } for action in output.actions() { match action { AIAgentAction { id, action: AIAgentActionType::RequestFileEdits { title, file_edits, .. }, .. } => { self.ensure_requested_edit_view( id, title, file_edits.clone(), output.server_output_id.clone(), ctx, ); } AIAgentAction { id, action: AIAgentActionType::SearchCodebase(request), .. } => { self.handle_search_codebase_complete( id, &request.query, request.codebase_path.clone(), output.server_output_id.clone(), ctx, ); } AIAgentAction { id, action: AIAgentActionType::SuggestPrompt(SuggestPromptRequest::UnitTestsSuggestion { query, title, description, }), .. } => { if !self.model.is_restored() { self.handle_unit_test_suggestion_complete( id, output.server_output_id.as_ref(), query.clone(), title.clone(), description.clone(), ctx, ); } } AIAgentAction { id, action: AIAgentActionType::InsertCodeReviewComments { repo_path, comments, base_branch, }, .. } if self.model.is_restored() && FeatureFlag::PRCommentsV2.is_enabled() => { self.handle_insert_code_review_comments( id.clone(), repo_path, comments, base_branch.as_deref(), ctx, ); } _ => (), } } // Collect UI state handles for code snippets, tables, and image // tooltips in a single pass. Each handle type is collected in the // order its section type appears, matching the indices used during // rendering. let (code_buttons, table_handles, image_tooltip_handles) = output .all_text() .flat_map(|text| text.sections.iter()) .fold( (Vec::new(), Vec::new(), Vec::new()), |(mut code_buttons, mut table_handles, mut image_tooltip_handles), section| { match section { AIAgentTextSection::Code { .. } => { code_buttons.push(CodeSnippetButtonHandles::default()); } AIAgentTextSection::Table { .. } => { table_handles.push(TableSectionHandles::default()); } AIAgentTextSection::Image { .. } => { image_tooltip_handles.push(MouseStateHandle::default()); } AIAgentTextSection::PlainText { .. } | AIAgentTextSection::MermaidDiagram { .. } => {} } (code_buttons, table_handles, image_tooltip_handles) }, ); self.state_handles.normal_response_code_snippet_buttons = code_buttons; self.state_handles.table_section_handles = table_handles; self.state_handles.image_section_tooltip_handles = image_tooltip_handles; self.spawn_link_detection(ctx); let shell_type = self.active_session.as_ref(ctx).shell_type(ctx); let escape_char = shell_type.map(|s| ShellFamily::from(s).escape_char()); for (requested_command_action_id, command, is_read_only, is_risky) in output.actions().filter_map(|action| { if let AIAgentAction { action: AIAgentActionType::RequestCommandOutput { command, is_read_only, is_risky, .. }, .. } = action { Some(( &action.id, command, is_read_only.unwrap_or(false), *is_risky, )) } else { None } }) { if is_agent_mode_autonomy_allowed(ctx) { let autoexecute_decision = escape_char.map(|escape_char| { BlocklistAIPermissions::as_ref(ctx).can_autoexecute_command( &self.client_ids.conversation_id, command, escape_char, is_read_only, is_risky, Some(self.terminal_view_id), ctx, ) }); match autoexecute_decision { Some(CommandExecutionPermission::Denied( CommandExecutionPermissionDeniedReason::AlwaysAskEnabled, )) if !*AISettings::as_ref(ctx) .has_shown_agent_mode_profile_command_autoexecution_speedbump => { // Show the default command autonomy setting if set to Always Ask. self.autonomy_setting_speedbump = AutonomySettingSpeedbump::ShouldShowForProfileCommandAutoexecution { action_id: requested_command_action_id.clone(), shown: Default::default(), }; self.update_requested_command_autonomy_speedbump( requested_command_action_id.clone(), ctx, ); // Mark the speedbump as shown in settings so that we do not render it again. AISettings::handle(ctx) .update(ctx, |ai_settings, ctx| { if let Err(err) = ai_settings.has_shown_agent_mode_profile_command_autoexecution_speedbump.set_value(true, ctx) { log::warn!("Could not mark profile command autoexecution speedbump as shown {err}"); } } ) } Some(CommandExecutionPermission::Denied( CommandExecutionPermissionDeniedReason::Inconclusive, )) if *AISettings::as_ref(ctx) .should_show_agent_mode_autoexecute_readonly_commands_speedbump && is_read_only => { // Try to show the speedbump for the readonly command setting // if we haven't shown it enough before and this command is // considered readonly. self.autonomy_setting_speedbump = AutonomySettingSpeedbump::ShouldShowForAutoexecutingReadonlyCommands { action_id: requested_command_action_id.clone(), checked: true, shown: Arc::new(Mutex::new(false)), }; self.update_requested_command_autonomy_speedbump( requested_command_action_id.clone(), ctx, ); // Mark the speedbump as shown in settings so that we do not render it again. AISettings::handle(ctx) .update(ctx, |ai_settings, ctx| { if let Err(err) = ai_settings.should_show_agent_mode_autoexecute_readonly_commands_speedbump.set_value(false, ctx) { log::warn!("Could not mark autoexecute read-only commands speedbump as shown {err}"); } } ) } _ => (), } } for citation in &output.citations { if is_command_copied_from_document(command, citation, shell_type, ctx) { if let Some(requested_command) = self.requested_commands.get(requested_command_action_id) { requested_command.view.update(ctx, |view, ctx| { view.update_copied_from_citation(citation); ctx.notify(); }); } } } } for action_id in output .actions() .filter_map(|action| (action.is_get_relevant_files()).then_some(&action.id)) { if is_agent_mode_autonomy_allowed(ctx) && *AISettings::as_ref(ctx).should_show_agent_mode_autoread_files_speedbump { // Try to show the speedbump for codebase search. self.state_handles.codebase_search_speedbump_option_handles = vec![Default::default(), Default::default()]; self.state_handles .codebase_search_speedbump_radio_button_handle .set_selected_idx(0); self.autonomy_setting_speedbump = AutonomySettingSpeedbump::ShouldShowForCodebaseSearchFileAccess { action_id: action_id.clone(), selected_option: Some(0), shown: Arc::new(Mutex::new(false)), }; // Mark the speedbump as shown in settings so that we do not render it again. AISettings::handle(ctx).update(ctx, |ai_settings, ctx| { if let Err(err) = ai_settings .should_show_agent_mode_autoread_files_speedbump .set_value(false, ctx) { log::warn!("Could not mark autoread files speedbump as shown {err}"); } }) } } // One-shot flag is consumed only after the footer is attached, so restored // blocks and views that haven't arrived yet don't burn the flag. for action in output.actions() { let is_file_access = action.is_get_specific_files() || action.is_grep() || action.is_file_glob(); if is_file_access { if is_agent_mode_autonomy_allowed(ctx) && *AISettings::as_ref(ctx).should_show_agent_mode_autoread_files_speedbump { // Try to show the speedbump for autoread files setting // if we haven't shown it enough before. self.autonomy_setting_speedbump = AutonomySettingSpeedbump::ShouldShowForFileAccess { action_id: action.id.clone(), checked: true, shown: Arc::new(Mutex::new(false)), }; // Mark the speedbump as shown in settings so that we do not render it again. AISettings::handle(ctx).update(ctx, |ai_settings, ctx| { if let Err(err) = ai_settings .should_show_agent_mode_autoread_files_speedbump .set_value(false, ctx) { log::warn!( "Error with marking autoread files speedbump as shown {err}" ); } }) } } else if matches!(action.action, AIAgentActionType::AskUserQuestion { .. }) && !self.model.is_restored() && FeatureFlag::AskUserQuestion.is_enabled() && is_agent_mode_autonomy_allowed(ctx) && *AISettings::as_ref(ctx).should_show_agent_mode_ask_user_question_speedbump { self.autonomy_setting_speedbump = AutonomySettingSpeedbump::ShouldShowForAskUserQuestion { action_id: action.id.clone(), shown: Arc::new(Mutex::new(false)), }; if self.sync_ask_user_question_speedbump_footer(ctx) { Self::mark_ask_user_question_speedbump_as_shown(ctx); } } } // Run secret detection at the end of the stream to catch any secrets we might've missed while streaming, // due to secret patterns that may include whitespace within them (we delimit on whitespace with the optimized // secret detection approach). if get_secret_obfuscation_mode(ctx).is_visually_obfuscated() { self.secret_redaction_state .run_redaction_on_complete_output(output); } let surfaced_citations = output .citations .iter() .filter_map(|citation| citation.for_telemetry(ctx)) .collect_vec(); if !surfaced_citations.is_empty() { send_telemetry_from_ctx!( TelemetryEvent::AgentModeSurfacedCitations { citations: surfaced_citations, block_id: self.client_ids.client_exchange_id.to_string(), conversation_id: self.client_ids.conversation_id, server_output_id: output.server_output_id.clone(), }, ctx ); } // This is used to trigger the theme chooser opening when the theme chooser onboarding block is active. if let Some(text_message) = output.text_from_agent_output().last() { if text_message.sections.iter().any(|section| { if let AIAgentTextSection::PlainText { text } = section { text.text().contains("The matrix theme is now available at") } else { false } }) { ctx.emit(AIBlockEvent::OpenThemeChooser); } } if self.requested_action_ids.is_empty() { // There are no actions to be taken in this block, it is finished. self.finish(FinishReason::Complete, ctx); } } /// Returns `true` if the AI block should be hidden. pub fn is_hidden(&self, app: &AppContext) -> bool { let is_for_hidden_exchange = BlocklistAIHistoryModel::as_ref(app).is_exchange_hidden( self.client_ids.conversation_id, self.client_ids.client_exchange_id, ); // If the AI Block's exchange is hidden, return true. // // This is typically the case for the initial exchange in a conversation started for a // 'passive' AI feature like suggested prompts. if is_for_hidden_exchange { return true; } if !FeatureFlag::AgentView.is_enabled() { return false; } if let Some(active_conversation_id) = self .agent_view_controller .as_ref(app) .agent_view_state() .active_conversation_id() { // If the agent view is active, only AI blocks for the active agent view conversation // should be visible. active_conversation_id != self.client_ids.conversation_id } else { // If there is no active agent view, only passive, non-hidden (we checked for if the // exchange is hidden already above) exchanges are rendered. // // These correspond to AI blocks with a successfully received suggested code diff or // unit test suggestion. !self.model.request_type(app).is_passive() } } pub fn is_passive_conversation(&self, app: &AppContext) -> bool { self.model.request_type(app).is_passive() } fn handle_code_section_stream_update( &mut self, index: usize, code: &str, language: &Option, source: &Option, ctx: &mut ViewContext, ) { match self.code_editor_views.get_mut(index) { Some(embedded_view) => { embedded_view.view.update(ctx, |view, ctx| { // The language and starting line number may not be specified in the output for the first iteration. // Only set the language/starting line number the first time that they are specified or if they change. if embedded_view.language != *language { embedded_view.language = language.clone(); if let Some(extension) = language .as_ref() .and_then(|language| language.to_extension()) { // Since this is a code snippet, construct a fake path name for looking up the language. let fake_path = format!("/snippet.{extension}"); view.set_language_with_local_path(Path::new(&fake_path), ctx); } } let starting_line_number = source.as_ref().and_then(|s| { if let CodeSource::Link { range_start, .. } = s { range_start.as_ref().map(|ls| ls.line_num) } else { None } }); if view.starting_line_number() != starting_line_number { view.set_starting_line_number(starting_line_number); } // Update the buffer with just the new or deleted range. // Assumption: Only the end of the string is updated. // Assumption: The only time text is deleted is at the end of parsing, where it has partially // received the ``` end marker. // Ex: Iteration 57: "a += 12\n``" // Ex: Iteration 58: "a += 12" match code.len().cmp(&embedded_view.length) { Ordering::Greater => { view.append_at_end(&code[embedded_view.length..], ctx); ctx.notify(); } Ordering::Less => { view.truncate(code.len(), ctx); ctx.notify(); } Ordering::Equal => {} } embedded_view.length = code.len(); }); } None => { let view = ctx.add_typed_action_view(|ctx| { CodeEditorView::new( self.shell_launch_data.clone().map(|data| data.into()), None, CodeEditorRenderOptions::new(VerticalExpansionBehavior::InfiniteHeight), ctx, ) .with_can_show_diff_ui(false) }); view.update(ctx, |view, ctx| { view.set_starting_line_number({ source.as_ref().and_then(|s| match s { CodeSource::Link { range_start, .. } => { range_start.as_ref().map(|ls| ls.line_num) } _ => None, }) }); view.set_show_current_line_highlights(false, ctx); view.set_interaction_state(InteractionState::Selectable, ctx); let state = InitialBufferState::plain_text(code); view.reset(state, ctx); // Apply language immediately on initial creation so restored blocks get syntax highlighting. if let Some(ext) = language.as_ref().and_then(|lang| lang.to_extension()) { let fake_path = format!("/snippet.{ext}"); view.set_language_with_local_path(Path::new(&fake_path), ctx); } ctx.notify(); }); ctx.subscribe_to_view(&view, |me, view, event, ctx| match event { CodeEditorEvent::SelectionChanged => { // If there's an ongoing text selection, clear all other selections within the // `AIBlock`'s view sub-hierarchy to ensure only one component has a selection at a time. // // The `is_some` check is necessary because `CodeEditorEvent::SelectionChanged` is // also emitted when the editor's selection is cleared via external means // (i.e. when a text selection is made outside the `CodeEditorView`). if view.as_ref(ctx).selected_text(ctx).is_some() { me.clear_other_selections(Some(view.id()), ctx.window_id(), ctx); ctx.emit(AIBlockEvent::ChildViewTextSelected); } } CodeEditorEvent::CopiedEmptyText => { ctx.emit(AIBlockEvent::CopiedEmptyText); } #[cfg(windows)] CodeEditorEvent::WindowsCtrlC { .. } => { ctx.emit(AIBlockEvent::WindowsCtrlC); } _ => {} }); self.code_editor_views.push(EmbeddedCodeEditorView { view, language: language.clone(), length: code.len(), }); } } } pub fn current_todo_list<'a>(&'a self, app: &'a AppContext) -> Option<&'a AIAgentTodoList> { self.model.conversation_id(app).and_then(|id| { BlocklistAIHistoryModel::as_ref(app) .conversation(&id) .and_then(|conversation| conversation.active_todo_list()) }) } fn enable_autoexecute_override(&mut self, ctx: &mut ViewContext) { let is_on: bool = BlocklistAIHistoryModel::as_ref(ctx) .conversation(&self.client_ids.conversation_id) .map(|c| c.autoexecute_any_action()) .unwrap_or(false); if !is_on { BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { history.toggle_autoexecute_override( &self.client_ids.conversation_id, self.terminal_view_id, ctx, ); }); } } fn mark_ask_user_question_speedbump_as_shown(ctx: &mut ViewContext) { AISettings::handle(ctx).update(ctx, |ai_settings, ctx| { if let Err(err) = ai_settings .should_show_agent_mode_ask_user_question_speedbump .set_value(false, ctx) { log::warn!("Could not mark ask-user-question speedbump as shown: {err}"); } }); } fn ensure_requested_edit_view( &mut self, action_id: &AIAgentActionId, title: &Option, file_edits: Vec, server_output_id: Option, ctx: &mut ViewContext, ) { if self.requested_edits.contains_key(action_id) { return; } let identifiers = AIIdentifiers { client_conversation_id: Some(self.client_ids.conversation_id), client_exchange_id: Some(self.client_ids.client_exchange_id), server_output_id, server_conversation_id: None, model_id: self.model.model_id(ctx), }; let contains_str_replace = file_edits.iter().any(|file_edit| { matches!( file_edit, FileEdit::Edit(ai::diff_validation::ParsedDiff::StrReplaceEdit { .. }) ) }); let contains_v4a = file_edits.iter().any(|file_edit| { matches!( file_edit, FileEdit::Edit(ai::diff_validation::ParsedDiff::V4AEdit { .. }) ) }); let edit_format_kind = match (contains_str_replace, contains_v4a) { (true, false) => RequestFileEditsFormatKind::StrReplace, (false, true) => RequestFileEditsFormatKind::V4A, (true, true) => RequestFileEditsFormatKind::Mixed, (false, false) => RequestFileEditsFormatKind::Unknown, }; // Only show the speedbump once, update the setting afterwards. let should_show_code_suggestion_speedbump = self.model.request_type(ctx).is_passive_code_diff() && UserWorkspaces::as_ref(ctx).is_code_suggestions_toggleable() && AISettings::as_ref(ctx).show_code_suggestion_speedbump(ctx); if should_show_code_suggestion_speedbump { AISettings::handle(ctx).update(ctx, |settings, ctx| { if let Err(e) = settings .show_code_suggestion_speedbump .set_value(false, ctx) { log::error!("Failed to persist 'Show code suggestion speedbump' setting: {e}"); } }); } let view = ctx.add_typed_action_view(|ctx| { CodeDiffView::new( action_id, self.model.as_ref(), title.clone(), identifiers, edit_format_kind, should_show_code_suggestion_speedbump, self.action_model.clone(), self.shell_launch_data.clone().map(|data| data.into()), ctx, ) }); // If the diff is being viewed in a shared session (read-only mode), populate diffs from the payload. if self.action_model.as_ref(ctx).is_view_only() { let active_session = self.active_session.as_ref(ctx); let file_diffs = convert_file_edits_to_file_diffs( file_edits, &active_session.shell_launch_data(ctx), &active_session.current_working_directory().cloned(), ); view.update(ctx, |diff_view, ctx| { diff_view.set_candidate_diffs(file_diffs, ctx); }); } let action_id_clone = action_id.clone(); ctx.subscribe_to_view(&view, move |me, view, event, ctx| { match event { CodeDiffViewEvent::TryAccept => { me.action_model.update(ctx, |action_model, ctx| { action_model.execute_action( &action_id_clone, me.client_ids.conversation_id, ctx, ); }); } CodeDiffViewEvent::EnableAutoexecuteMode => { me.enable_autoexecute_override(ctx); } CodeDiffViewEvent::Rejected => { me.cancel_action(&action_id_clone, ctx); } CodeDiffViewEvent::EditModeChanged { enabled } => { if *enabled { ctx.emit(AIBlockEvent::OpenCodeWithDiff { view: view.clone() }) } else { ctx.notify() } } CodeDiffViewEvent::ToggledEditVisibility => { ctx.emit(AIBlockEvent::ToggleCodeDiffVisibility); ctx.notify(); } CodeDiffViewEvent::EditorFocused => { // Actions within the editor should clear all other text selections me.clear_other_selections(Some(view.id()), ctx.window_id(), ctx); } CodeDiffViewEvent::TextSelected => { // If there's an ongoing text selection, clear all other selections within the // `AIBlock`'s view sub-hierarchy to ensure only one component has a selection at a time. me.clear_other_selections(Some(view.id()), ctx.window_id(), ctx); ctx.emit(AIBlockEvent::ChildViewTextSelected); } CodeDiffViewEvent::CopiedEmptyText => { ctx.emit(AIBlockEvent::CopiedEmptyText); } CodeDiffViewEvent::Blur => { ctx.emit(AIBlockEvent::FocusTerminal); } CodeDiffViewEvent::DisplayModeChanged => { ctx.notify(); } CodeDiffViewEvent::OpenSettings => { ctx.emit(AIBlockEvent::OpenSettings); } CodeDiffViewEvent::CancelPassive => { ctx.emit(AIBlockEvent::DismissedPassiveBlock); } CodeDiffViewEvent::ViewDetails => { // We only need to set the selected conversation when agent view is disabled; // when agent view is enabled, you have to enter the agent view for the code diff // conversation to follow-up in the first place, and hitting 'view details' // shouldn't auto-enter the agent view. if !FeatureFlag::AgentView.is_enabled() { me.context_model.update(ctx, |context_model, ctx| { context_model.set_pending_query_state_for_existing_conversation( me.client_ids.conversation_id, AgentViewEntryOrigin::ViewPassiveCodeDiffDetails, ctx, ); }); } ctx.emit(AIBlockEvent::FocusTerminal); ctx.notify(); } CodeDiffViewEvent::ContinuePassiveCodeDiffWithAgent { accepted: auto_resume, .. } => { let trigger_block_id = me.model.inputs_to_render(ctx).iter().find_map(|i| { if let Some(PassiveSuggestionTrigger::ShellCommandCompleted(trigger)) = i.passive_suggestion_trigger() { Some(trigger.executed_shell_command.id.clone()) } else { None } }); ctx.emit(AIBlockEvent::ContinuePassiveCodeDiffWithAgent { conversation_id: me.client_ids.conversation_id, trigger_block_id, auto_resume: *auto_resume, }); ctx.emit(AIBlockEvent::FocusTerminal); ctx.notify(); } CodeDiffViewEvent::ToggleCodeReviewPane { entrypoint } => { ctx.emit(AIBlockEvent::ToggleCodeReviewPane { entrypoint: *entrypoint, }); } CodeDiffViewEvent::LoadedDiffs => { if me.model.request_type(ctx).is_passive_code_diff() { ctx.emit(AIBlockEvent::PassiveCodeDiffLoaded); } } #[cfg_attr(not(feature = "local_fs"), allow(unused_variables))] CodeDiffViewEvent::OpenSkill { reference, path } => { #[cfg(feature = "local_fs")] { ctx.emit(AIBlockEvent::OpenCodeInWarp { source: CodeSource::Skill { reference: reference.clone(), location: path.clone(), origin: SkillOpenOrigin::EditFiles, }, layout: *crate::util::file::external_editor::EditorSettings::as_ref( ctx, ) .open_file_layout .value(), }); } } #[cfg_attr(not(feature = "local_fs"), allow(unused_variables))] CodeDiffViewEvent::OpenMCPConfig { path, .. } => { #[cfg(feature = "local_fs")] { ctx.emit(AIBlockEvent::OpenCodeInWarp { source: CodeSource::Link { path: path.clone(), range_start: None, range_end: None, }, layout: *crate::util::file::external_editor::EditorSettings::as_ref( ctx, ) .open_file_layout .value(), }); } } _ => (), } }); self.requested_edits .insert(action_id.clone(), RequestedEdit::new(view.clone())); let executor = self .action_model .as_ref(ctx) .request_file_edits_executor(ctx); executor.update(ctx, |executor, ctx| { executor.register_requested_edits(action_id, &view, ctx); }); self.action_model.update(ctx, |action_model, ctx| { action_model.retry_not_ready_action(action_id, self.client_ids.conversation_id, ctx); }); if self.model.request_type(ctx).is_passive() { BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { history.set_exchange_hidden_status( self.terminal_view_id, self.client_ids.conversation_id, self.client_ids.client_exchange_id, false, ctx, ); }); self.terminal_model .lock() .block_list_mut() .mark_rich_content_dirty(ctx.view_id()); } ctx.notify(); } pub fn set_restored_file_edits( &mut self, action_id: &AIAgentActionId, file_edits: Vec, ctx: &mut ViewContext, ) { let current_working_directory = self .active_session .as_ref(ctx) .current_working_directory() .cloned(); let shell_launch_data = self.active_session.as_ref(ctx).shell_launch_data(ctx); if let Some(code_diff_view) = self.requested_edits.get(action_id).map(|edit| &edit.view) { let file_diffs = crate::ai::blocklist::inline_action::code_diff_view::convert_file_edits_to_file_diffs( file_edits, &shell_launch_data, ¤t_working_directory, ); code_diff_view.update(ctx, |diff_view, ctx| { diff_view.set_candidate_diffs(file_diffs, ctx); // For restored conversations that include a passive code diff, we assume the diff // is no longer live, so we display it as embedded instead of inline. if self.model.request_type(ctx).is_passive_code_diff() { diff_view.set_embedded_display_mode(true, ctx); } // Set the state based on the action status from the action model let action_status = self .action_model .as_ref(ctx) .get_action_status(self.client_ids.conversation_id, action_id); let is_reverted = BlocklistAIHistoryModel::as_ref(ctx) .conversation(&self.client_ids.conversation_id) .map(|conv| conv.is_action_reverted(action_id)) .unwrap_or(false); let state = if is_reverted { CodeDiffState::Reverted } else { match action_status { Some(AIActionStatus::Finished(result)) => { if result.result.is_successful() { CodeDiffState::Accepted(None) } else { // For other finished states, default to rejected CodeDiffState::Rejected } } _ => { // When we quit in the middle of an action being completed, it is expected for that action to be saved as in progress. // However, it does not make sense to interact with a restored in-progress action, // so we mark the action as cancelled/rejected on restore. CodeDiffState::Rejected } } }; diff_view.set_state(state, ctx); }); } } /// Handle a new requested command received from the server. This will update the existing /// requested command block if one exists, or insert a new one otherwise. fn handle_requested_command_stream_update( &mut self, action_id: &AIAgentActionId, command: &str, citations: &[AIAgentCitation], ctx: &mut ViewContext, ) { match self.requested_commands.get_mut(action_id) { Some(requested_command) => { requested_command.view.update(ctx, |view, ctx| { view.apply_streamed_update(command, ctx); view.update_derived_from_citations(citations); ctx.notify(); }); } None => { let view = ctx.add_typed_action_view(|ctx| { let mut view = RequestedCommandView::new( action_id.clone(), self.client_ids.clone(), RequestedActionViewType::Command, self.model.clone(), &self.action_model, self.terminal_model.clone(), self.autonomy_setting_speedbump.clone(), self.state_handles .manage_autonomy_settings_link_handle .clone(), self.view_id, ctx, ); view.apply_streamed_update(command, ctx); view.update_derived_from_citations(citations); view }); let action_id_clone = action_id.clone(); ctx.subscribe_to_view(&view, move |me, view, event, ctx| { me.handle_requested_command_view_event(&action_id_clone, view, event, ctx); }); self.requested_commands .insert(action_id.clone(), RequestedCommand { view }); } } } fn handle_requested_command_view_event( &mut self, action_id: &AIAgentActionId, view: ViewHandle, event: &RequestedCommandViewEvent, ctx: &mut ViewContext, ) { // Short-circuit if this is no longer a requested command we're tracking. if !self.requested_commands.contains_key(action_id) { return; } match event { RequestedCommandViewEvent::Accepted => { self.action_model.update(ctx, |action_model, ctx| { action_model.handle_requested_command_accepted( self.client_ids.conversation_id, action_id, view.as_ref(ctx).command_text().to_string(), ctx, ); }); self.yield_requested_action_focus_if_focused(&view, ctx); ctx.notify(); } RequestedCommandViewEvent::EnableAutoexecuteMode => { self.enable_autoexecute_override(ctx); } RequestedCommandViewEvent::Rejected => { self.cancel_action(action_id, ctx); self.yield_requested_action_focus_if_focused(&view, ctx); } RequestedCommandViewEvent::UpdatedExpansionState { is_expanded } => { // We only care about expansion state updates when the command // is running or finished (i.e. when it has a block). let action_status = self .action_model .as_ref(ctx) .get_action_status(self.client_ids.conversation_id, action_id); let has_finished_command_block = { let terminal_model = self.terminal_model.lock(); terminal_model .block_list() .block_for_ai_action_id(action_id) .is_some_and(|block| block.finished()) }; if !has_finished_command_block && !action_status.is_some_and(|a| a.is_running() || a.is_done()) { return; } // If the requested command is being expanded, we don't need to auto-expand anymore. if *is_expanded { self.abort_auto_expand_requested_command_timer(); } else { // Remove requested command from list of requested commands to auto-collapse if the user manually collapses it. // In the edge-case where the user then manually expands the requested command, we won't auto-collapse it again. self.requested_commands_to_auto_collapse.remove(action_id); } ctx.emit(AIBlockEvent::UpdateInlineActionVisibility { action_id: action_id.clone(), is_visible: *is_expanded, }); ctx.notify(); } RequestedCommandViewEvent::TextSelected => { // If there's an ongoing text selection, clear all other selections within the // `AIBlock`'s view sub-hierarchy to ensure only one component has a selection at a time. self.clear_other_selections(Some(view.id()), ctx.window_id(), ctx); ctx.emit(AIBlockEvent::ChildViewTextSelected); } RequestedCommandViewEvent::CopiedEmptyText => { ctx.emit(AIBlockEvent::CopiedEmptyText); } RequestedCommandViewEvent::EditorFocused => { // Actions within the editor should clear all other text selections self.clear_other_selections(Some(view.id()), ctx.window_id(), ctx); } RequestedCommandViewEvent::OpenActiveAgentProfileEditor => { ctx.emit(AIBlockEvent::OpenActiveAgentProfileEditor); } } } /// Update the autonomy setting speedbump in requested command and MCP tool views that match the given action ID. fn update_requested_command_autonomy_speedbump( &self, action_id: AIAgentActionId, ctx: &mut ViewContext, ) { if let Some(requested_command) = self.requested_commands.get(&action_id) { requested_command.view.update(ctx, |view, ctx| { view.set_autonomy_setting_speedbump(self.autonomy_setting_speedbump.clone(), ctx); }); } if let Some(requested_mcp_tool) = self.requested_mcp_tools.get(&action_id) { requested_mcp_tool.view.update(ctx, |view, ctx| { view.set_autonomy_setting_speedbump(self.autonomy_setting_speedbump.clone(), ctx); }); } } /// Handle a new MCP tool call received from the server. This will update the existing /// MCP tool call block if one exists, or insert a new one otherwise. fn handle_mcp_tool_stream_update( &mut self, action_id: &AIAgentActionId, command_text: &str, mcp_name: String, mcp_args: serde_json::Value, ctx: &mut ViewContext, ) { match self.requested_mcp_tools.get_mut(action_id) { Some(requested_mcp_tool) => { requested_mcp_tool.view.update(ctx, |view, ctx| { view.apply_streamed_update(command_text, ctx); view.update_mcp_request(mcp_name, mcp_args); ctx.notify(); }); } None => { let view = ctx.add_typed_action_view(|ctx| { let mut view = RequestedCommandView::new( action_id.clone(), self.client_ids.clone(), RequestedActionViewType::McpTool, self.model.clone(), &self.action_model, self.terminal_model.clone(), self.autonomy_setting_speedbump.clone(), self.state_handles .manage_autonomy_settings_link_handle .clone(), self.view_id, ctx, ); view.apply_streamed_update(command_text, ctx); view.update_mcp_request(mcp_name, mcp_args); view }); let action_id_clone = action_id.clone(); ctx.subscribe_to_view(&view, move |me, view, event, ctx| { me.handle_mcp_tool_view_event(&action_id_clone, view, event, ctx); }); self.requested_mcp_tools .insert(action_id.clone(), RequestedCommand { view }); } } } fn handle_mcp_tool_view_event( &mut self, action_id: &AIAgentActionId, view: ViewHandle, event: &RequestedCommandViewEvent, ctx: &mut ViewContext, ) { // Short-circuit if this is no longer an MCP tool call we're tracking. if !self.requested_mcp_tools.contains_key(action_id) { return; } match event { RequestedCommandViewEvent::Accepted => { self.action_model.update(ctx, |action_model, ctx| { action_model.execute_action(action_id, self.client_ids.conversation_id, ctx); }); self.yield_requested_action_focus_if_focused(&view, ctx); ctx.notify(); } RequestedCommandViewEvent::Rejected => { self.cancel_action(action_id, ctx); self.yield_requested_action_focus_if_focused(&view, ctx); } RequestedCommandViewEvent::TextSelected => { // If there's an ongoing text selection, clear all other selections within the // `AIBlock`'s view sub-hierarchy to ensure only one component has a selection at a time. self.clear_other_selections(Some(view.id()), ctx.window_id(), ctx); ctx.emit(AIBlockEvent::ChildViewTextSelected); } RequestedCommandViewEvent::CopiedEmptyText => { ctx.emit(AIBlockEvent::CopiedEmptyText); } RequestedCommandViewEvent::EditorFocused => { // Actions within the editor should clear all other text selections self.clear_other_selections(Some(view.id()), ctx.window_id(), ctx); } RequestedCommandViewEvent::EnableAutoexecuteMode => { self.enable_autoexecute_override(ctx); } // There's nothing to do here for MCP tool calls; their expanded state // doesn't change the blocklist like it does for requested commands. RequestedCommandViewEvent::UpdatedExpansionState { .. } => {} RequestedCommandViewEvent::OpenActiveAgentProfileEditor => { ctx.emit(AIBlockEvent::OpenActiveAgentProfileEditor); } } } fn handle_ask_user_question_stream_update( &mut self, action_id: &AIAgentActionId, questions: &[AskUserQuestionItem], ctx: &mut ViewContext, ) { let needs_init = match self.ask_user_question_view.as_ref() { Some(view) => !view.as_ref(ctx).matches_action(action_id, questions), None => true, }; if !needs_init { return; } let action_model = self.action_model.clone(); let conversation_id = self.client_ids.conversation_id; let action_id_for_view = action_id.clone(); let questions_for_view = questions.to_vec(); let view = ctx.add_typed_action_view(move |ctx| { AskUserQuestionView::new( action_model.clone(), conversation_id, action_id_for_view.clone(), questions_for_view.clone(), ctx, ) }); let action_id_clone = action_id.clone(); ctx.subscribe_to_view(&view, move |me, _, event, ctx| { me.handle_ask_user_question_view_event(&action_id_clone, event, ctx); }); self.ask_user_question_view = Some(view.clone()); if self .action_model .as_ref(ctx) .get_action_status(self.client_ids.conversation_id, action_id) .is_some_and(|status| status.is_blocked()) { ctx.focus(&view); } if self.sync_ask_user_question_speedbump_footer(ctx) && *AISettings::as_ref(ctx).should_show_agent_mode_ask_user_question_speedbump { Self::mark_ask_user_question_speedbump_as_shown(ctx); } ctx.notify(); } /// Attaches the footer to the view if the speedbump variant matches. Called from /// both the variant-seeding and view-creation paths. fn sync_ask_user_question_speedbump_footer(&mut self, ctx: &mut ViewContext) -> bool { let Some(view) = self.ask_user_question_view.clone() else { return false; }; let speedbump_matches = matches!( &self.autonomy_setting_speedbump, AutonomySettingSpeedbump::ShouldShowForAskUserQuestion { action_id, .. } if action_id == view.as_ref(ctx).action_id() ); if speedbump_matches { let settings_link_handle = self .state_handles .ask_user_question_speedbump_settings_link_handle .clone(); if let AutonomySettingSpeedbump::ShouldShowForAskUserQuestion { shown, .. } = &self.autonomy_setting_speedbump { *shown.lock() = true; } let terminal_view_id = self.terminal_view_id; view.update(ctx, |view, ctx| { view.set_speedbump_settings_link(Some(settings_link_handle), ctx); view.init_speedbump_dropdown(ctx); view.refresh_speedbump_dropdown_selection(terminal_view_id, ctx); }); true } else { false } } fn handle_ask_user_question_view_event( &mut self, action_id: &AIAgentActionId, event: &AskUserQuestionViewEvent, ctx: &mut ViewContext, ) { if !self .ask_user_question_view .as_ref() .is_some_and(|view| view.as_ref(ctx).action_id() == action_id) { return; } match event { AskUserQuestionViewEvent::Updated => { ctx.notify(); } AskUserQuestionViewEvent::SpeedbumpPermissionChanged(permission) => { let permission = *permission; let profile_id = *AIExecutionProfilesModel::as_ref(ctx) .active_profile(Some(self.terminal_view_id), ctx) .id(); AIExecutionProfilesModel::handle(ctx).update(ctx, |model, ctx| { model.set_ask_user_question(profile_id, permission, ctx); }); send_telemetry_from_ctx!( TelemetryEvent::ChangedAgentModeAskUserQuestionPermission { src: AutonomySettingToggleSource::Speedbump, new: permission, }, ctx ); Self::mark_ask_user_question_speedbump_as_shown(ctx); self.autonomy_setting_speedbump = AutonomySettingSpeedbump::None; ctx.notify(); } } } fn handle_create_documents_stream_update( &mut self, action_id: &AIAgentActionId, documents: &[DocumentToCreate], ctx: &mut ViewContext, ) { let model_handle = AIDocumentModel::handle(ctx); let conversation_id = self.client_ids.conversation_id; // If the conversation stream has already been stopped, don't process the updates. // We need to do this to avoid marking the document as streaming again in the AIDocumentModel on // get_or_create_streaming_document_for_create_documents below after the stream has already been stopped. // This might throw away the last update for a normally completed stream, but that's okay because // we'll reset using the full content in the CreateDocumentsExecutor. if !self.model.status(ctx).is_streaming() { return; } let active_session_ref = self.active_session.as_ref(ctx); let file_link_resolution_context = active_session_ref .current_working_directory() .map(|working_directory| FileLinkResolutionContext { working_directory: working_directory.clone(), shell_launch_data: active_session_ref.shell_launch_data(ctx), }); let mut opened_first = false; for (index, document) in documents.iter().enumerate() { let title = if document.title.is_empty() { DEFAULT_PLANNING_DOCUMENT_TITLE.to_string() } else { document.title.clone() }; let (document_id, created_new) = model_handle.update(ctx, |model, model_ctx| { let (document_id, created_new) = model .get_or_create_streaming_document_for_create_documents( conversation_id, action_id, index, &title, document.content.clone(), file_link_resolution_context.clone(), model_ctx, ); if !created_new { model.apply_streamed_agent_update( &document_id, &title, &document.content, model_ctx, ); } (document_id, created_new) }); if created_new && !opened_first { ctx.emit(AIBlockEvent::OpenAIDocumentPane { document_id, document_version: AIDocumentVersion::default(), is_auto_open: true, }); opened_first = true; } } } fn calculate_renderable_action_index( &self, target_action_id: &AIAgentActionId, app: &AppContext, ) -> Option { let output = self.model.status(app).output_to_render()?; let output = output.get(); output.calculate_action_index(target_action_id) } fn handle_web_search_messages( &mut self, messages: &[AIAgentOutputMessage], ctx: &mut ViewContext, ) { for message in messages { // Check if this is a WebSearch message let AIAgentOutputMessageType::WebSearch(status) = &message.message else { continue; }; if let Some(view) = self.web_search_views.get(&message.id) { // Update existing view view.update(ctx, |view, ctx| { view.set_status(status); ctx.notify(); }); } else { let view = ctx.add_typed_action_view(|_ctx| { let mut view = WebSearchView::new(String::new()); view.set_status(status); view }); self.web_search_views.insert(message.id.clone(), view); ctx.notify(); } } } fn handle_web_fetch_messages( &mut self, messages: &[AIAgentOutputMessage], ctx: &mut ViewContext, ) { for message in messages { // Check if this is a WebFetch message let AIAgentOutputMessageType::WebFetch(status) = &message.message else { continue; }; if let Some(view) = self.web_fetch_views.get(&message.id) { // Update existing view view.update(ctx, |view, ctx| { view.set_status(status); ctx.notify(); }); } else { let view = ctx.add_typed_action_view(|_ctx| { let mut view = WebFetchView::new(Vec::new()); view.set_status(status); view }); self.web_fetch_views.insert(message.id.clone(), view); ctx.notify(); } } } fn handle_summarization_messages( &mut self, messages: &[AIAgentOutputMessage], ctx: &mut ViewContext, ) { use crate::ai::agent::SummarizationType; for message in messages { let AIAgentOutputMessageType::Summarization { finished_duration, summarization_type, .. } = &message.message else { continue; }; if !matches!(summarization_type, SummarizationType::ConversationSummary) { continue; } if let Some(view) = self.summarization_views.get(&message.id) { if finished_duration.is_some() { view.update(ctx, |view, ctx| { view.mark_finished(); ctx.notify(); }); } } else { let is_finished = finished_duration.is_some(); let view = ctx.add_view(|ctx| { let mut v = SummarizationView::new(ctx); if is_finished { v.mark_finished(); } v }); self.summarization_views.insert(message.id.clone(), view); ctx.notify(); } } } /// Creates a SummarizationView when the exchange input is a SummarizeConversation. /// This handles the Bedrock path where no Summarization output message is emitted. fn maybe_create_summarization_view_from_input(&mut self, ctx: &mut ViewContext) { let is_summarize_input = self .model .inputs_to_render(ctx) .iter() .any(|i| matches!(i, AIAgentInput::SummarizeConversation { .. })); if !is_summarize_input { return; } let key = MessageId::new("__summarization_inline_view__".to_string()); if self.summarization_views.contains_key(&key) { // Already created — check if we should mark it finished let is_complete = !self.model.status(ctx).is_streaming(); if is_complete { if let Some(view) = self.summarization_views.get(&key) { view.update(ctx, |view, ctx| { if !view.is_finished { view.mark_finished(); ctx.notify(); } }); } } return; } let view = ctx.add_view(SummarizationView::new); self.summarization_views.insert(key, view); ctx.notify(); } /// Note this is called when the search codebase tool call definition finishes streaming, not when the search actually completes. fn handle_search_codebase_complete( &mut self, action_id: &AIAgentActionId, query: &str, repo_path: Option, _server_output_id: Option, ctx: &mut ViewContext, ) { if !FeatureFlag::SearchCodebaseUI.is_enabled() { return; } let Some(action_index) = self.calculate_renderable_action_index(action_id, ctx) else { return; }; let view = ctx.add_typed_action_view(|_ctx| { SearchCodebaseView::new( self.find_model.clone(), vec![], query.to_string(), repo_path, &self.shell_launch_data, &self.current_working_directory, action_index, ) }); // Subscribe to events from SearchCodebaseView and convert them to AIBlockActions ctx.subscribe_to_view(&view, |me, view, event, ctx| match event { #[cfg(feature = "local_fs")] SearchCodebaseViewEvent::OpenLinkTooltip { rich_content_link } => { let rich_content_link = match rich_content_link { RichContentLink::FilePath { absolute_path, line_and_column_num, .. } => RichContentLink::FilePath { absolute_path: absolute_path.clone(), line_and_column_num: *line_and_column_num, target_override: me.detected_file_path_target_override(absolute_path), }, RichContentLink::Url(url) => RichContentLink::Url(url.clone()), }; ctx.emit(AIBlockEvent::ShowLinkTooltip(RichContentLinkTooltipInfo { link: rich_content_link, position_id: RICH_CONTENT_LINK_FIRST_CHAR_POSITION_ID.to_owned(), })); ctx.notify(); } #[cfg(not(feature = "local_fs"))] SearchCodebaseViewEvent::OpenLinkTooltip { rich_content_link } => { ctx.emit(AIBlockEvent::ShowLinkTooltip(RichContentLinkTooltipInfo { link: rich_content_link.clone(), position_id: RICH_CONTENT_LINK_FIRST_CHAR_POSITION_ID.to_owned(), })); ctx.notify(); } #[cfg(feature = "local_fs")] SearchCodebaseViewEvent::OpenDetectedFilePath { absolute_path, line_and_column_num, } => { ctx.emit(AIBlockEvent::OpenDetectedFilePath { absolute_path: absolute_path.clone(), line_and_column_num: *line_and_column_num, target_override: me.detected_file_path_target_override(absolute_path), }); } SearchCodebaseViewEvent::TextSelected => { me.clear_other_selections(Some(view.id()), ctx.window_id(), ctx); ctx.emit(AIBlockEvent::ChildViewTextSelected); } }); self.search_codebase_view.insert(action_id.clone(), view); // Initialize the view with the action status and file contexts from the action model, if populated. // The action is not expected to exist already in live conversations since search is just beginning, // but it's not incorrect to populate if it is, and we rely on this for // for restored conversations because action model events don't re-fire // after the view is created. let action_status = self .action_model .as_ref(ctx) .get_action_status(self.client_ids.conversation_id, action_id); if let Some(view) = self.search_codebase_view.get(action_id) { let files = if let Some(AIActionStatus::Finished(ref result)) = action_status { if let AIAgentActionResultType::SearchCodebase(SearchCodebaseResult::Success { files, }) = &result.result { Some(files.clone()) } else { None } } else { None }; if let Some(files) = files { let find_state = self.find_state.clone(); view.update(ctx, |view, ctx| { view.update_render_read_file_args(&find_state, files, action_status); ctx.notify(); }); } else if action_status.is_some() { view.update(ctx, |view, ctx| { view.update_status(action_status); ctx.notify(); }); } } ctx.notify(); } /// Creates the AWS Bedrock credentials error view if the error is `AwsBedrockCredentialsExpiredOrInvalid` /// and we don't already have one. If auto-login is enabled, automatically runs the login command. fn maybe_create_aws_bedrock_credentials_error_view( &mut self, error: &RenderableAIError, ctx: &mut ViewContext, ) { // Only create the view for AWS Bedrock credentials errors let RenderableAIError::AwsBedrockCredentialsExpiredOrInvalid { model_name } = error else { return; }; // Don't recreate if we already have a view if self.aws_bedrock_credentials_error_view.is_some() { return; } let ai_settings = AISettings::as_ref(ctx); let login_command = ai_settings.bedrock_auth_refresh_command.value().clone(); let auto_login_enabled = *ai_settings.bedrock_auto_login.value(); // If auto-login is enabled, run the login command automatically if auto_login_enabled { ctx.emit(AIBlockEvent::RunAwsLoginCommand); } let model_name = model_name.clone(); let view = ctx.add_typed_action_view(|ctx| { AwsBedrockCredentialsErrorView::new(model_name, login_command, auto_login_enabled, ctx) }); // Subscribe to events from the view and emit AIBlockEvents directly // Note: We emit events here rather than dispatch actions because we're in a // subscription callback where the context is already for AIBlock ctx.subscribe_to_view(&view, |_me, _view, event, ctx| match event { AwsBedrockCredentialsErrorEvent::RunLoginCommand => { ctx.emit(AIBlockEvent::RunAwsLoginCommand); } AwsBedrockCredentialsErrorEvent::ConfigureLoginCommand => { ctx.dispatch_typed_action(&WorkspaceAction::ShowSettingsPageWithSearch { search_query: "aws bedrock".to_string(), section: Some(SettingsSection::WarpAgent), }); } }); self.aws_bedrock_credentials_error_view = Some(view); ctx.notify(); } pub fn accept_pending_unit_test_suggestion( &mut self, interaction_source: InteractionSource, ctx: &mut ViewContext, ) -> bool { let Some(suggested_prompt) = self.pending_unit_test_suggestion(ctx) else { return false; }; self.accept_unit_test_suggestion(suggested_prompt.clone(), interaction_source, ctx) } pub fn dismiss_pending_suggested_prompt( &mut self, interaction_source: InteractionSource, ctx: &mut ViewContext, ) -> bool { let Some(suggested_prompt) = self.pending_unit_test_suggestion(ctx) else { return false; }; let identifiers = suggested_prompt.as_ref(ctx).identifiers().clone(); // Complete the suggest prompt executor with Cancelled so the async action // finishes cleanly (the action auto-executes and is no longer in pending_actions). self.action_model.update(ctx, |action_model, ctx| { let executor = action_model.suggest_prompt_executor(ctx).clone(); executor.update(ctx, |executor, _ctx| { executor.complete_suggest_prompt_action(SuggestPromptResult::Cancelled); }); }); // Hide the view so pending_unit_test_suggestion() won't find it again, // preventing a double-dismiss race from emitting DismissedPassiveBlock twice. suggested_prompt.clone().update(ctx, |view, _ctx| { view.set_is_hidden(true); }); send_telemetry_from_ctx!( TelemetryEvent::UnitTestSuggestionCancelled { identifiers, interaction_source, }, ctx ); ctx.emit(AIBlockEvent::DismissedPassiveBlock); true } fn accept_unit_test_suggestion( &mut self, view: ViewHandle, interaction_source: InteractionSource, ctx: &mut ViewContext, ) -> bool { let Some(query) = view.as_ref(ctx).query() else { return false; }; if FeatureFlag::AgentView.is_enabled() && self .agent_view_controller .update(ctx, |controller, ctx| { controller.try_enter_agent_view( Some(self.client_ids.conversation_id), AgentViewEntryOrigin::AcceptedUnitTestSuggestion, ctx, ) }) .is_err() { return false; } let action_id = view.as_ref(ctx).action_id().clone(); self.action_model.update(ctx, |action_model, ctx| { action_model.execute_action(&action_id, self.client_ids.conversation_id, ctx); let executor = action_model.suggest_prompt_executor(ctx).clone(); executor.update(ctx, |executor, _ctx| { executor.complete_suggest_prompt_action(SuggestPromptResult::Accepted { query }); }); }); // When accepted, we only want to hide the banner portion of the exchange. view.update(ctx, |view, _ctx| { view.set_is_hidden(true); }); let identifiers = view.as_ref(ctx).identifiers().clone(); let query = view.as_ref(ctx).query().unwrap_or_default(); let should_collect_ugc = should_collect_ai_ugc_telemetry(ctx, PrivacySettings::as_ref(ctx).is_telemetry_enabled); let redacted_query = if should_collect_ugc { let mut redacted_query = query.clone(); redact_secrets(&mut redacted_query); Some(redacted_query) } else { None }; send_telemetry_from_ctx!( TelemetryEvent::UnitTestSuggestionAccepted { identifiers, query: redacted_query, interaction_source, }, ctx ); ctx.notify(); true } fn handle_unit_test_suggestion_complete( &mut self, action_id: &AIAgentActionId, server_output_id: Option<&ServerOutputId>, query: String, title: String, description: String, ctx: &mut ViewContext, ) { // Short-circuit if we've already handled the suggested prompt correspoding to this action // id. if self.unit_tests_suggestions.contains_key(action_id) { return; } let identifiers = AIIdentifiers { client_conversation_id: Some(self.client_ids.conversation_id), client_exchange_id: Some(self.client_ids.client_exchange_id), server_output_id: server_output_id.cloned(), server_conversation_id: None, model_id: self.model.model_id(ctx), }; // Only show the speedbump once, update the setting afterwards. let should_show_speedbump = self .model .request_type(ctx) .is_passive_unit_test_suggestion() && UserWorkspaces::as_ref(ctx).is_code_suggestions_toggleable() && AISettings::as_ref(ctx).show_code_suggestion_speedbump(ctx); if should_show_speedbump { AISettings::handle(ctx).update(ctx, |settings, ctx| { if let Err(e) = settings .show_code_suggestion_speedbump .set_value(false, ctx) { log::error!("Failed to persist 'Show code suggestion speedbump' setting: {e}"); } }); } let view = ctx.add_typed_action_view(|ctx| { SuggestedUnitTestsView::new( identifiers.clone(), action_id.clone(), query, title, description, should_show_speedbump, ctx, ) }); let action_id_clone = action_id.clone(); ctx.subscribe_to_view(&view, move |me, view, event, ctx| { me.handle_suggested_prompt_view_event(&action_id_clone, event, view, ctx); }); self.unit_tests_suggestions.insert(action_id.clone(), view); BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { history.set_exchange_hidden_status( self.terminal_view_id, self.client_ids.conversation_id, self.client_ids.client_exchange_id, false, ctx, ); }); self.terminal_model .lock() .block_list_mut() .mark_rich_content_dirty(ctx.view_id()); ctx.notify(); send_telemetry_from_ctx!(TelemetryEvent::UnitTestSuggestionShown { identifiers }, ctx); } fn handle_suggested_prompt_view_event( &mut self, action_id: &AIAgentActionId, event: &SuggestedUnitTestsEvent, view: ViewHandle, ctx: &mut ViewContext, ) { // Short-circuit if this is no longer a suggested prompt we're tracking. if !self.unit_tests_suggestions.contains_key(action_id) { return; } match event { SuggestedUnitTestsEvent::Accept => { self.accept_unit_test_suggestion(view, InteractionSource::Button, ctx); } SuggestedUnitTestsEvent::Cancel => { self.dismiss_pending_suggested_prompt(InteractionSource::Button, ctx); } SuggestedUnitTestsEvent::Blur => { ctx.emit(AIBlockEvent::FocusTerminal); } SuggestedUnitTestsEvent::OpenSettings => { ctx.emit(AIBlockEvent::OpenSettings); } } } #[cfg(feature = "integration_tests")] pub fn selection_type(&self) -> SelectionType { self.state_handles.selection_handle.selection_type() } /// Handles find match focus changes by auto-expanding collapsed reasoning blocks /// that contain the focused match. /// The number of cached find matches for this AI block. Test-only; used to /// assert that find highlights are cleared when the find bar closes. #[cfg(test)] pub(crate) fn find_match_count(&self) -> usize { self.find_state.match_count() } fn handle_find_match_focus_change(&mut self, ctx: &mut ViewContext) { // Get the currently focused match ID from the terminal's find model. // The helper handles both the sync and async find paths. let Some(match_id) = self.find_model.as_ref(ctx).focused_rich_content_match_id() else { return; }; // Resolve the match ID to a location in this block's find state let Some(match_location) = self.find_state.location_for_match(match_id) else { return; }; // If the match has a message ID, expand that message's block if it's collapsed let Some(message_id) = &match_location.message_id else { return; }; if let Some(state) = self.collapsible_block_states.get_mut(message_id) { if let CollapsibleExpansionState::Collapsed = state.expansion_state { state.expand(); ctx.notify(); } } } } impl AIBlock { pub fn conversation_id(&self) -> AIConversationId { self.client_ids.conversation_id } /// Reverts all file diffs (CodeDiffViews) in this AIBlock, from newest to oldest (order matters) pub fn revert_all_diffs(&mut self, ctx: &mut ViewContext) { for edit in self.requested_edits.values().rev() { edit.view.update(ctx, |diff_view, ctx| { diff_view.handle_action(&CodeDiffViewAction::RevertChanges, ctx); }); } } pub fn response_stream_id(&self) -> Option<&ResponseStreamId> { self.client_ids.response_stream_id.as_ref() } pub fn current_working_directory(&self) -> Option<&String> { self.current_working_directory.as_ref() } pub fn output_status(&self, app: &AppContext) -> AIBlockOutputStatus { self.model.status(app) } /// Returns `true` if this AI block contains user input. pub fn has_user_input(&self, app: &AppContext) -> bool { self.model .inputs_to_render(app) .iter() .any(|input| input.display_query().is_some()) } /// `true` if the AI block is "finished". /// An AI block is not finished if it has any pending requested command(s) or pending /// requested action(s) that are still receiving output. pub fn is_finished(&self) -> bool { self.finish_reason.is_some() } pub fn is_manually_cancelled(&self) -> bool { self.finish_reason == Some(FinishReason::Cancelled) } pub fn is_complete(&self) -> bool { self.finish_reason == Some(FinishReason::Complete) } pub fn finish_reason(&self) -> Option { self.finish_reason } pub fn is_restored(&self) -> bool { self.model.is_restored() } /// Returns the rich-content link currently hovered by the mouse, if any. /// /// This is used to build a link-specific right-click context menu (e.g. "Copy URL") when the /// user right-clicks a hyperlink rendered inside an AI response. Returns `None` if the mouse /// is not over a detected link. pub fn hovered_rich_content_link(&self) -> Option { let hovered = self .detected_links_state .currently_hovered_link_location .as_ref()?; let link_type = self .detected_links_state .link_at(&hovered.location, &hovered.link_range)?; let rich_content_link = match link_type { DetectedLinkType::Url(link) => RichContentLink::Url(link.clone()), #[cfg(feature = "local_fs")] DetectedLinkType::FilePath { absolute_path, line_and_column_num, } => RichContentLink::FilePath { absolute_path: absolute_path.to_owned(), line_and_column_num: *line_and_column_num, target_override: self.detected_file_path_target_override(absolute_path), }, }; Some(rich_content_link) } /// `true` if the AI output in the block finished streaming. /// /// Note that this is different from `is_finished` since user could still have pending /// actions to execute. pub fn is_ai_output_complete(&self, app: &AppContext) -> bool { self.model.status(app).is_complete() } /// Returns `true` if the block contains any actions that are blocked on user confirmation. pub fn is_blocked_on_user_confirmation(&self, app: &AppContext) -> bool { self.requested_action_ids .iter() .filter_map(|id| { self.action_model .as_ref(app) .get_action_status(self.client_ids.conversation_id, id) }) .any(|status| status.is_blocked()) } /// Returns the server output ID for this AI block, if available. pub fn server_output_id(&self, app: &AppContext) -> Option { self.model.server_output_id(app) } fn finish(&mut self, finish_reason: FinishReason, ctx: &mut ViewContext) { if self.finish_reason.is_some() { return; } self.finish_reason = Some(finish_reason); ctx.emit(AIBlockEvent::Finished); } /// Registers a subscription on the `BlocklistAIActionModel` that updates view state in response /// to AI action updates. fn register_action_model_subscription( action_model: &ModelHandle, ctx: &mut ViewContext, ) { ctx.subscribe_to_model(action_model, |me, action_model, event, ctx| { let action_id = event.action_id(); if event .conversation_id() .is_some_and(|conversation_id| conversation_id != me.client_ids.conversation_id) || me.is_finished() || !me.requested_action_ids.contains(action_id) { // Technically, this subscription should be unregistered after `is_finished` is // set to true, but it seems that the callback is called once more after the `unsubscribe_to_model` // call, so early return here if this is errantly being called. return; } match event { BlocklistAIActionEvent::ExecutingAction { .. } => { match &me.autonomy_setting_speedbump { AutonomySettingSpeedbump::ShouldShowForAutoexecutingReadonlyCommands { action_id: speedbump_action_id, shown, checked, .. } if speedbump_action_id == action_id && *shown.lock() => { BlocklistAIPermissions::handle(ctx).update(ctx, |permissions, ctx| { report_if_error!(permissions .set_should_autoexecute_readonly_commands(*checked, ctx)); }); } AutonomySettingSpeedbump::ShouldShowForFileAccess { action_id: speedbump_action_id, shown, checked, .. } if speedbump_action_id == action_id && *shown.lock() => { let permission = if *checked { AgentModeCodingPermissionsType::AlwaysAllowReading } else { AgentModeCodingPermissionsType::AlwaysAskBeforeReading }; BlocklistAIPermissions::handle(ctx).update(ctx, |permissions, ctx| { report_if_error!( permissions.set_coding_permissions(permission, ctx) ); }); } AutonomySettingSpeedbump::ShouldShowForCodebaseSearchFileAccess { action_id: speedbump_action_id, shown, selected_option, .. } if speedbump_action_id == action_id && *shown.lock() => { let Some(root_repo_path) = me .action_model .as_ref(ctx) .search_codebase_executor(ctx) .as_ref(ctx) .root_repo_for_action(action_id) .map(Path::to_owned) else { return; }; let permission = match selected_option { Some(0) => AgentModeCodingPermissionsType::AlwaysAllowReading, Some(1) => { AgentModeCodingPermissionsType::AllowReadingSpecificFiles } _ => AgentModeCodingPermissionsType::AlwaysAskBeforeReading, }; BlocklistAIPermissions::handle(ctx).update(ctx, |permissions, ctx| { report_if_error!( permissions.set_coding_permissions(permission, ctx) ); if matches!( permission, AgentModeCodingPermissionsType::AllowReadingSpecificFiles ) { report_if_error!(permissions .add_filepath_to_code_read_allowlist(root_repo_path, ctx)); } }); } _ => {} } } BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { .. } => { ctx.emit(AIBlockEvent::ActionBlockedOnUserConfirmation); } BlocklistAIActionEvent::FinishedAction { action_id, .. } => { me.abort_auto_expand_requested_command_timer(); // Handle auto-collapsing of requested commands that were marked for auto-collapse // Skip collapsing for interrupted commands (exit code 130) so user can see partial output if let Some(requested_command) = me .requested_commands_to_auto_collapse .contains(action_id) .then(|| me.requested_commands.get(action_id)) .flatten() { let should_collapse = action_model .as_ref(ctx) .get_action_result(me.client_ids.conversation_id, action_id) .is_none_or(|result| match &result.result { AIAgentActionResultType::RequestCommandOutput( RequestCommandOutputResult::Completed { exit_code, .. }, ) => exit_code.value() != 130, // Keep original expansion state on ctrl-c during long running command _ => true, // Collapse for other results }); // Default to collapse if can't determine if should_collapse { requested_command.force_collapse(ctx); } me.requested_commands_to_auto_collapse.remove(action_id); } if let Some(view) = me.search_codebase_view.get(action_id) { let new_status = action_model .as_ref(ctx) .get_action_status(me.client_ids.conversation_id, action_id); view.update(ctx, |view, ctx| { view.update_status(new_status); ctx.notify(); }); } // Create subagent panel state for finished StartAgent actions if let Some(AIActionStatus::Finished(result)) = action_model .as_ref(ctx) .get_action_status(me.client_ids.conversation_id, action_id) { if let AIAgentActionResultType::StartAgent( crate::ai::agent::StartAgentResult::Success { agent_id, .. }, ) = &result.result { if !me.state_handles.subagent_panel_states.contains_key(action_id) { if let Some(conversation_id) = crate::ai::blocklist::agent_view::orchestration_conversation_links::conversation_id_for_agent_id(agent_id, ctx) { me.state_handles.subagent_panel_states.insert( action_id.clone(), super::agent_view::subagent_inline_panel::SubagentPanelState::new(conversation_id), ); } } } } let action_statuses = me .requested_action_ids .iter() .filter_map(|id| { action_model .as_ref(ctx) .get_action_status(me.client_ids.conversation_id, id) }) .collect_vec(); // Detecting links on SearchCodebase tool call outputs for (action_index, status) in action_statuses.iter().enumerate() { let AIActionStatus::Finished(result) = status else { continue; }; if let AIAgentActionResultType::SearchCodebase( SearchCodebaseResult::Success { files }, ) = &result.result { if !FeatureFlag::SearchCodebaseUI.is_enabled() { for (line_index, file) in files.iter().enumerate() { let text_location = TextLocation::Action { action_index, line_index, }; detect_links( &mut me.detected_links_state, &file.to_string(), text_location, me.current_working_directory.as_ref(), me.shell_launch_data.as_ref(), ); } } if let Some(view) = me.search_codebase_view.get(action_id) { view.update(ctx, |view, ctx| { view.update_render_read_file_args( &me.find_state, files.clone(), action_model .as_ref(ctx) .get_action_status(me.client_ids.conversation_id, action_id), ); ctx.notify(); }) } } } // Open the AI document pane when documents are created or edited if let Some(action_result) = action_model .as_ref(ctx) .get_action_result(me.client_ids.conversation_id, action_id) { match &action_result.result { AIAgentActionResultType::CreateDocuments( CreateDocumentsResult::Success { created_documents }, ) => { if let Some(first_doc) = created_documents.first() { ctx.emit(AIBlockEvent::OpenAIDocumentPane { document_id: first_doc.document_id, document_version: first_doc.document_version, is_auto_open: true, }); } } AIAgentActionResultType::EditDocuments( EditDocumentsResult::Success { updated_documents }, ) => { if let Some(first_doc) = updated_documents.first() { ctx.emit(AIBlockEvent::OpenAIDocumentPane { document_id: first_doc.document_id, document_version: first_doc.document_version, is_auto_open: true, }); } } _ => {} } } if action_statuses.iter().any(AIActionStatus::is_blocked) && !me.is_hidden(ctx) { // TODO (suraj): figure out focus behaviour for multi-action responses. me.try_steal_focus(ctx); ctx.emit(AIBlockEvent::ActionFinished); } else if action_statuses .iter() .all(|action| action.is_cancelled_during_requested_command_execution()) { me.finish(FinishReason::CancelledDuringRequestedCommandExecution, ctx); ctx.unsubscribe_to_model(&action_model); } else if action_statuses.iter().all(|action| action.is_cancelled()) { me.finish(FinishReason::Cancelled, ctx); ctx.unsubscribe_to_model(&action_model); } else { // Only finish the block when all tool calls in this block are done. if action_statuses.iter().all(|s| s.is_done()) { me.finish(FinishReason::Complete, ctx); ctx.unsubscribe_to_model(&action_model); } } ctx.notify(); } BlocklistAIActionEvent::QueuedAction { action_id, .. } => { // Update search codebase view status when action is queued if let Some(view) = me.search_codebase_view.get(action_id) { view.update(ctx, |view, ctx| { view.update_status(Some(AIActionStatus::Queued)); ctx.notify(); }); } ctx.notify(); } BlocklistAIActionEvent::InsertCodeReviewComments { action_id, repo_path, comments, base_branch, } => { if FeatureFlag::PRCommentsV2.is_enabled() { me.handle_insert_code_review_comments( action_id.clone(), repo_path, comments, base_branch.as_deref(), ctx, ); ctx.notify(); } } BlocklistAIActionEvent::ToolLifecycle { .. } | BlocklistAIActionEvent::InitProject(_) | BlocklistAIActionEvent::ToggleCodeReview(_) => {} } }); } /// Registers the direct-provider child linkage needed to render a live /// StartAgent panel while the action is still waiting for child output. fn register_start_agent_executor_subscription( action_model: &ModelHandle, ctx: &mut ViewContext, ) { let start_agent_executor = action_model.as_ref(ctx).start_agent_executor(ctx); ctx.subscribe_to_model(&start_agent_executor, |me, _, event, ctx| { let StartAgentExecutorEvent::DirectProviderChildConversationCreated { action_id, parent_conversation_id, child_conversation_id, } = event else { return; }; if me.client_ids.conversation_id != *parent_conversation_id || !me.requested_action_ids.contains(action_id) { return; } me.state_handles .subagent_panel_states .entry(action_id.clone()) .or_insert_with(|| { super::agent_view::subagent_inline_panel::SubagentPanelState::new( *child_conversation_id, ) }); ctx.notify(); }); } /// Cleans up state for this block, to be called before the block is `Drop`ped (e.g. deleted from the blocklist). pub fn cleanup_block(&mut self, ctx: &mut ViewContext) { if self.is_finished() { return; } self.controller.update(ctx, |controller, ctx| { controller.cancel_conversation_progress( self.client_ids.conversation_id, CancellationReason::ManuallyCancelled, ctx, ) }); self.finish(FinishReason::Cancelled, ctx); } fn handle_insert_code_review_comments( &mut self, action_id: AIAgentActionId, repo_path: &Path, // TODO: this should be migrated to str comments: &[InsertReviewComment], base_branch: Option<&str>, ctx: &mut ViewContext, ) { let Some(repo_location) = self .active_session .as_ref(ctx) .location_for_path(repo_path.to_string_lossy().as_ref(), ctx) else { log::warn!( "Cannot import review comments for repo path without an active session location: {}", repo_path.display() ); return; }; let raw_count = comments.len(); let pending = convert_insert_review_comments(comments); let converted_count = pending.len(); let flattened = attach_pending_imported_comments(pending, &repo_location); let thread_count = flattened.len(); if !self.model.is_restored() { send_telemetry_from_ctx!( CodeReviewTelemetryEvent::CommentsReceived { is_local: Some(repo_location.is_local()), raw_count, converted_count, thread_count, }, ctx ); } let cards: Vec = flattened .into_iter() .map(|comment| { CommentViewCard::new(comment, true, true, None, Some(&repo_location), ctx) }) .collect(); let element_states = cards .iter() .enumerate() .map(|(comment_index, card)| { let html_url = match &card.source().origin { CommentOrigin::ImportedFromGitHub(details) => details.html_url.clone(), CommentOrigin::Native => None, }; ImportedCommentElementState::new(action_id.clone(), comment_index, html_url, ctx) }) .collect(); let base_branch = base_branch.map(String::from); if !cards.is_empty() { self.has_imported_comments = true; } self.imported_comments.insert( action_id, ImportedCommentGroup::new(repo_location, base_branch, cards, element_states), ); self.update_imported_comments_disabled_state(ctx); } fn cancel_action(&mut self, action_id: &AIAgentActionId, ctx: &mut ViewContext) { self.action_model.update(ctx, |action_model, ctx| { action_model.cancel_action_with_id( self.client_ids.conversation_id, action_id, CancellationReason::ManuallyCancelled, ctx, ) }); } fn yield_requested_action_focus_if_focused( &self, view: &ViewHandle, ctx: &mut ViewContext, ) { if view.is_self_or_child_focused(ctx) { ctx.emit(AIBlockEvent::FocusTerminal); } } /// Tries to focus the AI block or one of its parts, if applicable. /// If the block doesn't need to be focused, focus is yielded /// back to the owning [`TerminalView`]. /// /// WARNING: take care to only use this API when you are sure the AI block or its /// children should steal focus (e.g. on user action). For example, be careful /// not to steal focus away from another terminal session just because a requested /// command needs attention. pub fn try_steal_focus(&self, ctx: &mut ViewContext) { if self.is_hidden(ctx) { return; } // If there's a blocking passive code diff, focus that. // We special case this since get_pending_action only focuses on active conversations, // and passive code diffs are not part of an active conversation, when they initially appear. if self.model.request_type(ctx).is_passive_code_diff() { if let Some(diff) = self.find_undismissed_code_diff(ctx) { ctx.focus(&diff.view); return; } } if self .model .request_type(ctx) .is_passive_unit_test_suggestion() && self.pending_unit_test_suggestion(ctx).is_some() { ctx.emit(AIBlockEvent::FocusTerminal); return; } if self.focus_subview_if_necessary(ctx) { return; } let should_focus_block = !self.requested_action_ids.is_empty() || self.keyboard_navigable_buttons.is_some(); if should_focus_block { // Otherwise, just focus the block. ctx.focus_self(); } else { // Else, delegate focus back to the terminal view. ctx.emit(AIBlockEvent::FocusTerminal); } } pub fn focus_subview_if_necessary(&self, ctx: &mut ViewContext) -> bool { let mut did_focus_subview = false; let pending_action_id = self .action_model .as_ref(ctx) .get_pending_action(ctx) .map(|a| &a.id); if let Some(diff) = pending_action_id.and_then(|id| self.requested_edits.get(id)) { // If there's a blocking code diff, focus that. ctx.focus(&diff.view); did_focus_subview = true; } else if let Some(command) = pending_action_id.and_then(|id| self.requested_commands.get(id)) { // If there's a blocking requested command, focus that. ctx.focus(&command.view); did_focus_subview = true; } else if let Some(mcp_tool) = pending_action_id.and_then(|id| self.requested_mcp_tools.get(id)) { // If there's a blocking MCP tool call, focus that. ctx.focus(&mcp_tool.view); did_focus_subview = true; } else if let Some(ask_user_question_view) = self.ask_user_question_view.as_ref().filter(|view| { pending_action_id.is_some_and(|id| { let view = view.as_ref(ctx); view.action_id() == id && view.is_editing() }) }) { ctx.focus(ask_user_question_view); did_focus_subview = true; } else if let Some(card_view) = pending_action_id.and_then(|id| self.run_agents_card_views.get(id)) { // If there's a blocking RunAgents card, focus it so its // own keybindings (`enter -> Accept`, `cmdorctrl-e -> // ToggleEdit`, etc.) resolve. ctx.focus(card_view); did_focus_subview = true; } else if let Some(keyboard_navigable_buttons) = self.keyboard_navigable_buttons.as_ref() { // If there's buttons to take action on, focus those. ctx.focus(keyboard_navigable_buttons); did_focus_subview = true; } did_focus_subview } /// Returns the currently selected text within the entire `AIBlock` view sub-hierarchy. /// There **shouldn't** be more than one instance of selected text at any given time across /// any view within the same `AIBlock` view sub-hierarchy. pub fn selected_text(&self, ctx: &AppContext) -> Option { self.code_editor_views .iter() .find_map(|editor_view| editor_view.view.as_ref(ctx).selected_text(ctx)) .or_else(|| { self.requested_commands .values() .find_map(|command| command.view.as_ref(ctx).selected_text(ctx)) }) .or_else(|| { self.requested_mcp_tools .values() .find_map(|tool| tool.view.as_ref(ctx).selected_text(ctx)) }) .or_else(|| { self.requested_edits .values() .find_map(|edit| edit.view.as_ref(ctx).selected_text(ctx)) }) .or_else(|| { self.search_codebase_view .values() .find_map(|search_view| search_view.as_ref(ctx).selected_text(ctx)) }) .or_else(|| { self.comment_states .values() .find_map(|comment| comment.rich_text_editor.as_ref(ctx).selected_text(ctx)) }) .or_else(|| self.selected_text.read().clone()) .filter(|selection| !selection.is_empty()) } /// Test-only helper to set the block-level text selection, which is normally /// written by the `SelectableArea` selection callback during a drag. #[cfg(any(test, feature = "integration_tests"))] pub fn set_block_level_selected_text_for_test(&self, text: Option) { *self.selected_text.write() = text; } /// Test-only helper that simulates a block-level text selection in this AI /// block: it writes the selected text and emits the same /// [`AIBlockEvent::SelectionChanged`] signal that /// [`AIBlockAction::SelectText`] does, so the terminal view mirrors it into /// the model's rich content selection (which the copy/insert paths read). /// /// This lets integration tests exercise the in-AI-block copy path without /// depending on layout-sensitive pixel coordinates. #[cfg(any(test, feature = "integration_tests"))] pub fn simulate_text_selection_for_test( &mut self, text: Option, ctx: &mut ViewContext, ) { *self.selected_text.write() = text; ctx.emit(AIBlockEvent::SelectionChanged); } /// Start a selection at the top left corner of the block's SelectableArea. pub fn start_selection_at_min_point(&self, selection_type: SelectionType, x_pos: Option) { self.state_handles.selection_handle.start_selection_outside( match x_pos { Some(x_bound) => SelectionBound::Top { x_bound }, None => SelectionBound::TopLeft, }, selection_type, ) } /// Start a selection at the bottom right corner of the block's SelectableArea. pub fn start_selection_at_max_point(&self, selection_type: SelectionType, x_pos: Option) { self.state_handles.selection_handle.start_selection_outside( match x_pos { Some(x_bound) => SelectionBound::Bottom { x_bound }, None => SelectionBound::BottomRight, }, selection_type, ) } /// Clears all text selections in all components within this `AIBlock`'s view sub-hierarchy. /// This includes the `AIBlock` level and all child views (code blocks, etc.). pub fn clear_all_selections(&mut self, ctx: &mut ViewContext) { self.clear_other_selections(None, ctx.window_id(), ctx); self.clear_block_level_selection(); } /// Clears text selections at the `AIBlock` level (e.g. selected reasoning paragraph text). /// This does _not_ clear the selection of the child views (code blocks, etc.)! fn clear_block_level_selection(&mut self) { self.state_handles.selection_handle.clear(); *self.selected_text.write() = None; } /// Clears all text selections in all components within this `AIBlock`'s view sub-hierarchy /// _other_ than the one that triggered a selection change. /// /// Call this after text is selected in one part of the AI block (e.g. a code diff), to ensure /// that there's only one active selection at a time. fn clear_other_selections( &mut self, source_view_id: Option, source_window_id: WindowId, ctx: &mut ViewContext, ) { if source_window_id != ctx.window_id() { return; } for editor_view in self.code_editor_views.iter() { // Don't clear selections for the view that triggered this change. if source_view_id.is_some_and(|entity_id| editor_view.view.id() == entity_id) && editor_view.view.window_id(ctx) == source_window_id { continue; } editor_view .view .update(ctx, |view, ctx| view.clear_selection(ctx)); } for command in self.requested_commands.values() { // Don't clear selections for the requested command view that triggered this change. if source_view_id.is_some_and(|entity_id| command.view.id() == entity_id) && command.view.window_id(ctx) == source_window_id { continue; } command .view .update(ctx, |view, ctx| view.clear_selection(ctx)); } for mcp_tool in self.requested_mcp_tools.values() { // Don't clear selections for the MCP tool view that triggered this change. if source_view_id.is_some_and(|entity_id| mcp_tool.view.id() == entity_id) && mcp_tool.view.window_id(ctx) == source_window_id { continue; } mcp_tool .view .update(ctx, |view, ctx| view.clear_selection(ctx)); } for diff in self.requested_edits.values() { // Don't clear selections for the diff view that triggered this change. if source_view_id.is_some_and(|entity_id| diff.view.id() == entity_id) && diff.view.window_id(ctx) == source_window_id { continue; } diff.view .update(ctx, |view, ctx| view.clear_all_selections(ctx)); } for search_view in self.search_codebase_view.values() { // Don't clear selections for the search codebase view that triggered this change. if source_view_id.is_some_and(|entity_id| search_view.id() == entity_id) && search_view.window_id(ctx) == source_window_id { continue; } search_view.update(ctx, |view, ctx| view.clear_selection(ctx)); } for comment in self.comment_states.values() { if source_view_id.is_some_and(|entity_id| comment.rich_text_editor.id() == entity_id) && comment.rich_text_editor.window_id(ctx) == source_window_id { continue; } comment .rich_text_editor .update(ctx, |view, ctx| view.clear_text_selection(ctx)); } // If the event was dispatched by a nested view (i.e. code block, requested command, etc.), // clear the text selection at the `AIBlock` level (outside the code block). // We want to have only 1 selection active at any one point in time. if source_view_id.is_some() { self.clear_block_level_selection(); } } pub fn dismiss_ai_tooltips(&mut self, ctx: &mut ViewContext) { self.detected_links_state.link_location_open_tooltip = None; ctx.emit(AIBlockEvent::DismissLinkTooltip); self.secret_redaction_state.dismiss_tooltip(); ctx.emit(AIBlockEvent::DismissSecretTooltip); for search_view in self.search_codebase_view.values() { search_view.update(ctx, |view, ctx| { view.clear_link_tooltip(ctx); }); } // The hover state for the "open" button in linked code blocks should be reset on a focus change. for button_handles in self .state_handles .normal_response_code_snippet_buttons .iter() { button_handles.reset_hover_state_on_focus_change(); } ctx.notify(); } fn open_link( &self, location: &TextLocation, link_range: &Range, ctx: &mut ViewContext, ) { match self.detected_links_state.link_at(location, link_range) { Some(DetectedLinkType::Url(link)) => { ctx.open_url(link); } #[cfg(feature = "local_fs")] Some(DetectedLinkType::FilePath { absolute_path, line_and_column_num, }) => ctx.emit(AIBlockEvent::OpenDetectedFilePath { absolute_path: absolute_path.clone(), line_and_column_num: *line_and_column_num, target_override: self.detected_file_path_target_override(absolute_path), }), None => (), } } fn show_link_tooltip( &mut self, location: &TextLocation, link_range: &Range, ctx: &mut ViewContext, ) { let Some(link_type) = self.detected_links_state.link_at(location, link_range) else { return; }; let rich_content_link = match link_type { DetectedLinkType::Url(link) => RichContentLink::Url(link.clone()), #[cfg(feature = "local_fs")] DetectedLinkType::FilePath { absolute_path, line_and_column_num, } => RichContentLink::FilePath { absolute_path: absolute_path.to_owned(), line_and_column_num: *line_and_column_num, target_override: self.detected_file_path_target_override(absolute_path), }, }; self.detected_links_state.link_location_open_tooltip = Some(LinkLocation { link_range: link_range.clone(), location: *location, }); ctx.emit(AIBlockEvent::ShowLinkTooltip(RichContentLinkTooltipInfo { link: rich_content_link, position_id: RICH_CONTENT_LINK_FIRST_CHAR_POSITION_ID.to_owned(), })); } fn show_secret_tooltip( &mut self, location: &TextLocation, secret_range: &SecretRange, ctx: &mut ViewContext, ) { if let Some(hoverable_secret) = self .secret_redaction_state .show_secret_tooltip(location, secret_range) { ctx.emit(AIBlockEvent::ShowSecretTooltip( RichContentSecretTooltipInfo { secret: hoverable_secret.secret.clone(), is_obfuscated: hoverable_secret.is_obfuscated, position_id: RICH_CONTENT_SECRET_FIRST_CHAR_POSITION_ID.to_owned(), secret_range: secret_range.clone(), location: *location, view_id: ctx.view_id(), secret_level: hoverable_secret.secret_level, }, )); } } pub fn set_secret_redaction_state( &mut self, location: &TextLocation, secret_range: &SecretRange, is_obfuscated: bool, ) { self.secret_redaction_state .set_obfuscated(location, secret_range, is_obfuscated); } fn handle_safe_mode_settings_changed_event( &mut self, event: &SafeModeSettingsChangedEvent, ctx: &mut ViewContext, ) { match event { SafeModeSettingsChangedEvent::SafeModeEnabled { .. } | SafeModeSettingsChangedEvent::HideSecretsInBlockList { .. } | SafeModeSettingsChangedEvent::SecretDisplayModeSetting { .. } => { ctx.notify(); } } } pub fn on_requested_command_execution_started( &mut self, action_id: AIAgentActionId, ctx: &mut ViewContext, ) { // After executing a requested command, auto expand it after some delay (for long-running commands). self.auto_expand_requested_command_timer_handle = Some(ctx.spawn( async move { Timer::after(AUTO_EXPAND_REQUESTED_COMMAND_DELAY).await; action_id }, |me, requested_command_id, ctx| { me.auto_expand_requested_command_timer_handle = None; // Avoid auto-expanding while voice input is active. let voice_active = { #[cfg(feature = "voice_input")] { voice_input::VoiceInput::as_ref(ctx).is_active() } #[cfg(not(feature = "voice_input"))] { false } }; // If user has typed since the last submit, do not auto-expand while they are editing. if me.terminal_model.lock().is_input_dirty() || voice_active { return; } // We should only attempt to auto-expand the requested command if it is still running. let is_command_still_active = { let terminal_model = me.terminal_model.lock(); let active_block = terminal_model.block_list().active_block(); active_block.is_active_and_long_running() && active_block .agent_interaction_metadata() .is_some_and(|metadata| { metadata .requested_command_action_id() .is_some_and(|id| id == &requested_command_id) }) }; if is_command_still_active { me.expand_requested_command_view(&requested_command_id, ctx); // Mark this command to be auto-collapsed when the command execution is complete. me.requested_commands_to_auto_collapse .insert(requested_command_id); } }, )); } pub fn expand_requested_command_view( &mut self, action_id: &AIAgentActionId, ctx: &mut ViewContext, ) { // Auto expansion timers only apply to requested commands, as requested actions that // don't lean on Views (i.e. file retrieval, grep, MCP, etc.) are non-expandable. let Some(requested_command) = self.requested_commands.get(action_id) else { return; }; requested_command.force_expand(ctx); ctx.notify(); } fn collapse_requested_command_view( &mut self, action_id: &AIAgentActionId, ctx: &mut ViewContext, ) { let Some(requested_command) = self.requested_commands.get(action_id) else { return; }; requested_command.force_collapse(ctx); ctx.notify(); } /// Terminates any active requested command auto-expansion timer. fn abort_auto_expand_requested_command_timer(&mut self) { if let Some(auto_expand_requested_command_timer_handle) = self.auto_expand_requested_command_timer_handle.take() { auto_expand_requested_command_timer_handle.abort(); } } /// Returns the document that the requested command was copied from, if any. pub fn requested_command_copied_from_doc( &self, action_id: &AIAgentActionId, ctx: &ViewContext, ) -> Option { let requested_command = self.requested_commands.get(action_id)?; let requested_command_view = requested_command.view.as_ref(ctx); requested_command_view.copied_from_citation().cloned() } pub fn handle_passive_code_diff_action( &mut self, action: CodeDiffAction, ctx: &mut ViewContext, ) -> bool { let Some(edit) = self.find_undismissed_code_diff(ctx) else { return false; }; edit.view.update(ctx, |view, ctx| match action { CodeDiffAction::Accept => view.try_accept_action(ctx), CodeDiffAction::Reject => view.reject(ctx), CodeDiffAction::Edit => view.expand_and_edit(ctx), CodeDiffAction::ScrollToExpand => view.expand_inline_banner(ctx), }); ctx.notify(); true } /// Marks all pending passive actions (code diffs and suggested prompts) as dismissed/ignored. /// This hides their keybindings in the UI and makes them less interactive. pub fn ignore_passive_actions(&mut self, ctx: &mut ViewContext) { self.action_model.update(ctx, |action_model, ctx| { for action in action_model.get_pending_actions_for_conversation(&self.client_ids.conversation_id) { if let Some(edit) = self.requested_edits.get(&action.id) { edit.view.update(ctx, |view, ctx| view.dismiss(ctx)); } else if let Some(suggested_prompt) = self.unit_tests_suggestions.get(&action.id) { suggested_prompt.update(ctx, |view, ctx| view.hide_keybindings(ctx)); } } }); } fn pending_requested_edit(&self, app: &AppContext) -> Option<&RequestedEdit> { self.action_model .as_ref(app) .get_pending_action(app) .and_then(|action| self.requested_edits.get(&action.id)) } /// Accepts the latest pending (blocked) action, if any. /// Includes code diffs, requested commands, and MCP tool calls. pub fn accept_pending_action(&mut self, ctx: &mut ViewContext) { self.accept_pending_requested_edit(ctx); self.accept_pending_requested_command(ctx); self.accept_pending_requested_mcp_tool(ctx); } /// Accepts the latest pending (blocked) requested code diff, if any. fn accept_pending_requested_edit(&mut self, ctx: &mut ViewContext) { if let Some(edit) = self.pending_requested_edit(ctx) { edit.view .update(ctx, |view, ctx| view.try_accept_action(ctx)); ctx.notify(); } } fn has_pending_requested_edit(&self, app: &AppContext) -> bool { self.pending_requested_edit(app).is_some() } /// Accepts the latest pending (blocked) requested command, if any. fn accept_pending_requested_command(&mut self, ctx: &mut ViewContext) { let pending_action_id = { self.action_model .as_ref(ctx) .get_pending_action(ctx) .map(|a| a.id.clone()) }; if let Some(action_id) = pending_action_id { if let Some(requested_command) = self.requested_commands.get(&action_id) { let command_text = requested_command .view .update(ctx, |view, ctx| view.commit_and_get_command_text(ctx)); self.action_model.update(ctx, |action_model, ctx| { action_model.handle_requested_command_accepted( self.client_ids.conversation_id, &action_id, command_text, ctx, ); }); ctx.notify(); } } } /// Accepts the latest pending (blocked) requested MCP tool call, if any. fn accept_pending_requested_mcp_tool(&mut self, ctx: &mut ViewContext) { let pending_action_id = { self.action_model .as_ref(ctx) .get_pending_action(ctx) .map(|a| a.id.clone()) }; if let Some(action_id) = pending_action_id { if self.requested_mcp_tools.contains_key(&action_id) { self.action_model.update(ctx, |action_model, ctx| { action_model.execute_action(&action_id, self.client_ids.conversation_id, ctx); }); ctx.notify(); } } } /// Finds the undismissed passive code diff across all pending actions. /// This is needed because passive code diffs are NOT added to the active conversation by default, when they first appear. pub(crate) fn find_undismissed_code_diff(&self, app: &AppContext) -> Option<&RequestedEdit> { // Find any RequestFileEdits action that has a corresponding passive code diff view. // Note that we only expect a maximum of 1 passive code diff to be undismissed at any given time. self.action_model .as_ref(app) .get_pending_actions_for_conversation(&self.client_ids.conversation_id) .find_map(|action| match &action.action { AIAgentActionType::RequestFileEdits { file_edits: _, title: _, } => self.requested_edits.get(&action.id).and_then(|edit| { let view = edit.view.as_ref(app); if view.is_passive() && !view.is_inline_banner_dismissed() { Some(edit) } else { None } }), _ => None, }) } pub fn pending_unit_test_suggestion( &self, app: &AppContext, ) -> Option<&ViewHandle> { self.unit_tests_suggestions .values() .find(|view| !view.as_ref(app).is_hidden()) } /// Inspects the state of the AI output stream and determines if we are currently at a point where /// we should render the floating AI control panel. This is purely a UX decision. pub fn should_show_ai_control_panel(&self, app: &AppContext) -> bool { // Returns true if we're not blocked on user input at this moment. // A stream of regular text output is not blocking. Generating a command is not blocking. Generating a code // diff is not blocking. In those cases, we return true. Waiting for a user to accept a requested command // is blocking. So is waiting for them to accept a file read. In those cases, we return false since the action // takes responsiblity for showing a cancel option. self.model .status(app) .output_to_render() .is_none_or(|output| { output.get().actions().last().is_none_or(|action| { let is_streaming = self.model.status(app).is_streaming(); let status = self .action_model .as_ref(app) .get_action_status(self.client_ids.conversation_id, &action.id); is_streaming || status.is_some_and(|status| status.is_running()) }) }) } pub fn saved_position_id(&self) -> String { get_rich_content_position_id(&self.view_id) } pub fn get_pending_action_type(&self, app: &AppContext) -> Option { self.action_model .as_ref(app) .get_pending_action(app) .map(|action| action.action.clone()) } pub fn status(&self, app: &AppContext) -> AIBlockOutputStatus { self.model.status(app) } pub fn has_expanded_running_commands(&self, app: &AppContext) -> bool { self.requested_commands .iter() .any(|(action_id, requested_command)| { self.action_model .as_ref(app) .get_action_status(self.client_ids.conversation_id, action_id) .is_some_and(|status| status.is_running()) && requested_command.view.as_ref(app).is_header_expanded() }) } pub fn output_model_display_name(&self, app: &AppContext) -> String { let Some(base_model_id) = self.model.base_model(app) else { log::warn!("No base model found for output model display name"); return String::default(); }; // Get the model name from the input metadata. let mut model_name = LLMPreferences::as_ref(app) .get_llm_info(base_model_id) .map(|info| info.display_name.clone()) .unwrap_or_default(); // If the input model is "auto", always display that, otherwise use the actual output model if available. if model_name != "auto" { let model_id = self.model.model_id(app); if let Some(model_id) = model_id { if let Some(output_model_name) = LLMPreferences::as_ref(app) .get_llm_info(&model_id) .map(|info| info.display_name.clone()) { model_name = output_model_name; } } } model_name } #[cfg(feature = "agent_mode_debug")] pub fn set_diffs(&self, diffs: Vec, ctx: &mut ViewContext) { if let Some(edit) = self.requested_edits.values().next() { edit.view.update(ctx, |view, ctx| { view.set_candidate_diffs(diffs, ctx); }); } } /// Gets the prompt text for copying pub fn get_prompt_text(&self, app: &AppContext) -> String { self.model .inputs_to_render(app) .iter() .filter_map(|input| input.display_query()) .collect::>() .join("\n") } /// Gets the prompt text from the preceding user query (where the overflow menu would appear) pub fn get_preceding_user_query(&self, app: &AppContext) -> String { let history = BlocklistAIHistoryModel::as_ref(app); let current_exchange_id = self.model.exchange_id(app); let Some(exchange_id) = current_exchange_id else { return self.get_prompt_text(app); }; let Some(conversation_id) = history.conversation_id_for_exchange(exchange_id, self.terminal_view_id) else { return self.get_prompt_text(app); }; let Some(conversation) = history.conversation(&conversation_id) else { return self.get_prompt_text(app); }; let exchanges: Vec<_> = conversation.root_task_exchanges().collect(); // Find the current exchange index let current_index = exchanges .iter() .position(|exchange| exchange.id == exchange_id); let Some(current_idx) = current_index else { return self.get_prompt_text(app); }; // Find the preceding user query (where the overflow menu would be) for i in (0..=current_idx).rev() { let formatted_input = exchanges[i].format_input_for_copy(); if !formatted_input.is_empty() { return formatted_input; } } // Fallback to current block's prompt self.get_prompt_text(app) } /// Gets the AI output text for copying pub fn get_output_text(&self, app: &AppContext) -> String { let Some(output) = self.model.status(app).output_to_render() else { return String::new(); }; let output = output.get(); output.format_for_copy_for_conversation( Some(self.action_model.as_ref(app)), Some(self.client_ids.conversation_id), ) } /// Gets AI output text for copying from the preceding user query until the next user query /// This copies all AI outputs that would be contained within one "overflow menu scope" pub fn get_output_text_since_preceding_user_query(&self, app: &AppContext) -> String { let history = BlocklistAIHistoryModel::as_ref(app); let current_exchange_id = self.model.exchange_id(app); let Some(exchange_id) = current_exchange_id else { return self.get_output_text(app); }; let Some(conversation_id) = history.conversation_id_for_exchange(exchange_id, self.terminal_view_id) else { return self.get_output_text(app); }; let Some(conversation) = history.conversation(&conversation_id) else { return self.get_output_text(app); }; let exchanges: Vec<_> = conversation.root_task_exchanges().collect(); // Find the current exchange index let current_index = exchanges .iter() .position(|exchange| exchange.id == exchange_id); let Some(current_idx) = current_index else { return self.get_output_text(app); }; // Find the preceding user query (where the overflow menu would be) let mut start_idx = current_idx; for i in (0..=current_idx).rev() { if exchanges[i].has_user_query() { start_idx = i; break; } } // Find the next user query (where the next overflow menu would be) let mut end_idx = exchanges.len(); for (i, exchange) in exchanges.iter().enumerate().skip(start_idx + 1) { if exchange.has_user_query() { end_idx = i; break; } } // Collect all AI outputs from start_idx to end_idx (exclusive) let mut combined_result = Vec::new(); for exchange in exchanges.iter().take(end_idx).skip(start_idx) { let formatted_output = exchange.format_output_for_copy_for_conversation( Some(self.action_model.as_ref(app)), Some(self.client_ids.conversation_id), ); if !formatted_output.is_empty() { combined_result.push(formatted_output); } } // Not expected to be needed, but get the output of just this block as a fallback. if combined_result.is_empty() { self.get_output_text(app) } else { combined_result.join("\n\n") // Separate different exchanges with double newlines } } fn has_accepted_file_edits_since_last_query(&self, app: &AppContext) -> bool { let Some(conversation) = BlocklistAIHistoryModel::as_ref(app).conversation(&self.conversation_id()) else { return false; }; // Check any finished actions, from the most recent AI output, for accepted file edits. if let Some(finished_action_results) = self .action_model .as_ref(app) .get_finished_action_results(conversation.id()) { if finished_action_results.iter().any(|result| { matches!( result.result, AIAgentActionResultType::RequestFileEdits( RequestFileEditsResult::Success { .. } ) ) }) { return true; } } // Otherwise, we also check all past exchanges since the last user query for accepted file edits. conversation .exchanges_reversed() .take_while(|exchange| !exchange.has_user_query()) .any(|exchange| exchange.has_accepted_file_edit()) } pub fn num_requested_commands(&self) -> usize { self.requested_commands.len() } pub fn requested_commands_iter( &self, ) -> impl Iterator { self.requested_commands.iter() } /// Collects all imported review comments stored in this block /// and the base branch (if any). Returns `None` when this block has no imported comments. pub(crate) fn collect_imported_comments(&self) -> Option { if !self.has_imported_comments { return None; } let mut comments = Vec::new(); let mut base_branch = None; for group in self.imported_comments.values() { if let Some(group_base_branch) = &group.base_branch { if base_branch.is_none() { base_branch = Some(group_base_branch.clone()); } else if base_branch.as_ref() != Some(group_base_branch) { log::warn!( "Encountered multiple base branches while opening imported review comments from a single AI block", ); } } comments.extend(group.cards.iter().map(|card| card.source().clone())); } if comments.is_empty() { return None; } Some(ImportedBlockComments { comments, base_branch, }) } /// Returns `true` if this block has any imported review comments. pub(crate) fn has_any_imported_comments(&self) -> bool { self.has_imported_comments } /// Returns `true` if the current working directory is within any of this block's /// imported comment group repo roots. fn cwd_matches_any_imported_comment_repo(&self, cwd: &LocalOrRemotePath) -> bool { self.imported_comments .values() .any(|group| group.repo_path.strip_repo_prefix(cwd).is_some()) } /// Returns the repo path associated with this block's imported comments, if any. /// /// All imported comment groups in a single block share the same repo /// (they were fetched in the same terminal context), so any group's /// path is representative. pub(crate) fn imported_comment_repo_path(&self) -> Option<&LocalOrRemotePath> { self.imported_comments .values() .next() .map(|group| &group.repo_path) } fn current_working_directory_location( &self, ctx: &mut ViewContext, ) -> Option { self.active_session .as_ref(ctx) .current_working_directory_location(ctx) } /// Disables or enables the per-comment "Open in code review" buttons and the /// bulk "Open all in code review" button based on whether the current working /// directory is still within the imported comments' repository. fn update_imported_comments_disabled_state(&mut self, ctx: &mut ViewContext) { let cwd_location = self.current_working_directory_location(ctx); if self.has_imported_comments { self.update_own_imported_comments_disabled_state(cwd_location.as_ref(), ctx); } else if self.model.is_latest_visible_exchange_in_root_task(ctx) { // The "Open all" button is rendered by the latest visible exchange when the // current thread has imported comments but this block does not own them directly. // Update that block's button state from its CWD so the button disables when the // user navigates outside the imported comments' repository. self.update_open_all_button_disabled_state(cwd_location.as_ref(), ctx); } else { return; } ctx.notify(); } /// Updates the per-comment and "Open all" buttons for a block that owns /// imported comments. We assume all comment groups share the same repo. fn update_own_imported_comments_disabled_state( &mut self, cwd_location: Option<&LocalOrRemotePath>, ctx: &mut ViewContext, ) { let cwd_matches_repo = cwd_location.is_some_and(|cwd| self.cwd_matches_any_imported_comment_repo(cwd)); let should_disable = !cwd_matches_repo; for group in self.imported_comments.values() { group.set_buttons_disabled(should_disable, ctx); } let repo_path = if should_disable { self.imported_comment_repo_path().cloned() } else { None }; set_imported_comment_button_disabled( &self.open_all_comments_button, should_disable, repo_path.as_ref(), ctx, ); } /// Updates the "Open all" button for a block that has no imported comments /// of its own but is the latest exchange (and therefore renders the button). /// Derives the repo root from the block's CWD via `DetectedRepositories`. fn update_open_all_button_disabled_state( &self, cwd_location: Option<&LocalOrRemotePath>, ctx: &mut ViewContext, ) { #[cfg(not(target_family = "wasm"))] let repo_path = cwd_location.and_then(|cwd| DetectedRepositories::as_ref(ctx).get_root_for_path(cwd)); #[cfg(target_family = "wasm")] let repo_path = cwd_location.cloned(); let cwd_matches_repo = match (cwd_location, repo_path.as_ref()) { (Some(cwd), Some(rp)) => rp.strip_repo_prefix(cwd).is_some(), _ => false, }; set_imported_comment_button_disabled( &self.open_all_comments_button, !cwd_matches_repo, repo_path.as_ref(), ctx, ); } /// A "thread" covers all exchanges since and including the most recent user query. This /// method checks across all AI blocks for this conversation — not just this block — by /// querying the `TerminalView`, which in turn calls `has_any_imported_comments` on each /// block. pub(crate) fn has_imported_comments_in_current_thread(&self, app: &AppContext) -> bool { self.terminal_view_handle .upgrade(app) .is_some_and(|terminal_view_handle| { terminal_view_handle .as_ref(app) .has_imported_comments_in_thread(&self.client_ids.conversation_id, app) }) } } pub(crate) struct ImportedBlockComments { pub(crate) comments: Vec, pub(crate) base_branch: Option, } fn set_imported_comment_button_disabled( handle: &ViewHandle, should_disable: bool, repo_path: Option<&LocalOrRemotePath>, ctx: &mut ViewContext, ) { handle.update(ctx, |button, ctx| { button.set_disabled(should_disable, ctx); if should_disable { let tooltip = repo_path .map(|path| format!("Navigate to {} to open these comments", path.display_path())); button.set_tooltip(tooltip, ctx); } else { button.set_tooltip(None::, ctx); } }); } fn num_attached_context_blocks(inputs: &[AIAgentInput]) -> usize { inputs.iter().fold(0, |count, input| { if let Some(context) = input.context() { count + context .iter() .filter(|context| matches!(context, AIAgentContext::Block { .. })) .count() } else { count } }) } fn has_attached_context_selected_text(inputs: &[AIAgentInput]) -> bool { inputs.iter().any(|input| { input.context().is_some_and(|context| { context .iter() .any(|context| matches!(context, AIAgentContext::SelectedText(_))) }) }) } pub(super) fn attachment_names(inputs: &[AIAgentInput]) -> Vec<(AttachmentType, String)> { let mut names: Vec<(AttachmentType, String)> = inputs .iter() .filter_map(|input| { input.context().map(|contexts| { contexts.iter().filter_map(|context| match context { AIAgentContext::Image(image) => { Some((AttachmentType::Image, image.file_name.clone())) } _ => None, }) }) }) .flatten() .collect_vec(); // Also collect file names from FilePathReference attachments. // Use the map key (clean filename) rather than the file_name field // (which may contain a UUID prefix from the VM filesystem path). for input in inputs { if let AIAgentInput::UserQuery { referenced_attachments, .. } = input { for (key, attachment) in referenced_attachments { if matches!(attachment, AIAgentAttachment::FilePathReference { .. }) { names.push((AttachmentType::File, key.clone())); } } } } names } #[derive(Clone, Debug)] pub enum AIBlockEvent { /// Emitted when the AI block is "finished", meaning it will no longer receive any more output /// (either the output is complete or the request was cancelled) and it is no longer /// interactable (if any requested commands or requested actions were included, all requested /// commands and requested actions have been executed or cancelled). Finished, /// Emitted when the AI block requires user confirmation to execute. ActionBlockedOnUserConfirmation, /// Emitted when the visibility of the command block is toggled for a requested action or a /// requested command. This covers both [`View`] and non-[`View`] inline action components. UpdateInlineActionVisibility { action_id: AIAgentActionId, is_visible: bool, }, ToggleCodeDiffVisibility, /// Open a Warp Text instance with the requested code diff. OpenCodeWithDiff { view: ViewHandle, }, #[cfg(feature = "local_fs")] OpenDetectedFilePath { absolute_path: PathBuf, line_and_column_num: Option, target_override: Option, }, ShowLinkTooltip(RichContentLinkTooltipInfo), DismissLinkTooltip, ShowSecretTooltip(RichContentSecretTooltipInfo), DismissSecretTooltip, OpenCitation(AIAgentCitation), OpenAIFactCollection { /// If set, open the fact collection to the specific rule. sync_id: Option, }, OpenWorkflow { sync_id: SyncId, }, /// Emitted when the continue conversation button is clicked ContinueConversation { conversation_id: AIConversationId, }, /// Emitted when a passive code diff should be injected into an agent context. ContinuePassiveCodeDiffWithAgent { conversation_id: AIConversationId, /// If the auto code diff was generated as a result of a block trigger, /// this is the ID of that block. trigger_block_id: Option, auto_resume: bool, }, OpenSuggestedAgentModeWorkflowModal { workflow_and_id: SuggestedAgentModeWorkflowAndId, }, OpenSuggestedRuleDialog { rule_and_id: SuggestedRuleAndId, }, FocusTerminal, OpenThemeChooser, #[cfg(windows)] WindowsCtrlC, AIOutputUpdated, ActionFinished, /// Emitted when text is selected within any `ChildView` of an `AIBlock`. This includes /// selections made within inline action views (such as requested edits, embedded code blocks, /// etc.) but not selections at the `AIBlock` level itself. Providing this distinction is /// important because selecting across multiple blocks only supports text selections at the /// `AIBlock` level. ChildViewTextSelected, /// Emitted when the `AIBlock`'s own (block-level) text selection state may /// have changed. The terminal view uses this to keep the model's record of /// which rich content block has an active selection in sync, so copy/insert /// paths can find the selected text. Rich content selections are not tied to /// the point-based model selection, so this signal is required. SelectionChanged, CopiedEmptyText, OpenSettings, #[cfg(feature = "local_fs")] OpenCodeInWarp { source: CodeSource, layout: crate::util::file::external_editor::settings::EditorLayout, }, /// Emitted when the resume conversation button is clicked ResumeConversation { conversation_id: AIConversationId, }, InsertForkSlashCommand, ToggleCodeReviewPane { entrypoint: CodeReviewPaneEntrypoint, }, DismissedPassiveBlock, OpenAIDocumentPane { document_id: AIDocumentId, document_version: AIDocumentVersion, is_auto_open: bool, }, OpenActiveAgentProfileEditor, /// Run the configured AWS auth refresh command to fix expired Bedrock credentials RunAwsLoginCommand, /// Emitted when a passive code diff has loaded its diffs and is ready to display. /// This is used to trigger height recalculation since the diffs are loaded asynchronously /// after the initial output completes. PassiveCodeDiffLoaded, OpenImportedCommentInCodeReview { repo_path: LocalOrRemotePath, comment: Box, base_branch: Option, }, /// Emitted when the "Open all in code review" button is clicked on a block that does not /// itself hold imported comments. The terminal view handles this by collecting imported /// comments from all blocks belonging to the same conversation's current thread. OpenAllImportedCommentsForConversation { conversation_id: AIConversationId, }, } impl Entity for AIBlock { type Event = AIBlockEvent; } /// User's final response to an AI-suggested code edit. #[derive(Clone, Copy, Debug, Serialize)] pub enum RequestedEditResolution { Accept, Reject, } #[derive(Debug, Clone)] pub enum AIBlockAction { /// Only applies to text selections made at the `AIBlock` level. Child views of the `AIBlock` /// are responsible for managing their own text selection states. SelectText, CopyAIBlockCodeSnippet(String), /// Continue the conversation using this response ContinueConversation, /// Resume the stopped conversation ResumeConversation, /// Fork the conversation ForkConversation, /// Manually cancel sending an AI request or streaming an AI response for a requested action. /// View-based inline actions (`RequestedCommandView`, etc.) should be handling AI block /// cancellation via their own View events. CancelRequestedAction { action_id: AIAgentActionId, }, /// Executes the requested action with the given ID. View-based inline actions (i.e. `PlanView`, /// `RequestedCommandView`, etc.) should be handling AI block execution via their own View events. ExecuteRequestedAction { action_id: AIAgentActionId, }, /// Execute the next pending action, if any. ExecuteNextPendingAction, ChangedHoverOnLink { link_range: Range, location: TextLocation, is_hovering: bool, }, OpenLink { link_range: Range, location: TextLocation, }, ChangedHoverOnSecret { secret_range: SecretRange, location: TextLocation, is_hovering: bool, }, OpenLinkTooltip { link_range: Range, location: TextLocation, }, OpenSecretTooltip { secret_range: SecretRange, location: TextLocation, }, OpenCitation(AIAgentCitation), OpenAIFactCollection, ToggleReferencesSection, ToggleAutoexecuteReadonlyCommandsSpeedbumpCheckbox, ToggleAutoreadFilesSpeedbumpCheckbox, ToggleBedrockAutoLogin, ToggleCodebaseSearchSpeedbump(Option), StartNewConversationButtonClicked { action_id: AIAgentActionId, server_output_id: Option, }, ContinueCurrentConversationButtonClicked { action_id: AIAgentActionId, server_output_id: Option, }, Rated { is_positive: bool, }, /// Clear the selections of all other views **except** for the source view that dispatched the event. /// The `source_view_id` will be `None` if the event is dispatched by the [`galaxyui::elements::SelectableArea`] /// instead of a nested view (i.e. code block, requested command, etc.), which means all nested views /// should have their selections cleared. ClearOtherSelections { source_view_id: Option, source_window_id: WindowId, }, /// Copy both query and AI output (see below) Copy, /// Copy the content from the previous user query. /// Note that this block may not have the user query. CopyQuery, /// Copy all AI output from the previous user query to the next user query. /// Note that this contains more than just this block, since from the user perspective everything after the user query appears like one block. CopyOutput, /// Copy the ai block's command CopyCommand, /// Store a command that was right-clicked for later copying StoreRightClickedCommand { command: String, }, OpenCodeInWarp { source: CodeSource, }, ToggleTodoListExpanded(MessageId), ToggleCollapsibleBlockExpanded(MessageId), SetCollapsibleBlockPinnedToBottom { message_id: MessageId, pinned_to_bottom: bool, }, ToggleCodeReviewPane, DismissSuggestionsSection, DisableRuleSuggestions, /// Copy the debug ID to clipboard CopyDebugId(String), /// Open Warp feedback documentation OpenFeedbackDocs, CommentExpanded { id: CommentId, }, /// Run the configured AWS auth refresh command to fix expired Bedrock credentials RunAwsLoginCommand, /// Open settings to configure the AWS auth refresh command ConfigureAwsLoginCommand, /// Open the screenshot lightbox for a UseComputer action. ViewScreenshot { action_id: AIAgentActionId, }, /// Open the lightbox for an image attached to an already-submitted user query /// rendered inside this AI block. OpenSubmittedAttachmentLightbox { image_index: usize, }, ToggleImportedCommentCollapsed { action_id: AIAgentActionId, comment_index: usize, }, OpenImportedCommentInCodeReview { action_id: AIAgentActionId, comment_index: usize, }, OpenAllImportedCommentsInCodeReview, OpenCommentInGitHub { url: String, }, ToggleSubagentPanel { action_id: AIAgentActionId, }, ToggleIsUsageFooterExpanded, } impl TypedActionView for AIBlock { type Action = AIBlockAction; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { match action { AIBlockAction::ToggleIsUsageFooterExpanded => { self.is_usage_footer_expanded = !self.is_usage_footer_expanded; ctx.notify(); } AIBlockAction::ToggleSubagentPanel { action_id } => { if let Some(state) = self.state_handles.subagent_panel_states.get_mut(action_id) { state.is_expanded = !state.is_expanded; ctx.notify(); } } AIBlockAction::SetCollapsibleBlockPinnedToBottom { message_id, pinned_to_bottom, } => { if let Some(state) = self.collapsible_block_states.get_mut(message_id) { if let CollapsibleExpansionState::Expanded { scroll_pinned_to_bottom, .. } = &mut state.expansion_state { *scroll_pinned_to_bottom = *pinned_to_bottom; ctx.notify(); } } } AIBlockAction::ContinueConversation => { // Get the current conversation ID from this block let conversation_id = self.client_ids.conversation_id; // Emit an event for the terminal view to handle // The terminal view will handle setting active conversation, // updating context model, setting input mode, and focusing ctx.emit(AIBlockEvent::ContinueConversation { conversation_id }); // Also emit focus terminal event to ensure input is focused ctx.emit(AIBlockEvent::FocusTerminal); ctx.notify(); } AIBlockAction::ResumeConversation => { ctx.emit(AIBlockEvent::ResumeConversation { conversation_id: self.client_ids.conversation_id, }); } AIBlockAction::ForkConversation => { // Fully reset the fork button's interaction state before navigation. // This avoids an immediate re-hover (and stuck tooltip) from synthetic mouse events // that can occur while the new pane is being created. if let Ok(mut state) = self.state_handles.fork_conversation_handle.lock() { state.reset_interaction_state(); } let is_read_only = self.terminal_model.lock().is_read_only(); if FeatureFlag::AgentView.is_enabled() && !is_read_only { ctx.emit(AIBlockEvent::InsertForkSlashCommand); } else { ctx.dispatch_global_action( "workspace:fork_ai_conversation", ForkAIConversationParams { conversation_id: self.client_ids.conversation_id, fork_from_exchange: None, summarize_after_fork: false, summarization_prompt: None, initial_prompt: None, destination: ForkedConversationDestination::SplitPane, }, ); } ctx.notify(); } AIBlockAction::SelectText => { // If there's an ongoing text selection, clear all other selections within the // `AIBlock`'s view sub-hierarchy to ensure only one component has a selection at a time. self.clear_other_selections(None, ctx.window_id(), ctx); // If we have a selection, we should use the default cursor, even if it's over a link. ctx.reset_cursor(); self.dismiss_ai_tooltips(ctx); // Notify the terminal view so it can keep the model's record of which rich // content block has an active selection in sync (rich content selections are // not tied to the point-based model selection used for regular blocks). ctx.emit(AIBlockEvent::SelectionChanged); } AIBlockAction::CopyAIBlockCodeSnippet(text) => { ctx.clipboard() .write(ClipboardContent::plain_text(text.clone())); let window_id = ctx.window_id(); ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { toast_stack.add_ephemeral_toast( DismissibleToast::success(String::from("Copied to clipboard")), window_id, ctx, ); }); } AIBlockAction::CopyDebugId(debug_id) => { ctx.clipboard() .write(ClipboardContent::plain_text(debug_id.clone())); } AIBlockAction::OpenFeedbackDocs => { ctx.open_url("https://docs.warp.dev/support-and-community/troubleshooting-and-support/sending-us-feedback"); } AIBlockAction::CancelRequestedAction { action_id } => { self.cancel_action(action_id, ctx); } AIBlockAction::ExecuteNextPendingAction => { // If the next pending action is a RunAgents tool call, // delegate to the per-card view's Accept handler so // Enter routes through the executor-backed dispatch // path. (Focus normally goes to the card view via // `focus_subview_if_necessary`, in which case the // card's own keybinding fires; this handler covers // the case where focus is still on AIBlock.) let run_agents_id = self .action_model .as_ref(ctx) .get_pending_actions_for_conversation(&self.client_ids.conversation_id) .filter(|action| matches!(action.action, AIAgentActionType::RunAgents(_))) .last() .map(|action| action.id.clone()); if let Some(run_agents_id) = run_agents_id { if let Some(card_view) = self.run_agents_card_views.get(&run_agents_id).cloned() { card_view.update(ctx, |view, ctx_view| view.accept(ctx_view)); } else { log::warn!( "ExecuteNextPendingAction: no RunAgentsCardView for {run_agents_id:?}" ); } } else { self.action_model.update(ctx, |action_model, ctx| { action_model.execute_next_action_for_user(self.conversation_id(), ctx) }); } } AIBlockAction::ExecuteRequestedAction { action_id } => { self.action_model.update(ctx, |action_model, ctx| { action_model.execute_action(action_id, self.client_ids.conversation_id, ctx) }); } AIBlockAction::ChangedHoverOnLink { link_range, location, is_hovering, } => { // If we're currently selecting, don't consider the link hovered. self.detected_links_state.update_hovered_link( *is_hovering, self.state_handles.selection_handle.is_selecting(), link_range, location, ); } AIBlockAction::ChangedHoverOnSecret { secret_range, location, is_hovering, } => { self.secret_redaction_state.set_hover_state_for_secret( location, secret_range, *is_hovering, ); } AIBlockAction::OpenLink { location, link_range, } => { self.open_link(location, link_range, ctx); } AIBlockAction::OpenLinkTooltip { location, link_range, } => { self.show_link_tooltip(location, link_range, ctx); } AIBlockAction::OpenSecretTooltip { location, secret_range, } => { self.show_secret_tooltip(location, secret_range, ctx); } AIBlockAction::OpenCitation(citation) => { ctx.emit(AIBlockEvent::OpenCitation(citation.clone())); let server_output_id = self .model .status(ctx) .output_to_render() .and_then(|output| output.get().server_output_id.clone()); if let Some(citation) = citation.for_telemetry(ctx) { send_telemetry_from_ctx!( TelemetryEvent::AgentModeOpenedCitation { citation, block_id: self.client_ids.client_exchange_id.to_string(), conversation_id: self.client_ids.conversation_id, server_output_id, }, ctx ); } } AIBlockAction::OpenAIFactCollection => { ctx.emit(AIBlockEvent::OpenAIFactCollection { sync_id: None }); } AIBlockAction::ToggleReferencesSection => { self.is_references_section_open = !self.is_references_section_open; } AIBlockAction::CommentExpanded { id } => { let Some(comment) = self.comment_states.get_mut(id) else { return; }; comment.is_expanded = !comment.is_expanded; let new_icon = if comment.is_expanded { Icon::Minimize } else { Icon::Maximize }; comment.maximize_minimize_button.update(ctx, |button, ctx| { button.set_icon(Some(new_icon), ctx); }); ctx.notify() } AIBlockAction::DismissSuggestionsSection => { BlocklistAIHistoryModel::handle(ctx).update(ctx, |model, _| { if let Some(conversation) = model.conversation_mut(&self.client_ids.conversation_id) { conversation.dismiss_current_suggestions(); } }); ctx.notify(); } AIBlockAction::DisableRuleSuggestions => { // Dismiss the current suggestions and permanently disable future ones. BlocklistAIHistoryModel::handle(ctx).update(ctx, |model, _| { if let Some(conversation) = model.conversation_mut(&self.client_ids.conversation_id) { conversation.dismiss_current_suggestions(); } }); AISettings::handle(ctx).update(ctx, |settings, ctx| { report_if_error!(settings .rule_suggestions_enabled_internal .set_value(false, ctx)); }); ctx.notify(); } AIBlockAction::ToggleAutoexecuteReadonlyCommandsSpeedbumpCheckbox => { if let AutonomySettingSpeedbump::ShouldShowForAutoexecutingReadonlyCommands { checked, .. } = &mut self.autonomy_setting_speedbump { *checked = !*checked; BlocklistAIPermissions::handle(ctx).update(ctx, |model, ctx| { match model.set_should_autoexecute_readonly_commands(*checked, ctx) { Ok(_) => { send_telemetry_from_ctx!( TelemetryEvent::ToggledAgentModeAutoexecuteReadonlyCommandsSetting { src: AutonomySettingToggleSource::Speedbump, enabled: *checked, }, ctx); } Err(e) => report_error!(e), } }); } } AIBlockAction::ToggleAutoreadFilesSpeedbumpCheckbox => { if let AutonomySettingSpeedbump::ShouldShowForFileAccess { checked, .. } = &mut self.autonomy_setting_speedbump { *checked = !*checked; let permission = if *checked { AgentModeCodingPermissionsType::AlwaysAllowReading } else { AgentModeCodingPermissionsType::AlwaysAskBeforeReading }; BlocklistAIPermissions::handle(ctx).update(ctx, |model, ctx| { match model.set_coding_permissions(permission, ctx) { Ok(_) => { send_telemetry_from_ctx!( TelemetryEvent::ChangedAgentModeCodingPermissions { src: AutonomySettingToggleSource::Speedbump, new: permission, }, ctx ); } Err(e) => report_error!(e), } }); } } AIBlockAction::ToggleCodebaseSearchSpeedbump(new) => { if let AutonomySettingSpeedbump::ShouldShowForCodebaseSearchFileAccess { selected_option, .. } = &mut self.autonomy_setting_speedbump { *selected_option = *new; let permission = match new { Some(0) => AgentModeCodingPermissionsType::AlwaysAllowReading, Some(1) => AgentModeCodingPermissionsType::AllowReadingSpecificFiles, _ => AgentModeCodingPermissionsType::AlwaysAskBeforeReading, }; BlocklistAIPermissions::handle(ctx).update(ctx, |model, ctx| { match model.set_coding_permissions(permission, ctx) { Ok(_) => { send_telemetry_from_ctx!( TelemetryEvent::ChangedAgentModeCodingPermissions { src: AutonomySettingToggleSource::Speedbump, new: permission, }, ctx ); } Err(e) => report_error!(e), } }); } } AIBlockAction::StartNewConversationButtonClicked { action_id, server_output_id: _, } => { self.action_model.update(ctx, |action_model, ctx| { action_model.suggest_new_conversation_executor(ctx).update( ctx, |executor, _| { executor.complete_suggest_new_conversation_action( NewConversationDecision::Accept, ); }, ); action_model.execute_action(action_id, self.client_ids.conversation_id, ctx); }); } AIBlockAction::ContinueCurrentConversationButtonClicked { action_id, server_output_id: _, } => { self.action_model.update(ctx, |action_model, ctx| { action_model.suggest_new_conversation_executor(ctx).update( ctx, |executor, _| { executor.complete_suggest_new_conversation_action( NewConversationDecision::Reject, ); }, ); action_model.execute_action(action_id, self.client_ids.conversation_id, ctx); }); } AIBlockAction::Rated { is_positive } => { let output_id = self.model.server_output_id(ctx); let rating = if *is_positive { AIBlockResponseRating::Positive } else { AIBlockResponseRating::Negative }; if self.response_rating.set(rating).is_err() { // A rating was already set for this block. This should be unreachable. return; } if matches!(rating, AIBlockResponseRating::Negative) { if let Some(output_id) = output_id.clone() { let request_usage_model = AIRequestUsageModel::handle(ctx); request_usage_model.update(ctx, |request_usage_model, ctx| { request_usage_model .provide_negative_feedback_response_for_ai_conversation( self.client_ids.conversation_id, output_id.to_string(), self.client_ids.client_exchange_id, ctx, ); }); } } let window_id = ctx.window_id(); ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { let toast = DismissibleToast::default(String::from("Thank you for the feedback!")); toast_stack.add_ephemeral_toast(toast, window_id, ctx); }); send_telemetry_from_ctx!( TelemetryEvent::AgentModeRatedResponse { server_output_id: output_id, conversation_id: self.client_ids.conversation_id, rating, }, ctx ); } AIBlockAction::ClearOtherSelections { source_view_id, source_window_id, } => { self.clear_other_selections(*source_view_id, *source_window_id, ctx); } AIBlockAction::CopyQuery => { // Copy the prompt from the preceding user query (where overflow menu would appear) let prompt_text = self.get_preceding_user_query(ctx); ctx.clipboard() .write(ClipboardContent::plain_text(prompt_text)); } AIBlockAction::CopyOutput => { // Copy all AI output from preceding user query until the next user query let output_text = self.get_output_text_since_preceding_user_query(ctx); ctx.clipboard() .write(ClipboardContent::plain_text(output_text)); } AIBlockAction::Copy => { // Copy the preceding user query and all AI output until the next user query let prompt_text = self.get_preceding_user_query(ctx); let output_text = self.get_output_text_since_preceding_user_query(ctx); let combined_text = format!("{prompt_text}\n\n{output_text}"); ctx.clipboard() .write(ClipboardContent::plain_text(combined_text)); } AIBlockAction::CopyCommand => { let command_text = if let Some(stored_command) = &self.last_right_clicked_command { // Use the specific command that was right-clicked stored_command.clone() } else { // Fallback to collecting all commands (previous behavior) let commands: Vec = self .requested_commands .values() .map(|requested_command| { requested_command .view .as_ref(ctx) .command_text() .to_string() }) .collect(); // Join multiple commands with a newline if there are multiple commands in a single AI block // (this only happens when the agent uses parallel tool calling to execute multiple commands at once, // and the user has not right clicked on a specific command) commands.join("\n") }; ctx.clipboard() .write(ClipboardContent::plain_text(command_text)); // Clear the stored command after copying self.last_right_clicked_command = None; } AIBlockAction::OpenCodeInWarp { #[cfg_attr(not(feature = "local_fs"), allow(unused))] source, } => { // Resets the interaction states of ReadSkill and ReadFiles tool call banners before opening a new code pane // Avoids an immediate re-hover (and stuck tooltip) while the new code pane is being created for handle in self.state_handles.skill_button_handles.values() { if let Ok(mut state) = handle.lock() { state.reset_interaction_state(); } } // Sends a telemetry event when a skill is opened from an 'open skill' button if let CodeSource::Skill { reference, origin, .. } = source { send_telemetry_from_ctx!( SkillTelemetryEvent::Opened { reference: reference.clone(), name: SkillManager::as_ref(ctx) .skill_by_reference(reference) .map(|skill| skill.name.clone()), origin: *origin, }, ctx ); } #[cfg(feature = "local_fs")] { ctx.emit(AIBlockEvent::OpenCodeInWarp { source: source.clone(), layout: *crate::util::file::external_editor::EditorSettings::as_ref(ctx) .open_file_layout .value(), }) } } AIBlockAction::ToggleTodoListExpanded(id) => { if let Some(state) = self.todo_list_states.get_mut(id) { state.is_expanded = !state.is_expanded; } } AIBlockAction::ToggleCollapsibleBlockExpanded(id) => { if let Some(state) = self.collapsible_block_states.get_mut(id) { state.toggle_expansion(); } } AIBlockAction::ToggleCodeReviewPane => { ctx.emit(AIBlockEvent::ToggleCodeReviewPane { entrypoint: CodeReviewPaneEntrypoint::AgentModeRunning, }); } AIBlockAction::StoreRightClickedCommand { command } => { self.last_right_clicked_command = Some(command.clone()); } AIBlockAction::RunAwsLoginCommand => { ctx.emit(AIBlockEvent::RunAwsLoginCommand); } AIBlockAction::ToggleBedrockAutoLogin => { AISettings::handle(ctx).update(ctx, |settings, ctx| { let current = *settings.bedrock_auto_login.value(); let new_value = !current; report_if_error!(settings.bedrock_auto_login.set_value(new_value, ctx)); }); } AIBlockAction::ConfigureAwsLoginCommand => { ctx.dispatch_typed_action(&WorkspaceAction::ShowSettingsPageWithSearch { search_query: "aws bedrock".to_string(), section: Some(SettingsSection::WarpAgent), }); } AIBlockAction::ToggleImportedCommentCollapsed { action_id, comment_index, } => { if let Some(group) = self.imported_comments.get_mut(action_id) { if let Some(card) = group.card_mut(*comment_index) { card.toggle_collapsed(); let is_collapsed = card.is_collapsed(); if let Some(state) = group.element_states.get(*comment_index) { let icon = if is_collapsed { Icon::ChevronRight } else { Icon::ChevronDown }; state.chevron_button.update(ctx, |button, ctx| { button.set_icon(Some(icon), ctx); }); } } } } AIBlockAction::OpenImportedCommentInCodeReview { action_id, comment_index, } => { if let Some(group) = self.imported_comments.get_mut(action_id) { let repo_path = group.repo_path.clone(); let base_branch = group.base_branch.clone(); if let Some(card) = group.card_mut(*comment_index) { ctx.emit(AIBlockEvent::OpenImportedCommentInCodeReview { repo_path, comment: Box::new(card.source().clone()), base_branch, }); } } } AIBlockAction::OpenAllImportedCommentsInCodeReview => { ctx.emit(AIBlockEvent::OpenAllImportedCommentsForConversation { conversation_id: self.client_ids.conversation_id, }); } AIBlockAction::OpenCommentInGitHub { url } => { ctx.open_url(url); } AIBlockAction::ViewScreenshot { action_id } => { // Collect all UseComputer action IDs across the entire conversation // so the lightbox can navigate between their screenshots. let conversation_id = self.client_ids.conversation_id; let use_computer_action_ids: Vec = BlocklistAIHistoryModel::as_ref(ctx) .conversation(&conversation_id) .into_iter() .flat_map(|c| c.use_computer_action_ids()) .collect(); // Build lightbox images for each action that has a screenshot result. // We Arc::clone the result each iteration to release the immutable // borrow on ctx, allowing the mutable AssetCache update in the same // loop body. Arc::clone is just a refcount bump (no data copied). let mut screenshot_action_ids: Vec<&AIAgentActionId> = Vec::new(); let mut images: Vec = Vec::new(); for action_id in &use_computer_action_ids { let Some(result) = self .action_model .as_ref(ctx) .get_action_result(self.client_ids.conversation_id, action_id) .map(Arc::clone) else { continue; }; let AIAgentActionResultType::UseComputer( crate::ai::agent::UseComputerResult::Success(computer_use::ActionResult { screenshot: Some(screenshot), .. }), ) = &result.result else { continue; }; let asset_id = format!("screenshot-{action_id}"); AssetCache::handle(ctx).update(ctx, |asset_cache, ctx| { asset_cache.insert_raw_asset_bytes::( asset_id.clone(), &screenshot.data, ctx, ); }); images.push(ui_components::lightbox::LightboxImage { source: ui_components::lightbox::LightboxImageSource::Resolved { asset_source: galaxyui::assets::asset_cache::AssetSource::Raw { id: asset_id, }, }, description: None, }); screenshot_action_ids.push(action_id); } if images.is_empty() { return; } let initial_index = screenshot_action_ids .iter() .position(|id| *id == action_id) .unwrap_or(0); ctx.dispatch_typed_action(&WorkspaceAction::OpenLightbox { images, initial_index, }); } AIBlockAction::OpenSubmittedAttachmentLightbox { image_index } => { let decoded_images = self .model .inputs_to_render(ctx) .iter() .filter_map(|input| input.context()) .flat_map(|contexts| contexts.iter()) .filter_map(|context| match context { AIAgentContext::Image(image) => Some(image), _ => None, }) .enumerate() .filter_map(|(submitted_image_index, image)| { let image_bytes = match base64::engine::general_purpose::STANDARD.decode(&image.data) { Ok(image_bytes) => image_bytes, Err(error) => { log::warn!( "Failed to decode submitted image attachment for lightbox: {error}" ); return None; } }; Some((submitted_image_index, image_bytes, image.file_name.clone())) }) .collect_vec(); let mut images = Vec::new(); let mut initial_index = None; for (submitted_image_index, image_bytes, file_name) in decoded_images { let asset_id = format!( "submitted-attachment-lightbox-{}-{submitted_image_index}", self.client_ids.client_exchange_id ); // Raw assets are keyed by exchange/image index, so repeated opens replace the // same cache entry. `AssetCache` also enforces its raw-asset size cap when // inserting, so no lightbox-specific cleanup is needed here. AssetCache::handle(ctx).update(ctx, |asset_cache, ctx| { asset_cache.insert_raw_asset_bytes::( asset_id.clone(), &image_bytes, ctx, ); }); if submitted_image_index == *image_index { initial_index = Some(images.len()); } images.push(ui_components::lightbox::LightboxImage { source: ui_components::lightbox::LightboxImageSource::Resolved { asset_source: galaxyui::assets::asset_cache::AssetSource::Raw { id: asset_id, }, }, description: Some(file_name), }); } let Some(initial_index) = initial_index else { return; }; ctx.dispatch_typed_action(&WorkspaceAction::OpenLightbox { images, initial_index, }); } } ctx.notify(); } } impl AIBlock { /// Lazily create the per-action `RunAgentsCardView` so the /// orchestrate confirmation card can render on its first frame. /// Idempotent: re-running with an already-populated entry leaves /// it unchanged. The view drives Accept dispatch through /// [`BlocklistAIActionModel::execute_run_agents`] itself; only /// `RejectRequested` flows back here so the existing /// [`Self::cancel_action`] entry point handles cancellation. fn ensure_run_agents_card_view( &mut self, action_id: &AIAgentActionId, request: &RunAgentsRequest, ctx: &mut ViewContext, ) { if let Some(existing_view) = self.run_agents_card_views.get(action_id) { // The view was created on an earlier streaming chunk that may // have carried a partial/empty request. Re-sync the edit state // from the latest (potentially more complete) request so the // card renders the correct agent count, summary, etc. existing_view.update(ctx, |view, ctx| { view.update_request(request, ctx); }); return; } // Read the active orchestration config for auto-launch / // denied decisions from the conversation (not the singleton). let active_config = { let history = crate::BlocklistAIHistoryModel::as_ref(ctx); let conv = history.conversation(&self.client_ids.conversation_id); let result = if !request.plan_id.is_empty() { conv.and_then(|conv| { conv.orchestration_config_for_plan(&request.plan_id) .map(|(config, status)| (config.clone(), status)) }) } else { None }; result }; let action_id_clone = action_id.clone(); let request_clone = request.clone(); let action_model = self.action_model.clone(); let run_agents_executor = self.action_model.as_ref(ctx).run_agents_executor(ctx); let block_model = self.model.clone(); let view = ctx.add_typed_action_view(move |ctx_view| { RunAgentsCardView::new( action_id_clone, &request_clone, active_config, action_model, run_agents_executor, block_model, ctx_view, ) }); let action_id_for_event = action_id.clone(); ctx.subscribe_to_view(&view, move |me, _, event, ctx| match event { RunAgentsCardViewEvent::RejectRequested => { me.cancel_action(&action_id_for_event, ctx); } }); self.run_agents_card_views.insert(action_id.clone(), view); } } #[cfg(test)] #[path = "block_tests.rs"] mod tests;