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

902 lines
34 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};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use ai::project_context::model::ProjectContextModel;
use galaxy_core::features::FeatureFlag;
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
use galaxyui::{
AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity, WeakModelHandle,
};
use parking_lot::FairMutex;
use super::agent_view::{AgentViewEntryOrigin, EnterAgentViewError};
use super::block::DirectoryContext;
use super::{ConversationSelectionEvent, ConversationSelectionHandle};
use crate::ai::agent::conversation::{
AIConversation, AIConversationAutoexecuteMode, AIConversationId, ConversationStatus,
};
use crate::ai::agent::todos::AIAgentTodoList;
use crate::ai::agent::{
AIAgentAttachment, AIAgentContext, AnyFileContent, FileContext, ImageContext,
};
use crate::ai::block_context::BlockContext;
use crate::ai::document::ai_document_model::AIDocumentId;
use crate::ai::llms::{LLMPreferences, LLMPreferencesEvent};
use crate::ai::outline::RepoOutlines;
use crate::code_review::github_repo_model::GitHubRepoModel;
use crate::terminal::event::{BlockCompletedEvent, BlockType};
use crate::terminal::model::block::{BlockId, BlockMetadata};
use crate::terminal::model::session::Sessions;
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::terminal::TerminalModel;
use crate::util::git::{PrInfo, RepositoryInfo};
use crate::workspaces::user_workspaces::UserWorkspaces;
/// 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,
}
}
}
/// 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,
github_repo_model: Option<WeakModelHandle<GitHubRepoModel>>,
/// `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>,
conversation_selection: ConversationSelectionHandle,
/// The ID of the terminal surface this model is associated with.
terminal_surface_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>,
/// 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>,
}
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 {
/// Creates pending context state for a terminal surface.
pub fn new(
sessions: ModelHandle<Sessions>,
model_event_dispatcher: &ModelHandle<ModelEventDispatcher>,
terminal_model: Arc<FairMutex<TerminalModel>>,
terminal_surface_id: EntityId,
conversation_selection: ConversationSelectionHandle,
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
.conversation_selection
.as_ref(ctx)
.is_conversation_fullscreen(ctx)
&& !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(e) => {
me.apply_block_metadata_directory_context(&e.block_metadata, &sessions, ctx);
}
ModelEvent::BlockWorkingDirectoryUpdated(e) => {
me.apply_block_metadata_directory_context(&e.block_metadata, &sessions, 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_surface_id));
if !vision_supported {
me.clear_pending_images(ctx);
}
}
});
ctx.subscribe_to_model(&conversation_selection, |me, _, event, ctx| match event {
ConversationSelectionEvent::Changed => {
ctx.emit(BlocklistAIContextEvent::PendingQueryStateUpdated);
}
ConversationSelectionEvent::Activated { .. }
| ConversationSelectionEvent::Deactivated { .. } => {
me.auto_attached_agent_view_user_block_ids.clear();
}
});
Self {
terminal_model,
directory_context: Default::default(),
github_repo_model: None,
pending_context_block_ids: HashSet::new(),
pending_context_selected_text: None,
pending_attachments: Default::default(),
conversation_selection,
terminal_surface_id,
pending_inline_diff_hunk_attachments: Default::default(),
pending_document_id: None,
auto_attached_agent_view_user_block_ids: Vec::new(),
}
}
/// Test-only constructor that skips production subscriptions and singleton lookups.
#[cfg(test)]
pub(crate) fn new_for_test(
terminal_model: Arc<FairMutex<TerminalModel>>,
terminal_surface_id: EntityId,
conversation_selection: ConversationSelectionHandle,
) -> Self {
Self {
terminal_model,
directory_context: Default::default(),
github_repo_model: None,
pending_context_block_ids: HashSet::new(),
pending_context_selected_text: None,
pending_attachments: Default::default(),
conversation_selection,
terminal_surface_id,
pending_inline_diff_hunk_attachments: Default::default(),
pending_document_id: None,
auto_attached_agent_view_user_block_ids: Vec::new(),
}
}
/// 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 `true` if the next AI query has any context that should force the input to be
/// locked in AI mode (skipping NLD): a pending image or file attachment.
pub fn has_locking_attachment(&self) -> bool {
!self.pending_attachments.is_empty()
}
/// 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,
current_working_directory_location: Option<&LocalOrRemotePath>,
) -> Vec<AIAgentContext> {
// `pwd` is the shell-reported path used for directory context and local indexing.
// The location is passed separately because it preserves remote host identity for rules.
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 = current_working_directory_location
.and_then(|pwd| ProjectContextModel::as_ref(app).find_applicable_rules(pwd));
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,
});
}
// Include repository info from the origin remote URL if available.
if let Some(repo_context) = self.repository_context(app) {
context.push(repo_context);
}
if let Some(pull_request_context) = self.pull_request_context(app) {
context.push(pull_request_context);
}
// Always include project rules if available
if let Some(rules) = project_rules {
context.push(AIAgentContext::ProjectRules {
root_path: rules.root_path.display_path(),
active_rules: rules
.active_rules
.into_iter()
.map(|rule| {
let line_count = rule.content.lines().count();
FileContext {
file_name: rule.path.display_path(),
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()));
}
}
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,
});
}
fn apply_block_metadata_directory_context(
&mut self,
block_metadata: &BlockMetadata,
sessions: &ModelHandle<Sessions>,
ctx: &mut ModelContext<Self>,
) {
let pwd = block_metadata
.current_working_directory()
.map(|s| PathBuf::from(s.to_owned()));
if let Some(session_id) = block_metadata.session_id() {
if let Some(active_session) = sessions.as_ref(ctx).get(session_id) {
self.update_directory_context(
pwd.map(|p| p.to_string_lossy().to_string()),
active_session.home_dir().map(|sq| sq.to_owned()),
ctx,
);
}
}
}
/// 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
}
/// 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.conversation_selection.update(ctx, |selection, ctx| {
selection.select_existing_conversation(conversation_id, origin, ctx);
});
}
/// 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.conversation_selection.update(ctx, |selection, ctx| {
selection.select_new_conversation(origin, ctx);
});
}
/// Starts and selects a new conversation, entering Agent View when this is a GUI selection.
pub(crate) fn try_start_new_conversation(
&mut self,
origin: AgentViewEntryOrigin,
ctx: &mut ModelContext<Self>,
) -> Result<AIConversationId, EnterAgentViewError> {
self.conversation_selection.update(ctx, |selection, ctx| {
selection.try_start_new_conversation(origin, ctx)
})
}
/// 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> {
self.conversation_selection
.as_ref(ctx)
.selected_conversation_id(ctx)
}
pub fn selected_conversation<'a>(&self, ctx: &'a AppContext) -> Option<&'a AIConversation> {
self.conversation_selection
.as_ref(ctx)
.selected_conversation(ctx)
}
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 {
self.conversation_selection
.as_ref(ctx)
.pending_query_autoexecute_override(ctx)
}
pub fn toggle_pending_query_autoexecute(&mut self, ctx: &mut ModelContext<Self>) {
self.conversation_selection.update(ctx, |selection, ctx| {
selection.toggle_pending_query_autoexecute(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, ctx: &AppContext) -> bool {
self.conversation_selection
.as_ref(ctx)
.selected_conversation_id(ctx)
.is_some()
}
/// 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,
});
}
}
pub fn set_github_repo_model(&mut self, handle: Option<WeakModelHandle<GitHubRepoModel>>) {
self.github_repo_model = handle;
}
/// Builds an `AIAgentContext::Repository` from cached git remote metadata, if available.
fn repository_context(&self, app: &AppContext) -> Option<AIAgentContext> {
let handle = self.github_repo_model.as_ref()?.upgrade(app)?;
let repository_info = handle.as_ref(app).repository_info(app)?;
Some(Self::repository_context_from_repository_info(
repository_info,
))
}
fn repository_context_from_repository_info(repository_info: &RepositoryInfo) -> AIAgentContext {
AIAgentContext::Repository {
name: repository_info.name.clone(),
owner: repository_info.owner.clone(),
}
}
fn pull_request_context(&self, app: &AppContext) -> Option<AIAgentContext> {
let handle = self.github_repo_model.as_ref()?.upgrade(app)?;
let pr_info = handle.as_ref(app).pr_info(app)?;
Self::pull_request_context_from_pr_info(pr_info)
}
fn pull_request_context_from_pr_info(pr_info: &PrInfo) -> Option<AIAgentContext> {
Some(AIAgentContext::PullRequest {
number: i32::try_from(pr_info.number).ok()?,
state: pr_info.state.clone(),
draft: pr_info.draft,
base_branch: pr_info.base_branch.clone(),
})
}
/// 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();
}
/// Drains all pending attachments, returning them, and emits the same update event as
/// [`Self::clear_pending_attachments`] so the input's attachment chips disappear. Used to
/// move staged attachments onto a queued prompt row at enqueue time.
pub fn take_pending_attachments(
&mut self,
ctx: &mut ModelContext<Self>,
) -> Vec<PendingAttachment> {
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,
});
}
std::mem::take(&mut self.pending_attachments)
}
}
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,
}
impl Entity for BlocklistAIContextModel {
type Event = BlocklistAIContextEvent;
}
#[cfg(test)]
#[path = "context_model_tests.rs"]
mod tests;