994 lines
38 KiB
Rust
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;
|
|
}
|