use galaxy_core::features::FeatureFlag; use galaxy_core::send_telemetry_from_ctx; use galaxy_core::ui::appearance::Appearance; use galaxyui::keymap::Keystroke; use galaxyui::{EntityId, SingletonEntity, ViewContext}; use crate::ai::agent::conversation::AIConversationId; use crate::ai::blocklist::agent_view::{ AgentViewEntryBlock, AgentViewEntryBlockEvent, AgentViewEntryBlockParams, AgentViewEntryOrigin, AutoTriggerBehavior, DismissalStrategy, EnterAgentViewError, EphemeralMessage, ENTER_OR_EXIT_CONFIRMATION_WINDOW, }; use crate::ai::blocklist::history_model::CloudConversationData; use crate::ai::blocklist::BlocklistAIHistoryModel; use crate::global_resource_handles::GlobalResourceHandlesProvider; use crate::persistence::ModelEvent; use crate::server::telemetry::TelemetryAgentViewEntryOrigin; use crate::terminal::input::message_bar::{Message, MessageItem}; use crate::terminal::model::rich_content::RichContentType; use crate::terminal::view::load_ai_conversation::{ RestoreConversationEntryBehavior, RestoredAIConversation, }; use crate::terminal::view::{ AgentViewEntryMetadata, RichContentInsertionPosition, RichContentMetadata, }; use crate::terminal::TerminalView; use crate::view_components::DismissibleToast; use crate::workspace::ToastStack; use crate::TelemetryEvent; pub const ENTER_AGAIN_TO_SEND_MESSAGE_ID: &str = "enter_again_to_send"; impl TerminalView { pub fn enter_agent_view( &mut self, initial_prompt: Option, conversation_id: Option, origin: AgentViewEntryOrigin, ctx: &mut ViewContext, ) { if let Some(id) = conversation_id { self.enter_agent_view_for_conversation(initial_prompt, origin, id, ctx); } else { self.enter_agent_view_for_new_conversation(initial_prompt, origin, ctx); } } pub fn enter_agent_view_for_new_conversation( &mut self, initial_prompt: Option, origin: AgentViewEntryOrigin, ctx: &mut ViewContext, ) { // Don't allow starting a new conversation while the agent is in control. 3p cloud // viewers enter agent view to wrap an existing run's content and are not starting a // new conversation, so they are exempt from this guard. if !matches!(&origin, AgentViewEntryOrigin::ThirdPartyCloudAgent) && !self .ai_context_model .as_ref(ctx) .can_start_new_conversation() { let window_id = ctx.window_id(); ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { toast_stack.add_ephemeral_toast( DismissibleToast::error( "Cannot start a new conversation while agent is monitoring a command." .to_string(), ), window_id, ctx, ); }); return; } if let Err(e) = self.try_enter_agent_view(initial_prompt, origin.clone(), None, ctx) { log::error!( "Failed to enter agent view for new conversation from origin {:?}: {:?}", origin, e ); self.show_error_toast(e.to_string(), ctx); } self.redetermine_global_focus(ctx); } // Enters the agent view for a restored CLI agent transcript, setting the title using the // restored CLI conversation metadata if we have it. pub(crate) fn enter_agent_view_for_restored_cli_agent( &mut self, fallback_title: String, ctx: &mut ViewContext, ) -> Option { let origin = AgentViewEntryOrigin::ThirdPartyCloudAgent; match self.try_enter_agent_view(None, origin.clone(), None, ctx) { Ok(conversation_id) => { let title = fallback_title.trim(); if !title.is_empty() { BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _| { if let Some(conversation) = history.conversation_mut(&conversation_id) { conversation.set_fallback_display_title(title.to_owned()); } }); } self.redetermine_global_focus(ctx); Some(conversation_id) } Err(e) => { log::error!( "Failed to enter agent view for restored CLI agent from origin {:?}: {:?}", origin, e ); self.show_error_toast(e.to_string(), ctx); self.redetermine_global_focus(ctx); None } } } pub fn enter_agent_view_for_conversation( &mut self, initial_prompt: Option, origin: AgentViewEntryOrigin, conversation_id: AIConversationId, ctx: &mut ViewContext, ) { let history_model = BlocklistAIHistoryModel::handle(ctx); let (in_memory_conversation, is_live) = { let history_model_ref = history_model.as_ref(ctx); let in_memory_conversation = history_model_ref.conversation(&conversation_id).cloned(); let is_live = in_memory_conversation.is_some() && history_model_ref .all_live_conversations_for_terminal_surface(self.view_id) .any(|conversation| conversation.id() == conversation_id); (in_memory_conversation, is_live) }; if is_live { if let Err(e) = self.try_enter_agent_view( initial_prompt.clone(), origin.clone(), Some(conversation_id), ctx, ) { log::error!( "Failed to enter agent view for existing conversation ({:?}) from origin {:?}: {:?}", conversation_id, origin, e ); self.show_error_toast(e.to_string(), ctx); } } else if let Some(conversation) = in_memory_conversation { self.restore_conversation_after_view_creation( RestoredAIConversation::new(conversation), false, RestoreConversationEntryBehavior::PreserveAgentViewState, ctx, ); if let Err(e) = self.try_enter_agent_view( initial_prompt.clone(), origin.clone(), Some(conversation_id), ctx, ) { log::error!( "Failed to enter agent view for restored in-memory conversation ({:?}) from origin {:?}: {:?}", conversation_id, origin, e ); self.show_error_toast(e.to_string(), ctx); } } else { let conversation_id_copy = conversation_id; let future = history_model .as_ref(ctx) .load_conversation_data(conversation_id_copy, ctx); ctx.spawn(future, move |me, conversation, ctx| { let Some(conversation) = conversation else { me.show_error_toast( format!("Failed to load conversation with id: {conversation_id}"), ctx, ); return; }; // For Oz conversations, restore data and then re-enter agent view (the // conversation will be in memory after restoration). // For CLI agent conversations, restore the block snapshot only. Because we // don't update the in-memory model in this case, attempting to re-enter agent // view will trigger an infinite loop of fetching and loading conversation data // from the server. #[allow(clippy::type_complexity)] let on_restored: Box< dyn FnOnce(&mut Self, &mut ViewContext), > = if matches!(&conversation, CloudConversationData::Oz(_)) { Box::new(move |me, ctx| { me.enter_agent_view_for_conversation( initial_prompt, origin, conversation_id, ctx, ); }) } else { if !FeatureFlag::AgentHarness.is_enabled() { log::warn!("AgentHarness flag is disabled; ignoring CLI agent conversation {conversation_id}"); return; } Box::new(|_, _| {}) }; let is_local = BlocklistAIHistoryModel::handle(ctx) .as_ref(ctx) .get_conversation_metadata(&conversation_id) .is_some_and(|m| m.has_local_data); me.restore_conversation_and_directory_context( conversation, false, RestoreConversationEntryBehavior::PreserveAgentViewState, is_local, on_restored, ctx, ); }); } } pub(super) fn try_enter_agent_view( &mut self, initial_prompt: Option, origin: AgentViewEntryOrigin, conversation_id: Option, ctx: &mut ViewContext, ) -> Result { // Capture pending context block IDs before entering agent view. let pending_attached_blocks = self .ai_context_model .as_ref(ctx) .pending_context_block_ids() .clone(); let was_in_agent_view_already = self .agent_view_controller .as_ref(ctx) .agent_view_state() .is_fullscreen(); let conversation_id = self.agent_view_controller.update(ctx, |controller, ctx| { controller.try_enter_agent_view(conversation_id, origin.clone(), ctx) })?; // Associate pending context blocks with the new conversation so they remain // visible in the agent view. This must happen after the conversation is created // but before any re-filtering of pending context block IDs occurs. if !pending_attached_blocks.is_empty() { let attached_blocks = self .model .lock() .block_list_mut() .associate_blocks_with_conversation( pending_attached_blocks.iter(), conversation_id, ); // Persist the updated visibility for each modified block if let Some(sender) = GlobalResourceHandlesProvider::as_ref(ctx) .get() .model_event_sender .as_ref() { for (block_id, agent_view_visibility) in attached_blocks { if let Err(e) = sender.send(ModelEvent::UpdateBlockAgentViewVisibility { block_id: block_id.to_string(), agent_view_visibility: agent_view_visibility.into(), }) { log::error!("Error sending UpdateBlockAgentViewVisibility event: {e:?}"); } } } } let mut did_auto_trigger_request = false; // Show ephemeral message when entering agent view via input with a prompt if let Some(initial_prompt) = initial_prompt { let should_auto_submit = match origin.should_autotrigger_request() { AutoTriggerBehavior::Always => true, AutoTriggerBehavior::InAgentView => was_in_agent_view_already, AutoTriggerBehavior::Never => false, }; if should_auto_submit { // Clear the "enter again to send" ephemeral message if it's currently showing self.ephemeral_message_model.update(ctx, |model, ctx| { if model .current_message() .and_then(|msg| msg.id()) .is_some_and(|id| id == ENTER_AGAIN_TO_SEND_MESSAGE_ID) { model.clear_message(ctx); } }); self.ai_controller.update(ctx, |controller, ctx| { controller.send_user_query_in_conversation( initial_prompt, conversation_id, None, ctx, ); }); did_auto_trigger_request = true; } else { let appearance = Appearance::handle(ctx).as_ref(ctx); let message = Message::new(vec![ MessageItem::keystroke(Keystroke { key: "enter".to_owned(), ..Default::default() }), MessageItem::text("again to send to agent"), ]) .with_text_color(appearance.theme().ansi_fg_magenta()); self.ephemeral_message_model.update(ctx, |model, ctx| { // Keep this explicit (instead of relying on the default message duration) so // "enter again to send" stays aligned with the broader confirmation cadence. model.show_ephemeral_message( EphemeralMessage::new( message, DismissalStrategy::Timer(ENTER_OR_EXIT_CONFIRMATION_WINDOW), ) .with_id(ENTER_AGAIN_TO_SEND_MESSAGE_ID), ctx, ); }); self.input.update(ctx, |input, ctx| { input.replace_buffer_content(&initial_prompt, ctx); }); } } send_telemetry_from_ctx!( TelemetryEvent::AgentViewEntered { origin: TelemetryAgentViewEntryOrigin::from(origin), did_auto_trigger_request, }, ctx ); // Mark all AgentViewEntry rich content as dirty so their heights get // re-measured. When the agent view is active, AgentViewEntryBlock renders // as Empty (0 height). When exiting, we need to force a re-layout so the // block's actual height is restored. The dirty item processing happens // before viewport iteration, so this works even for 0-height items at // the prefix of the blocklist. let mut model = self.model.lock(); self.mark_all_rich_content_items_dirty_where(&mut model, |metadata| { matches!(metadata, RichContentMetadata::AgentViewEntry(_)) }); drop(model); Ok(conversation_id) } pub(super) fn insert_agent_view_entry_block( &mut self, params: AgentViewEntryBlockParams, position: RichContentInsertionPosition, ctx: &mut ViewContext, ) { if BlocklistAIHistoryModel::as_ref(ctx) .conversation(¶ms.conversation_id) .is_some_and(|conversation| conversation.is_entirely_passive()) { return; } let conversation_id = params.conversation_id; let origin = params.origin.clone(); let agent_view_block = ctx.add_typed_action_view(|ctx| AgentViewEntryBlock::new(params, ctx)); ctx.subscribe_to_view(&agent_view_block, |me, _, event, ctx| match event { AgentViewEntryBlockEvent::EnterAgentView { conversation_id } => me .enter_agent_view_for_conversation( None, AgentViewEntryOrigin::AgentViewBlock, *conversation_id, ctx, ), AgentViewEntryBlockEvent::OpenConversationContextMenu { conversation_id, agent_view_entry_block_id, position, } => me.open_agent_view_entry_context_menu( *conversation_id, *agent_view_entry_block_id, *position, ctx, ), AgentViewEntryBlockEvent::ForkConversation { conversation_id } => { me.fork_ai_conversation(*conversation_id, None, ctx); } }); self.insert_rich_content( Some(RichContentType::EnterAgentView), agent_view_block, Some(RichContentMetadata::AgentViewEntry( AgentViewEntryMetadata { conversation_id, origin, }, )), position, ctx, ); } /// Retags the rich content view with the given id so it renders under `conversation_id`'s /// agent view. Updates both the local `rich_content_views` entry and the block list so /// `should_hide_for_agent_view_state` picks up the new association. pub(super) fn set_rich_content_agent_view_conversation_id( &mut self, rich_content_view_id: EntityId, conversation_id: AIConversationId, ) { let Some(rich_content) = self .rich_content_views .iter_mut() .find(|rich_content| rich_content.view_id() == rich_content_view_id) else { return; }; rich_content.set_agent_view_conversation_id(Some(conversation_id)); self.model .lock() .block_list_mut() .update_agent_view_conversation_id_for_rich_content( rich_content_view_id, Some(conversation_id), ); } }