Files
galaxy/app/src/ai/blocklist/context_model.rs
T

994 lines
38 KiB
Rust

//! This module contains state management logic for pending context, where "pending context"
//! is defined as additional context to be attached to the next AI query.
use std::{
collections::{HashMap, HashSet},
path::{Path, PathBuf},
str::FromStr,
sync::Arc,
};
use crate::ai::{
agent::{AnyFileContent, FileContext},
block_context::BlockContext,
};
use super::agent_view::{AgentViewController, AgentViewEntryOrigin, EnterAgentViewError};
use ai::project_context::model::ProjectContextModel;
use galaxy_core::features::FeatureFlag;
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use parking_lot::FairMutex;
use crate::ai::agent::conversation::{AIConversationAutoexecuteMode, ConversationStatus};
use crate::{
ai::{
agent::todos::AIAgentTodoList,
agent::{
conversation::{AIConversation, AIConversationId},
AIAgentAttachment, AIAgentContext, ImageContext,
},
document::ai_document_model::AIDocumentId,
llms::{LLMPreferences, LLMPreferencesEvent},
outline::RepoOutlines,
},
terminal::{
event::{BlockCompletedEvent, BlockType},
model::{block::BlockId, session::Sessions},
model_events::{ModelEvent, ModelEventDispatcher},
TerminalModel,
},
workspaces::user_workspaces::UserWorkspaces,
};
use super::{
block::DirectoryContext, history_model::BlocklistAIHistoryModel, BlocklistAIHistoryEvent,
};
/// A non-image file picked via the "attach file" button, stored until query submission.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PendingFile {
pub file_name: String,
pub file_path: PathBuf,
pub mime_type: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AttachmentType {
Image,
File,
}
/// A pending attachment — either an image (base64 in memory) or a file (path reference).
#[derive(Clone, Debug)]
pub enum PendingAttachment {
Image(ImageContext),
File(PendingFile),
}
impl PendingAttachment {
pub fn file_name(&self) -> &str {
match self {
PendingAttachment::Image(img) => &img.file_name,
PendingAttachment::File(file) => &file.file_name,
}
}
pub fn attachment_type(&self) -> AttachmentType {
match self {
PendingAttachment::Image(_) => AttachmentType::Image,
PendingAttachment::File(_) => AttachmentType::File,
}
}
}
/// The state the pending query is in.
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum PendingQueryState {
/// The next query will continue an existing conversation.
Existing { conversation_id: AIConversationId },
New {
/// Autoexecute override for the new conversation to be started.
autoexecute_override: AIConversationAutoexecuteMode,
},
}
impl Default for PendingQueryState {
fn default() -> Self {
Self::New {
autoexecute_override: AIConversationAutoexecuteMode::default(),
}
}
}
impl PendingQueryState {
pub fn targets_existing_conversation(&self) -> bool {
matches!(self, PendingQueryState::Existing { .. })
}
}
/// Model responsible for keeping track of session context to be attached to the next AI query.
pub struct BlocklistAIContextModel {
terminal_model: Arc<FairMutex<TerminalModel>>,
directory_context: DirectoryContext,
/// `BlockId`s corresponding to blocks to be included as context with the next AI query.
pending_context_block_ids: HashSet<BlockId>,
/// Selected text to be included as context with the next AI query.
pending_context_selected_text: Option<String>,
/// Images and files to be included as attachments with the next AI query.
pending_attachments: Vec<PendingAttachment>,
/// Storage for diff hunk attachments that can be referenced in queries
pending_inline_diff_hunk_attachments: HashMap<String, AIAgentAttachment>,
/// The pending query could be new, which means it starts a new conversation, or follow-up, which means
/// it continues the selected conversation.
///
/// Note that this is intentionally decoupled from the active conversation in the HistoryModel.
/// The active conversation (the one that agent outputs are being streamed to) can be different from the
/// conversation we're following up in for the next query.
pending_query_state: PendingQueryState,
/// The ID of the terminal view this controller is associated with.
terminal_view_id: EntityId,
/// AI document ID to be included as context with the next AI query.
/// When set, the document content will be attached as plain text context.
pending_document_id: Option<AIDocumentId>,
agent_view_controller: ModelHandle<AgentViewController>,
/// Block IDs of user-executed commands to be auto-attached as context.
/// When `AgentViewBlockContext` is enabled, completed user commands are tracked here
/// and automatically included as context with the next user query.
auto_attached_agent_view_user_block_ids: Vec<BlockId>,
/// When true, submitting a prompt while the agent is responding will queue it
/// instead of sending it immediately.
/// Persists across exchanges in the same conversation (like fast-forward).
queue_next_prompt_enabled: bool,
}
pub fn block_context_from_terminal_model(
terminal_model: &TerminalModel,
block_id: &BlockId,
is_auto_attached: bool,
) -> Option<BlockContext> {
let block = terminal_model
.block_list()
.block_index_for_id(block_id)
.and_then(|block_id| terminal_model.block_list().block_at(block_id))?;
// Note, if the user has explicitly asked Agent Mode to include a block as context, we do NOT
// _force_ secrets to be obfuscated. It will respect the user's settings for secret redaction.
let output = block.output_grid().content_summary(5000, 5000, false);
Some(BlockContext {
id: block_id.clone(),
index: block.index(),
command: block.command_to_string(),
output,
exit_code: block.exit_code(),
is_auto_attached,
started_ts: block.start_ts().cloned(),
finished_ts: block.completed_ts().cloned(),
pwd: None,
shell: None,
username: None,
hostname: None,
git_branch: None,
os: None,
session_id: None,
})
}
impl BlocklistAIContextModel {
pub fn new(
sessions: ModelHandle<Sessions>,
model_event_dispatcher: &ModelHandle<ModelEventDispatcher>,
terminal_model: Arc<FairMutex<TerminalModel>>,
terminal_view_id: EntityId,
agent_view_controller: ModelHandle<AgentViewController>,
ctx: &mut ModelContext<Self>,
) -> Self {
ctx.subscribe_to_model(model_event_dispatcher, move |me, event, ctx| match event {
ModelEvent::BlockCompleted(BlockCompletedEvent {
block_type: BlockType::User(user_block_completed),
block_id,
..
}) => {
// If AgentViewBlockContext is enabled and we're in agent view, track user-executed
// blocks for auto-attachment as context.
if FeatureFlag::AgentViewBlockContext.is_enabled()
&& me.agent_view_controller.as_ref(ctx).is_fullscreen()
&& !user_block_completed.was_part_of_agent_interaction
{
me.auto_attached_agent_view_user_block_ids
.push(block_id.clone());
}
// If the block that finished was part of an agent interaction (i.e. LRC finishing),
// we should preserve input context.
if !FeatureFlag::AgentViewBlockContext.is_enabled()
&& !user_block_completed.was_part_of_agent_interaction
{
me.reset_context_to_default(ctx);
}
}
ModelEvent::BlockMetadataReceived(block_metadata_received) => {
let pwd = block_metadata_received
.block_metadata
.current_working_directory()
.map(|s| PathBuf::from(s.to_owned()));
let session_id = block_metadata_received.block_metadata.session_id();
if let Some(session_id) = session_id {
let active_session = sessions.as_ref(ctx).get(session_id);
if let Some(active_session) = active_session {
me.update_directory_context(
pwd.map(|p| p.to_string_lossy().to_string()),
active_session.home_dir().map(|sq| sq.to_owned()),
ctx,
);
}
}
}
_ => {}
});
ctx.subscribe_to_model(&BlocklistAIHistoryModel::handle(ctx), |me, event, ctx| {
if event
.terminal_view_id()
.is_some_and(|id| id != me.terminal_view_id)
{
return;
}
match event {
BlocklistAIHistoryEvent::ClearedConversationsInTerminalView { .. } => {
me.set_pending_query_state(PendingQueryState::default(), ctx);
if FeatureFlag::AgentView.is_enabled() {
me.agent_view_controller.update(ctx, |controller, ctx| {
controller.exit_agent_view(ctx);
});
}
}
BlocklistAIHistoryEvent::SplitConversation {
new_conversation_id,
..
} => {
me.set_pending_query_state_for_existing_conversation(
*new_conversation_id,
AgentViewEntryOrigin::AgentRequestedNewConversation,
ctx,
);
}
_ => {}
}
});
ctx.subscribe_to_model(&LLMPreferences::handle(ctx), |me, event, ctx| {
if let LLMPreferencesEvent::UpdatedActiveAgentModeLLM = event {
let llm_prefs = LLMPreferences::as_ref(ctx);
let vision_supported = llm_prefs.vision_supported(ctx, Some(me.terminal_view_id));
if !vision_supported {
me.clear_pending_images(ctx);
}
}
});
// Clear auto-attached blocks when exiting agent view or switching conversations
ctx.subscribe_to_model(&agent_view_controller, |me, event, _ctx| {
use super::agent_view::AgentViewControllerEvent;
match event {
AgentViewControllerEvent::ExitedAgentView { .. }
| AgentViewControllerEvent::EnteredAgentView { .. } => {
me.auto_attached_agent_view_user_block_ids.clear();
}
AgentViewControllerEvent::ExitConfirmed { .. } => {}
}
});
// In sandboxed/autonomous mode (SDK mode with --sandboxed flag), automatically set
// conversations to RunToCompletion mode so they don't wait for user confirmation.
let pending_query_state =
if galaxy_core::execution_mode::AppExecutionMode::as_ref(ctx).is_sandboxed() {
PendingQueryState::New {
autoexecute_override: AIConversationAutoexecuteMode::RunToCompletion,
}
} else {
Default::default()
};
Self {
terminal_model,
directory_context: Default::default(),
pending_context_block_ids: HashSet::new(),
pending_context_selected_text: None,
pending_attachments: Default::default(),
pending_query_state,
terminal_view_id,
agent_view_controller,
pending_inline_diff_hunk_attachments: Default::default(),
pending_document_id: None,
auto_attached_agent_view_user_block_ids: Vec::new(),
queue_next_prompt_enabled: false,
}
}
/// Resets the set of blocks to be included as context to an empty list.
/// Also removes any selected text that was to be included as context.
pub fn reset_context_to_default(&mut self, ctx: &mut ModelContext<Self>) {
self.set_pending_context_block_ids(vec![], true, ctx);
self.set_pending_context_selected_text(None, true, ctx);
self.clear_pending_attachments(ctx);
self.clear_diff_hunk_attachments();
self.set_pending_document(None, ctx);
self.auto_attached_agent_view_user_block_ids.clear();
}
/// Returns the set `BlockId`s corresponding to blocks to be included as context with the next
/// query.
pub fn pending_context_block_ids(&self) -> &HashSet<BlockId> {
&self.pending_context_block_ids
}
/// Returns selected text to be included as context with the next query.
pub fn pending_context_selected_text(&self) -> Option<&String> {
self.pending_context_selected_text.as_ref()
}
/// Returns all pending attachments (images and files) for the next query.
pub fn pending_attachments(&self) -> &[PendingAttachment] {
&self.pending_attachments
}
/// Returns only the pending images for the next query.
pub fn pending_images(&self) -> Vec<&ImageContext> {
self.pending_attachments
.iter()
.filter_map(|a| match a {
PendingAttachment::Image(img) => Some(img),
PendingAttachment::File(_) => None,
})
.collect()
}
/// Returns only the pending files for the next query.
pub fn pending_files(&self) -> Vec<&PendingFile> {
self.pending_attachments
.iter()
.filter_map(|a| match a {
PendingAttachment::File(file) => Some(file),
PendingAttachment::Image(_) => None,
})
.collect()
}
/// Given a block ID, transform it into an AIAgentContext::Block.
pub fn transform_block_to_context(
&self,
block_id: &BlockId,
is_auto_attached_in_agent_view: bool,
) -> Option<AIAgentContext> {
let terminal_model = self.terminal_model.lock();
block_context_from_terminal_model(&terminal_model, block_id, is_auto_attached_in_agent_view)
.map(Box::new)
.map(AIAgentContext::Block)
}
/// Returns `AIAgentContext` for the blocks to be included in the current AI query.
/// If `is_user_query` is true, includes blocks, selected text, and images as context.
/// If false, excludes these user-specific contexts but includes everything else.
pub fn pending_context(&self, app: &AppContext, is_user_query: bool) -> Vec<AIAgentContext> {
let pwd = self.current_pwd();
let is_pwd_indexed = if cfg!(feature = "agent_mode_evals") {
// In evals, we want to disable file outline based search. Full
// source code embedding based context is still available.
false
} else {
UserWorkspaces::as_ref(app).is_codebase_context_enabled(app)
&& pwd.as_ref().is_some_and(|pwd| {
RepoOutlines::as_ref(app).is_directory_indexed(Path::new(&pwd))
})
};
let project_rules = if let Some(pwd) = pwd.clone().and_then(|path| {
PathBuf::from_str(&path)
.ok()
.and_then(|s| s.canonicalize().ok())
}) {
ProjectContextModel::as_ref(app).find_applicable_rules(&pwd)
} else {
None
};
let mut context = Vec::new();
// Always include directory context
context.push(AIAgentContext::Directory {
pwd,
home_dir: self.home_directory(),
are_file_symbols_indexed: is_pwd_indexed,
});
let (head, branch) = {
let terminal_model = self.terminal_model.lock();
let active_block = terminal_model.block_list().active_block();
(
active_block.git_branch().cloned(),
active_block.git_branch_name().cloned(),
)
};
if head.is_some() || branch.is_some() {
context.push(AIAgentContext::Git {
head: head.unwrap_or_default(),
branch,
});
}
// Always include project rules if available
if let Some(rules) = project_rules {
context.push(AIAgentContext::ProjectRules {
root_path: rules.root_path.to_string_lossy().into(),
active_rules: rules
.active_rules
.into_iter()
.map(|rule| {
let line_count = rule.content.lines().count();
FileContext {
file_name: rule.path.to_string_lossy().into(),
content: AnyFileContent::StringContent(rule.content.clone()),
line_range: None,
last_modified: None,
line_count,
}
})
.collect(),
additional_rule_paths: rules.additional_rule_paths,
});
}
// If this is a user query, add user-selected contexts
if is_user_query {
// Add selected blocks (manually attached)
for block_id in &self.pending_context_block_ids {
if let Some(block_context) = self.transform_block_to_context(block_id, false) {
context.push(block_context);
}
}
// Add auto-attached user-executed blocks (when AgentViewBlockContext is enabled)
if FeatureFlag::AgentViewBlockContext.is_enabled() {
for block_id in &self.auto_attached_agent_view_user_block_ids {
// Skip if already in pending_context_block_ids to avoid duplicates
if !self.pending_context_block_ids.contains(block_id) {
if let Some(block_context) = self.transform_block_to_context(block_id, true)
{
context.push(block_context);
}
}
}
}
// Add selected text
if let Some(selected_text) = &self.pending_context_selected_text {
context.push(AIAgentContext::SelectedText(selected_text.clone()));
}
// Add images from pending attachments
for attachment in &self.pending_attachments {
if let PendingAttachment::Image(image) = attachment {
context.push(AIAgentContext::Image(image.clone()));
}
}
}
context
}
pub fn current_pwd(&self) -> Option<String> {
self.directory_context.pwd.clone()
}
pub fn home_directory(&self) -> Option<String> {
self.directory_context.home_dir.clone()
}
/// Updates the context model's stored directory context.
pub fn update_directory_context(
&mut self,
pwd: Option<String>,
home_dir: Option<String>,
ctx: &mut ModelContext<Self>,
) {
self.directory_context = DirectoryContext { pwd, home_dir };
ctx.emit(BlocklistAIContextEvent::UpdatedPendingContext {
previous_block_ids: self.pending_context_block_ids.clone(),
requires_block_resync: true,
requires_text_resync: false,
});
}
/// Set `requires_visual_resync` to `false` only if the pending context was modified as a result
/// of manual user selections. In such cases, a visual resync won't be required because the
/// pending context was synchronized to the manual selection.
pub fn set_pending_context_block_ids(
&mut self,
ids: impl IntoIterator<Item = BlockId>,
requires_visual_resync: bool,
ctx: &mut ModelContext<Self>,
) {
// Filter out blocks that can't be used as AI context
let filtered_ids: Vec<BlockId> = {
let terminal_model = self.terminal_model.lock();
ids.into_iter()
.filter(|block_id| {
terminal_model
.block_list()
.block_with_id(block_id)
.map(|block| {
block.can_be_ai_context(terminal_model.block_list().agent_view_state())
})
.unwrap_or(false)
})
.collect()
};
let new_pending_context_block_ids = HashSet::from_iter(filtered_ids);
// Maintain the invariant that we can't simultaneously use both blocks and selected text
// as context for the next AI request.
if !new_pending_context_block_ids.is_empty() {
self.pending_context_selected_text = None;
}
if new_pending_context_block_ids != self.pending_context_block_ids {
let previous_block_ids = self.pending_context_block_ids.clone();
ctx.emit(BlocklistAIContextEvent::UpdatedPendingContext {
previous_block_ids,
requires_block_resync: requires_visual_resync,
requires_text_resync: !new_pending_context_block_ids.is_empty(),
});
}
self.pending_context_block_ids = new_pending_context_block_ids;
}
/// Set `requires_visual_resync` to `false` only if the pending context was modified as a result
/// of manual user selections. In such cases, a visual resync won't be required because the
/// pending context was synchronized to the manual selection.
pub fn set_pending_context_selected_text(
&mut self,
text: Option<String>,
requires_visual_resync: bool,
ctx: &mut ModelContext<Self>,
) {
// It doesn't make sense to allow empty text as AI context.
// Enforcing this assertion here ensures we don't run into weird behaviour with `Some("")` later.
debug_assert!(!matches!(text.as_deref(), Some("")));
let previous_block_ids = self.pending_context_block_ids.clone();
// Maintain the invariant that we can't simultaneously use both blocks and selected text
// as context for the next AI request.
if text.is_some() {
self.pending_context_block_ids = HashSet::new();
}
if text != self.pending_context_selected_text {
ctx.emit(BlocklistAIContextEvent::UpdatedPendingContext {
previous_block_ids,
requires_block_resync: text.is_some(),
requires_text_resync: requires_visual_resync,
});
}
self.pending_context_selected_text = text;
}
/// Set the pending AI document to be included as context with the next AI query.
pub fn set_pending_document(
&mut self,
document_id: Option<AIDocumentId>,
ctx: &mut ModelContext<Self>,
) {
if document_id != self.pending_document_id {
self.pending_document_id = document_id;
ctx.emit(BlocklistAIContextEvent::UpdatedPendingContext {
previous_block_ids: self.pending_context_block_ids.clone(),
requires_block_resync: false,
requires_text_resync: false,
});
}
}
/// Get the pending AI document ID if one is set.
pub fn pending_document_id(&self) -> Option<AIDocumentId> {
self.pending_document_id
}
pub fn clear_pending_images(&mut self, ctx: &mut ModelContext<Self>) {
let original_attachment_count = self.pending_attachments.len();
self.pending_attachments
.retain(|a| !matches!(a, PendingAttachment::Image(_)));
if self.pending_attachments.len() < original_attachment_count {
ctx.emit(BlocklistAIContextEvent::UpdatedPendingContext {
previous_block_ids: self.pending_context_block_ids.clone(),
requires_block_resync: false,
requires_text_resync: false,
});
}
}
pub fn append_pending_images(
&mut self,
images: Vec<ImageContext>,
ctx: &mut ModelContext<Self>,
) {
if !images.is_empty() {
let attachments: Vec<PendingAttachment> =
images.into_iter().map(PendingAttachment::Image).collect();
self.append_pending_attachments(attachments, ctx);
}
}
pub fn remove_pending_image(&mut self, index: usize, ctx: &mut ModelContext<Self>) {
// Find the nth image in the combined list and remove it.
let position = self
.pending_attachments
.iter()
.enumerate()
.filter(|(_, a)| matches!(a, PendingAttachment::Image(_)))
.nth(index)
.map(|(i, _)| i);
if let Some(pos) = position {
self.remove_pending_attachment(pos, ctx);
}
}
/// Returns the number of images removed
pub fn remove_last_pending_images(
&mut self,
images_to_remove: usize,
ctx: &mut ModelContext<Self>,
) -> usize {
let image_indices: Vec<usize> = self
.pending_attachments
.iter()
.enumerate()
.filter(|(_, a)| matches!(a, PendingAttachment::Image(_)))
.map(|(i, _)| i)
.collect();
let len = image_indices.len();
if images_to_remove == 0 || len == 0 {
return 0;
}
let to_remove = images_to_remove.min(len);
// Remove from the end to avoid shifting indices.
for &idx in image_indices.iter().rev().take(to_remove) {
self.pending_attachments.remove(idx);
}
ctx.emit(BlocklistAIContextEvent::UpdatedPendingContext {
previous_block_ids: self.pending_context_block_ids.clone(),
requires_block_resync: false,
requires_text_resync: false,
});
to_remove
}
pub fn pending_query_state(&self) -> &PendingQueryState {
&self.pending_query_state
}
/// Convenience function to set pending query state to continue an existing conversation by ID.
pub fn set_pending_query_state_for_existing_conversation(
&mut self,
conversation_id: AIConversationId,
origin: AgentViewEntryOrigin,
ctx: &mut ModelContext<Self>,
) {
self.set_pending_query_state(PendingQueryState::Existing { conversation_id }, ctx);
if FeatureFlag::AgentView.is_enabled() {
if let Err(e) = self.agent_view_controller.update(ctx, |controller, ctx| {
controller.try_enter_agent_view(Some(conversation_id), origin, ctx)
}) {
log::error!("Failed to enter agent view for existing conversation: {e}");
}
}
}
/// Sets the pending query state to the defaults for a *new* conversation (i.e. not a
/// followup).
pub fn set_pending_query_state_for_new_conversation(
&mut self,
origin: AgentViewEntryOrigin,
ctx: &mut ModelContext<Self>,
) {
self.set_pending_query_state(PendingQueryState::default(), ctx);
if FeatureFlag::AgentView.is_enabled() {
if let Err(e) = self.agent_view_controller.update(ctx, |controller, ctx| {
controller.try_enter_agent_view(None, origin, ctx)
}) {
log::error!("Failed to enter agent view for new conversation: {e}");
}
}
}
/// Attempts to enter agent view for a new conversation and returns the conversation ID.
/// This should be used when a slash command needs to create a new conversation
/// and the AgentView feature flag is enabled.
///
/// Returns `Ok(conversation_id)` on success, or `Err` if entry is blocked.
pub fn try_enter_agent_view_for_new_conversation(
&mut self,
origin: AgentViewEntryOrigin,
ctx: &mut ModelContext<Self>,
) -> Result<AIConversationId, EnterAgentViewError> {
let conversation_id = self.agent_view_controller.update(ctx, |controller, ctx| {
controller.try_enter_agent_view(None, origin, ctx)
})?;
self.set_pending_query_state(PendingQueryState::default(), ctx);
Ok(conversation_id)
}
/// Sets the value of `pending_query_state`, emitting an event if it changed.
fn set_pending_query_state(&mut self, state: PendingQueryState, ctx: &mut ModelContext<Self>) {
if self.pending_query_state != state {
self.pending_query_state = state;
ctx.emit(BlocklistAIContextEvent::PendingQueryStateUpdated);
}
}
/// Returns `true` if a new conversation may be created.
pub fn can_start_new_conversation(&self) -> bool {
let terminal_model = self.terminal_model.lock();
if FeatureFlag::AgentView.is_enabled() {
!terminal_model
.block_list()
.active_block()
.is_active_and_long_running()
} else {
!terminal_model
.block_list()
.active_block()
.is_agent_in_control()
}
}
/// Returns the conversation ID the pending query is following up for, if any.
/// None if the pending query should start a new conversation.
pub fn selected_conversation_id(&self, ctx: &AppContext) -> Option<AIConversationId> {
if FeatureFlag::AgentView.is_enabled() {
return self
.agent_view_controller
.as_ref(ctx)
.agent_view_state()
.active_conversation_id();
}
match self.pending_query_state {
PendingQueryState::Existing {
conversation_id, ..
} => Some(conversation_id),
PendingQueryState::New { .. } => None,
}
}
pub fn selected_conversation<'a>(&self, ctx: &'a AppContext) -> Option<&'a AIConversation> {
self.selected_conversation_id(ctx)
.as_ref()
.and_then(|conversation_id| {
BlocklistAIHistoryModel::as_ref(ctx).conversation(conversation_id)
})
}
pub fn selected_conversation_todolist<'a>(
&self,
ctx: &'a AppContext,
) -> Option<&'a AIAgentTodoList> {
self.selected_conversation(ctx)
.and_then(|c| c.active_todo_list())
.and_then(|todo_list| {
// Don't show todo list if it's empty or finished
if todo_list.is_empty() || todo_list.is_finished() {
None
} else {
Some(todo_list)
}
})
}
pub fn pending_query_autoexecute_override(
&self,
ctx: &AppContext,
) -> AIConversationAutoexecuteMode {
match &self.pending_query_state {
PendingQueryState::New {
autoexecute_override,
} => *autoexecute_override,
PendingQueryState::Existing {
conversation_id, ..
} => BlocklistAIHistoryModel::as_ref(ctx)
.conversation(conversation_id)
.map(|conversation| conversation.autoexecute_override())
.unwrap_or_default(),
}
}
pub fn is_queue_next_prompt_enabled(&self) -> bool {
self.queue_next_prompt_enabled
}
pub fn toggle_queue_next_prompt(&mut self, ctx: &mut ModelContext<Self>) {
self.queue_next_prompt_enabled = !self.queue_next_prompt_enabled;
ctx.emit(BlocklistAIContextEvent::QueueNextPromptToggled);
}
pub fn toggle_pending_query_autoexecute(&mut self, ctx: &mut ModelContext<Self>) {
// When AgentView is enabled, the autoexecution toggle should apply to the active agent view
// conversation -- even when starting a new conversation, the agent view always has a conversation
// ID.
if FeatureFlag::AgentView.is_enabled() {
if let Some(conversation_id) = self
.agent_view_controller
.as_ref(ctx)
.agent_view_state()
.active_conversation_id()
{
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.toggle_autoexecute_override(
&conversation_id,
self.terminal_view_id,
ctx,
);
});
}
return;
}
match &mut self.pending_query_state {
PendingQueryState::New {
autoexecute_override,
} => {
*autoexecute_override = if *autoexecute_override
== AIConversationAutoexecuteMode::RespectUserSettings
{
AIConversationAutoexecuteMode::RunToCompletion
} else {
AIConversationAutoexecuteMode::RespectUserSettings
};
ctx.emit(BlocklistAIContextEvent::PendingQueryStateUpdated);
}
PendingQueryState::Existing {
conversation_id, ..
} => {
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.toggle_autoexecute_override(
conversation_id,
self.terminal_view_id,
ctx,
);
});
}
}
}
/// Returns true if the pending query targets an existing conversation
/// (as opposed to starting a new one).
pub fn is_targeting_existing_conversation(&self) -> bool {
self.pending_query_state.targets_existing_conversation()
}
/// Returns the status of the selected conversation for purposes of rendering the input hint
/// text, or `None` if there is no selected conversation to display (either because no
/// conversation is selected, or because the selected conversation is empty/passive/untitled
/// and should be treated as a "new" conversation). Mirrors the `agent_indicator` pattern in
/// `app/src/tab.rs`.
pub fn selected_conversation_status_for_hint(
&self,
app: &AppContext,
) -> Option<ConversationStatus> {
let conversation = self.selected_conversation(app)?;
if conversation.is_empty()
|| conversation.is_entirely_passive()
|| conversation.title().is_none()
{
return None;
}
Some(conversation.status().clone())
}
/// Returns true if there are any blocks that can be used as AI context.
pub fn can_attach_blocks(&self) -> bool {
let terminal_model = self.terminal_model.lock();
terminal_model
.block_list()
.blocks()
.iter()
.any(|block| block.can_be_ai_context(terminal_model.block_list().agent_view_state()))
}
/// Register a diff hunk attachment that can be referenced in future queries
pub fn register_diff_hunk_attachment(
&mut self,
diff_hunk_id: String,
attachment: AIAgentAttachment,
) {
self.pending_inline_diff_hunk_attachments
.insert(diff_hunk_id, attachment);
}
/// Get a diff hunk attachment by its ID
pub fn get_diff_hunk_attachment(&self, diff_hunk_id: &str) -> Option<&AIAgentAttachment> {
self.pending_inline_diff_hunk_attachments.get(diff_hunk_id)
}
/// Clear all diff hunk attachments (should be called after each request)
pub fn clear_diff_hunk_attachments(&mut self) {
self.pending_inline_diff_hunk_attachments.clear();
}
/// Appends attachments to the pending list.
pub fn append_pending_attachments(
&mut self,
attachments: Vec<PendingAttachment>,
ctx: &mut ModelContext<Self>,
) {
if !attachments.is_empty() {
ctx.emit(BlocklistAIContextEvent::UpdatedPendingContext {
previous_block_ids: self.pending_context_block_ids.clone(),
requires_block_resync: false,
requires_text_resync: false,
});
}
self.pending_attachments.extend(attachments);
}
/// Removes an attachment by index.
pub fn remove_pending_attachment(&mut self, index: usize, ctx: &mut ModelContext<Self>) {
if index < self.pending_attachments.len() {
self.pending_attachments.remove(index);
ctx.emit(BlocklistAIContextEvent::UpdatedPendingContext {
previous_block_ids: self.pending_context_block_ids.clone(),
requires_block_resync: false,
requires_text_resync: false,
});
}
}
/// Clears all pending attachments.
pub fn clear_pending_attachments(&mut self, ctx: &mut ModelContext<Self>) {
if !self.pending_attachments.is_empty() {
ctx.emit(BlocklistAIContextEvent::UpdatedPendingContext {
previous_block_ids: self.pending_context_block_ids.clone(),
requires_block_resync: false,
requires_text_resync: false,
});
}
self.pending_attachments.clear();
}
}
pub enum BlocklistAIContextEvent {
/// The bool fields determine whether a visual resync is needed for each respective selection type.
/// For example, if selected text is cleared via the `BlocklistAIContextModel` **only**, then
/// the `TerminalView`'s current text selection should be visually cleared as well.
UpdatedPendingContext {
previous_block_ids: HashSet<BlockId>,
requires_block_resync: bool,
requires_text_resync: bool,
},
/// Emitted whenever the value changes.
PendingQueryStateUpdated,
QueueNextPromptToggled,
}
impl Entity for BlocklistAIContextModel {
type Event = BlocklistAIContextEvent;
}