Files
galaxy/app/src/terminal/view/agent_view.rs
T

443 lines
18 KiB
Rust

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<String>,
conversation_id: Option<AIConversationId>,
origin: AgentViewEntryOrigin,
ctx: &mut ViewContext<Self>,
) {
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<String>,
origin: AgentViewEntryOrigin,
ctx: &mut ViewContext<Self>,
) {
// 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<Self>,
) -> Option<AIConversationId> {
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<String>,
origin: AgentViewEntryOrigin,
conversation_id: AIConversationId,
ctx: &mut ViewContext<Self>,
) {
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<Self>),
> = 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<String>,
origin: AgentViewEntryOrigin,
conversation_id: Option<AIConversationId>,
ctx: &mut ViewContext<Self>,
) -> Result<AIConversationId, EnterAgentViewError> {
// 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<Self>,
) {
if BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&params.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),
);
}
}