first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+152 -45
View File
@@ -15,62 +15,51 @@
mod execute;
mod preprocess;
use crate::ai::agent::conversation::ConversationStatus;
use crate::ai::agent::{
AIAgentActionResultType, AIAgentActionType, AIAgentExchange, CancellationReason,
CreateDocumentsResult, EditDocumentsResult, RequestCommandOutputResult,
};
use crate::ai::{
agent::AIAgentInput,
blocklist::action_model::execute::suggest_new_conversation::SuggestNewConversationExecutor,
};
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::PathBuf;
use std::sync::Arc;
use chrono::Local;
pub(crate) use execute::apply_edits;
pub(crate) use execute::coerce_integer_args;
pub(crate) use execute::FileReadResult;
pub(crate) use execute::MalformedFinalLineProxyEvent;
pub(crate) use execute::{
apply_edits, coerce_integer_args, FileReadResult, MalformedFinalLineProxyEvent,
};
#[cfg(test)]
pub(crate) use execute::{compose_run_agents_child_prompt, run_agents_to_start_agent_mode};
pub use execute::{
read_local_file_context, EditAcceptAndContinueClickedEvent, EditAcceptClickedEvent,
EditResolvedEvent, EditStats, NewConversationDecision, PromptSuggestionExecutor,
ReadFileContextResult, RequestFileEditsExecutor, RequestFileEditsFormatKind,
RequestFileEditsTelemetryEvent, ShellCommandExecutor, ShellCommandExecutorEvent,
StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest,
RequestFileEditsTelemetryEvent, RunAgentsExecutor, RunAgentsExecutorEvent,
RunAgentsSpawningSnapshot, ShellCommandExecutor, ShellCommandExecutorEvent, StartAgentExecutor,
StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId,
};
use futures::future::{join_all, BoxFuture};
use preprocess::{PendingPreprocessedActions, PreprocessId};
use std::{
collections::{HashMap, HashSet, VecDeque},
path::PathBuf,
sync::Arc,
};
use crate::ai::agent::conversation::AIConversationId;
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use itertools::Itertools;
use parking_lot::FairMutex;
use preprocess::{PendingPreprocessedActions, PreprocessId};
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use crate::{
ai::{
agent::{AIAgentAction, AIAgentActionId, AIAgentActionResult},
get_relevant_files::controller::GetRelevantFilesController,
},
terminal::{
model::session::active_session::ActiveSession, model_events::ModelEventDispatcher,
TerminalModel,
},
};
use self::execute::ask_user_question::AskUserQuestionExecutor;
use self::execute::search_codebase::SearchCodebaseExecutor;
use self::execute::{
ask_user_question::AskUserQuestionExecutor, search_codebase::SearchCodebaseExecutor,
BlocklistAIActionExecutor, BlocklistAIActionExecutorEvent, NotExecutedReason,
RunningActionPhase, TryExecuteResult,
};
use super::BlocklistAIHistoryModel;
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
use crate::ai::agent::{
AIAgentAction, AIAgentActionId, AIAgentActionResult, AIAgentActionResultType,
AIAgentActionType, AIAgentActionTypeDiscriminants, AIAgentExchange, AIAgentInput,
CancellationOutcome, CancellationReason, CreateDocumentsResult, EditDocumentsResult,
RequestCommandOutputResult,
};
use crate::ai::ai_document_view::DEFAULT_PLANNING_DOCUMENT_TITLE;
use crate::ai::blocklist::action_model::execute::suggest_new_conversation::SuggestNewConversationExecutor;
use crate::ai::document::ai_document_model::AIDocumentModel;
use crate::ai::get_relevant_files::controller::GetRelevantFilesController;
use crate::terminal::model::session::active_session::ActiveSession;
use crate::terminal::model_events::ModelEventDispatcher;
use crate::terminal::TerminalModel;
use crate::{send_telemetry_from_ctx, TelemetryEvent};
/// The status of an action from an AI output.
@@ -274,7 +263,7 @@ impl BlocklistAIActionModel {
ctx,
)
});
ctx.subscribe_to_model(&executor, move |me, event, ctx| match event {
ctx.subscribe_to_model(&executor, move |me, _, event, ctx| match event {
BlocklistAIActionExecutorEvent::ExecutingAction { action_id } => {
ctx.emit(BlocklistAIActionEvent::ExecutingAction(action_id.clone()));
}
@@ -400,6 +389,10 @@ impl BlocklistAIActionModel {
self.executor.as_ref(app).start_agent_executor().clone()
}
pub fn run_agents_executor(&self, app: &AppContext) -> ModelHandle<RunAgentsExecutor> {
self.executor.as_ref(app).run_agents_executor().clone()
}
pub fn ask_user_question_executor(
&self,
app: &AppContext,
@@ -706,6 +699,71 @@ impl BlocklistAIActionModel {
}
}
/// Dispatches a `RunAgents` action with the user-edited request
/// from the confirmation card.
pub fn execute_run_agents(
&mut self,
action_id: &AIAgentActionId,
request: ai::agent::action::RunAgentsRequest,
ctx: &mut ModelContext<Self>,
) {
let mut found = None;
for (conv_id, queue) in self.pending_actions.iter_mut() {
if let Some(action) = queue.iter_mut().find(|action| &action.id == action_id) {
found = Some((*conv_id, action));
break;
}
}
let Some((conversation_id, action)) = found else {
log::warn!(
"BlocklistAIActionModel::execute_run_agents: no pending action for {action_id:?}"
);
return;
};
if !matches!(action.action, AIAgentActionType::RunAgents(_)) {
log::warn!(
"BlocklistAIActionModel::execute_run_agents: pending action {action_id:?} is not RunAgents"
);
return;
}
action.action = AIAgentActionType::RunAgents(request);
self.execute_action(action_id, conversation_id, ctx);
}
/// Removes a pending `RunAgents` action and records a `Denied`
/// result. Used when the orchestration config is disapproved at
/// the time the action becomes blocked on user confirmation.
pub fn deny_run_agents(
&mut self,
action_id: &AIAgentActionId,
reason: String,
ctx: &mut ModelContext<Self>,
) {
let mut found: Option<(AIConversationId, AIAgentAction)> = None;
for (conv_id, queue) in self.pending_actions.iter_mut() {
if let Some(idx) = queue.iter().position(|a| &a.id == action_id) {
if let Some(action) = queue.remove(idx) {
found = Some((*conv_id, action));
}
break;
}
}
let Some((conversation_id, action)) = found else {
log::warn!(
"BlocklistAIActionModel::deny_run_agents: no pending action for {action_id:?}"
);
return;
};
let result = Arc::new(AIAgentActionResult {
id: action.id,
task_id: action.task_id,
result: AIAgentActionResultType::RunAgents(
ai::agent::action_result::RunAgentsResult::Denied { reason },
),
});
self.handle_action_result(conversation_id, result, None, ctx);
}
/// Attempts to execute the next pending action for the active conversation.
pub fn execute_next_action_for_user(
&mut self,
@@ -839,18 +897,25 @@ impl BlocklistAIActionModel {
let action_id = action.id.clone();
let phase = self.action_phase_for_action(&action, ctx);
// WaitForEvents owns its own status transition; skip the default
// in-progress update.
let is_wait_for_events = matches!(action.action, AIAgentActionType::WaitForEvents { .. });
let execute_result = self.executor.update(ctx, |executor, ctx| {
executor.try_to_execute_action(action, conversation_id, is_user_initiated, ctx)
});
match execute_result {
TryExecuteResult::ExecutedAsync => {
self.update_conversation_in_progress_status(conversation_id, ctx);
if !is_wait_for_events {
self.update_conversation_in_progress_status(conversation_id, ctx);
}
self.add_running_action(conversation_id, action_id, phase);
Some(StartedAction::Async { phase })
}
TryExecuteResult::ExecutedSync => {
self.update_conversation_in_progress_status(conversation_id, ctx);
if !is_wait_for_events {
self.update_conversation_in_progress_status(conversation_id, ctx);
}
Some(StartedAction::Sync)
}
TryExecuteResult::NotExecuted { reason, action } => {
@@ -1033,6 +1098,27 @@ impl BlocklistAIActionModel {
}
}
/// Cancels any in-flight WaitForEvents action for the given conversation.
pub fn cancel_wait_for_events_for_conversation(
&mut self,
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
let action_id = self.executor.update(ctx, |executor, _| {
executor.find_running_wait_for_events(conversation_id)
});
if let Some(action_id) = action_id {
self.cancel_action_with_id(
conversation_id,
&action_id,
CancellationReason::FollowUpSubmitted {
is_for_same_conversation: true,
},
ctx,
);
}
}
pub(super) fn cancel_all_pending_actions(
&mut self,
conversation_id: AIConversationId,
@@ -1047,6 +1133,13 @@ impl BlocklistAIActionModel {
return;
};
for action in actions_to_cancel.drain(..).collect_vec() {
log::info!(
"Canceling pending action of type {:?} conversation_id={conversation_id:?} action_id={:?}, reason={:?}, backtrace=\n{}",
AIAgentActionTypeDiscriminants::from(&action.action),
action.id,
reason,
std::backtrace::Backtrace::force_capture()
);
self.cancel_pending_action(conversation_id, action, reason, ctx);
}
}
@@ -1090,9 +1183,14 @@ impl BlocklistAIActionModel {
pending_action.action,
AIAgentActionType::RequestComputerUse(_)
) {
let server_conversation_id = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.and_then(|c| c.server_conversation_token())
.map(|t| t.as_str().to_string());
send_telemetry_from_ctx!(
TelemetryEvent::ComputerUseCancelled {
conversation_id,
client_conversation_id: conversation_id,
server_conversation_id,
ambient_agent_task_id: self.ambient_agent_task_id,
},
ctx
@@ -1164,7 +1262,7 @@ impl BlocklistAIActionModel {
}
let Some(conversation_id) = found_conversation_id else {
debug_assert!(false, "Expected action to be requested command.");
log::warn!("Ignoring acceptance for non-pending requested command: {action_id:?}");
return;
};
@@ -1242,8 +1340,17 @@ impl BlocklistAIActionModel {
.get(&conversation_id)
.is_none_or(|actions| actions.is_empty())
{
if !cancellation_reason.is_some_and(|r| r.is_follow_up_for_same_conversation()) {
// Only a `Cancelled` outcome stamps a status here. The other outcomes are
// owned elsewhere: `KeepInProgress` / `Succeeded` and `FinalizedExternally`
// are finalized by the controller or a dedicated path, and a normal
// completion (no cancellation reason) is resolved by the controller's
// follow-up handling. Stamping here for any of those would clobber the real
// status and message.
if cancellation_reason
.is_some_and(|r| matches!(r.conversation_outcome(), CancellationOutcome::Cancelled))
{
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
// Treat action result as authoritative for determining status.
let status = if self
.finished_action_results
.get(&conversation_id)
+139 -47
View File
@@ -12,6 +12,7 @@ pub(super) mod read_mcp_resource;
pub(super) mod read_skill;
pub(super) mod request_computer_use;
pub(super) mod request_file_edits;
pub(super) mod run_agents;
pub(super) mod search_codebase;
pub(super) mod send_message;
pub(super) mod shell_command;
@@ -20,6 +21,12 @@ pub(super) mod suggest_new_conversation;
pub(super) mod suggest_prompt;
pub(super) mod upload_artifact;
pub(super) mod use_computer;
pub(super) mod wait_for_events;
use std::any::Any;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use ai::agent::action_result::{InsertReviewCommentsResult, RequestCommandOutputResult};
pub use ask_user_question::AskUserQuestionExecutor;
@@ -29,77 +36,74 @@ use create_documents::CreateDocumentsExecutor;
use edit_documents::EditDocumentsExecutor;
use fetch_conversation::FetchConversationExecutor;
use file_glob::FileGlobExecutor;
use futures::{future::BoxFuture, FutureExt};
use galaxy_core::{execution_mode::AppExecutionMode, features::FeatureFlag};
use futures::future::BoxFuture;
#[cfg(feature = "local_fs")]
use futures::AsyncReadExt;
use futures::FutureExt;
use grep::GrepExecutor;
use notebooks::NotebookExecutor;
#[cfg(feature = "local_fs")]
use mime_guess::from_path;
use parking_lot::FairMutex;
use read_documents::ReadDocumentsExecutor;
pub(super) use read_files::ReadFilesExecutor;
use read_mcp_resource::ReadMCPResourceExecutor;
use read_skill::ReadSkillExecutor;
use request_computer_use::RequestComputerUseExecutor;
pub(crate) use request_file_edits::apply_edits;
pub(crate) use request_file_edits::FileReadResult;
pub(crate) use request_file_edits::MalformedFinalLineProxyEvent;
pub(crate) use request_file_edits::{apply_edits, FileReadResult, MalformedFinalLineProxyEvent};
pub use request_file_edits::{
EditAcceptAndContinueClickedEvent, EditAcceptClickedEvent, EditResolvedEvent, EditStats,
RequestFileEditsExecutor, RequestFileEditsFormatKind, RequestFileEditsTelemetryEvent,
};
#[cfg(test)]
pub use run_agents::{compose_run_agents_child_prompt, run_agents_to_start_agent_mode};
pub use run_agents::{RunAgentsExecutor, RunAgentsExecutorEvent, RunAgentsSpawningSnapshot};
pub use send_message::SendMessageToAgentExecutor;
use serde::{Deserialize, Serialize};
pub use shell_command::{ShellCommandExecutor, ShellCommandExecutorEvent};
pub use start_agent::{StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest};
pub use start_agent::{
StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId,
};
pub use suggest_new_conversation::NewConversationDecision;
use suggest_new_conversation::SuggestNewConversationExecutor;
pub use suggest_prompt::PromptSuggestionExecutor;
use upload_artifact::UploadArtifactExecutor;
use use_computer::UseComputerExecutor;
#[cfg(feature = "local_fs")]
use crate::util::openable_file_type::is_binary_file;
#[cfg(feature = "local_fs")]
use futures::AsyncReadExt;
use wait_for_events::WaitForEventsExecutor;
use galaxy_core::execution_mode::AppExecutionMode;
use galaxy_core::features::FeatureFlag;
#[cfg(feature = "local_fs")]
use galaxy_files::{FileModel, TextFileReadResult};
#[cfg(feature = "local_fs")]
use galaxy_util::file::FileLoadError;
#[cfg(feature = "local_fs")]
use galaxy_util::file_type::is_buffer_binary;
use galaxyui::{
r#async::{Spawnable, SpawnableOutput},
AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity,
};
use std::{any::Any, path::PathBuf, pin::Pin, sync::Arc};
use galaxyui::r#async::{Spawnable, SpawnableOutput};
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use self::search_codebase::SearchCodebaseExecutor;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{
AIAgentAction, AIAgentActionId, AIAgentActionResult, AIAgentActionResultType,
AIAgentActionType, AIAgentActionTypeDiscriminants, CancellationReason, FileContext,
FileLocations, ServerOutputId,
};
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::get_relevant_files::controller::GetRelevantFilesController;
#[cfg(feature = "local_fs")]
use crate::ai::{agent::AnyFileContent, paths::host_native_absolute_path};
use crate::terminal::model::session::active_session::ActiveSession;
use crate::terminal::model::session::command_executor::shell_quote_arg;
use crate::terminal::model::session::{ExecuteCommandOptions, Session};
use crate::terminal::model_events::ModelEventDispatcher;
use crate::terminal::shell::ShellType;
use crate::terminal::{ShellLaunchData, TerminalModel};
#[cfg(feature = "local_fs")]
use crate::util::image::{
is_supported_image_mime_type, process_image_for_agent, ProcessImageResult,
};
#[cfg(feature = "local_fs")]
use mime_guess::from_path;
use self::search_codebase::SearchCodebaseExecutor;
#[cfg(feature = "local_fs")]
use crate::ai::{agent::AnyFileContent, paths::host_native_absolute_path};
use crate::{
ai::{
agent::{
conversation::AIConversationId, AIAgentAction, AIAgentActionId, AIAgentActionResult,
AIAgentActionResultType, AIAgentActionType, CancellationReason, FileContext,
FileLocations, ServerOutputId,
},
ambient_agents::AmbientAgentTaskId,
get_relevant_files::controller::GetRelevantFilesController,
},
terminal::{
model::session::{active_session::ActiveSession, ExecuteCommandOptions, Session},
model_events::ModelEventDispatcher,
shell::ShellType,
ShellLaunchData, TerminalModel,
},
BlocklistAIHistoryModel,
};
use crate::util::openable_file_type::is_binary_file;
use crate::BlocklistAIHistoryModel;
/// Types of actions that can be executed in parallel.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -261,8 +265,10 @@ pub struct BlocklistAIActionExecutor {
read_skill_executor: ModelHandle<ReadSkillExecutor>,
fetch_conversation_executor: ModelHandle<FetchConversationExecutor>,
start_agent_executor: ModelHandle<StartAgentExecutor>,
run_agents_executor: ModelHandle<RunAgentsExecutor>,
send_message_executor: ModelHandle<SendMessageToAgentExecutor>,
ask_user_question_executor: ModelHandle<AskUserQuestionExecutor>,
wait_for_events_executor: ModelHandle<WaitForEventsExecutor>,
/// The actions currently executing asynchronously, keyed by action ID.
/// We track them per action rather than as a single slot so multiple actions from the same
/// parallel phase can complete independently.
@@ -324,12 +330,16 @@ impl BlocklistAIActionExecutor {
let use_computer_executor = ctx.add_model(|_| UseComputerExecutor::new());
let request_computer_use_executor =
ctx.add_model(|_| RequestComputerUseExecutor::new(terminal_view_id));
let read_skill_executor = ctx.add_model(|_| ReadSkillExecutor::new());
let read_skill_executor = ctx.add_model(|_| ReadSkillExecutor::new(active_session.clone()));
let fetch_conversation_executor = ctx.add_model(|_| FetchConversationExecutor::new());
let start_agent_executor = ctx.add_model(StartAgentExecutor::new);
let run_agents_executor = ctx
.add_model(|_| RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id));
let send_message_executor = ctx.add_model(|_| SendMessageToAgentExecutor::new());
let ask_user_question_executor =
ctx.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
let wait_for_events_executor =
ctx.add_model(|ctx| WaitForEventsExecutor::new(terminal_view_id, ctx));
Self {
shell_command_executor,
read_files_executor,
@@ -353,8 +363,10 @@ impl BlocklistAIActionExecutor {
read_skill_executor,
fetch_conversation_executor,
start_agent_executor,
run_agents_executor,
send_message_executor,
ask_user_question_executor,
wait_for_events_executor,
}
}
@@ -364,6 +376,29 @@ impl BlocklistAIActionExecutor {
.map(|running| &running.action)
}
/// Returns the action_id of any running WaitForEvents action for the
/// given conversation. There is at most one (wait_for_events is
/// documented as exclusive within a turn).
pub(super) fn find_running_wait_for_events(
&self,
conversation_id: AIConversationId,
) -> Option<AIAgentActionId> {
self.async_executing_actions
.iter()
.find_map(|(action_id, running)| {
if running.conversation_id == conversation_id
&& matches!(
running.action.action,
AIAgentActionType::WaitForEvents { .. }
)
{
Some(action_id.clone())
} else {
None
}
})
}
pub fn shell_command_executor(&self) -> &ModelHandle<ShellCommandExecutor> {
&self.shell_command_executor
}
@@ -390,6 +425,10 @@ impl BlocklistAIActionExecutor {
&self.start_agent_executor
}
pub fn run_agents_executor(&self) -> &ModelHandle<RunAgentsExecutor> {
&self.run_agents_executor
}
pub fn action_phase(&self, action: &AIAgentAction, ctx: &AppContext) -> RunningActionPhase {
match &action.action {
AIAgentActionType::ReadFiles(..)
@@ -426,6 +465,9 @@ impl BlocklistAIActionExecutor {
id: Option<AmbientAgentTaskId>,
ctx: &mut ModelContext<Self>,
) {
self.send_message_executor.update(ctx, |executor, _| {
executor.set_ambient_agent_task_id(id);
});
self.request_computer_use_executor
.update(ctx, |executor, _| {
executor.set_ambient_agent_task_id(id);
@@ -540,6 +582,12 @@ impl BlocklistAIActionExecutor {
AIAgentActionType::AskUserQuestion { .. } => self
.ask_user_question_executor
.update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)),
AIAgentActionType::RunAgents(_) => self
.run_agents_executor
.update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)),
AIAgentActionType::WaitForEvents { .. } => self
.wait_for_events_executor
.update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)),
}
}
@@ -768,6 +816,14 @@ impl BlocklistAIActionExecutor {
.ask_user_question_executor
.update(ctx, |executor, ctx| executor.execute(input, ctx))
.into(),
AIAgentActionType::RunAgents(_) => self
.run_agents_executor
.update(ctx, |executor, ctx| executor.execute(input, ctx))
.into(),
AIAgentActionType::WaitForEvents { .. } => self
.wait_for_events_executor
.update(ctx, |executor, ctx| executor.execute(input, ctx))
.into(),
};
let action_id = action_clone.id.clone();
@@ -885,6 +941,11 @@ impl BlocklistAIActionExecutor {
return;
}
if let Some(running) = self.async_executing_actions.remove(action_id) {
let action_kind = AIAgentActionTypeDiscriminants::from(&running.action.action);
log::info!(
"Canceling running async action of type {action_kind:?} action_id={action_id:?}, reason={reason:?}, backtrace=\n{}",
std::backtrace::Backtrace::force_capture()
);
if running.is_shell_command_action() {
self.shell_command_executor.update(ctx, |executor, ctx| {
executor.cancel_execution(&running.action.id, ctx);
@@ -893,6 +954,19 @@ impl BlocklistAIActionExecutor {
self.search_codebase_executor.update(ctx, |executor, ctx| {
executor.cancel_execution(&running.action.id, ctx);
});
} else if matches!(running.action.action, AIAgentActionType::RunAgents(..)) {
self.run_agents_executor.update(ctx, |executor, ctx| {
executor.cancel_execution(&running.action.id, ctx);
});
} else if let AIAgentActionType::WaitForEvents { tool_call_id, .. } =
&running.action.action
{
// Drop the executor's pending entry; the shared cancel
// path emits FinishedAction(Cancelled).
let tool_call_id = tool_call_id.clone();
self.wait_for_events_executor.update(ctx, |executor, _| {
executor.cancel_execution(&tool_call_id);
});
}
ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction {
result: Arc::new(AIAgentActionResult {
@@ -1016,6 +1090,12 @@ impl BlocklistAIActionExecutor {
AIAgentActionType::AskUserQuestion { .. } => self
.ask_user_question_executor
.update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)),
AIAgentActionType::RunAgents(_) => self
.run_agents_executor
.update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)),
AIAgentActionType::WaitForEvents { .. } => self
.wait_for_events_executor
.update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)),
}
}
@@ -1300,14 +1380,26 @@ async fn read_binary_file_context(
})
}
fn build_is_file_path_command(path: &str, shell_type: ShellType) -> String {
let escaped_path = shell_quote_arg(path, shell_type);
if shell_type == ShellType::PowerShell {
format!("if (Test-Path -PathType Leaf {escaped_path}) {{ exit 0 }} else {{ exit 1 }}")
} else {
format!("test -f {escaped_path}")
}
}
fn build_is_git_repository_command(absolute_path: &str, shell_type: ShellType) -> String {
format!(
"git -C {} rev-parse",
shell_quote_arg(absolute_path, shell_type)
)
}
/// Returns true if the given path is a regular file on the session's filesystem.
/// Runs a shell command on the session so it works for both local and remote sessions.
async fn is_file_path(path: &str, session: &Session) -> bool {
let command = if session.shell().shell_type() == ShellType::PowerShell {
format!("if (Test-Path -PathType Leaf \"{path}\") {{ exit 0 }} else {{ exit 1 }}")
} else {
format!("test -f \"{path}\"")
};
let command = build_is_file_path_command(path, session.shell().shell_type());
session
.execute_command(&command, None, None, ExecuteCommandOptions::default())
.await
@@ -1317,7 +1409,7 @@ async fn is_file_path(path: &str, session: &Session) -> bool {
/// Returns true if git is installed and the given path is in a git repository.
async fn is_git_repository(absolute_path: &str, session: &Session) -> anyhow::Result<bool> {
let git_command = format!("git -C \"{absolute_path}\" rev-parse");
let git_command = build_is_git_repository_command(absolute_path, session.shell().shell_type());
let command_output = session
.execute_command(
git_command.as_str(),
@@ -1,12 +1,11 @@
use crate::ai::agent::{AIAgentActionResultType, AIAgentActionType};
use crate::ai::blocklist::orchestration_events::OrchestrationEventService;
use crate::ai::blocklist::BlocklistAIHistoryModel;
use crate::ai::blocklist::BlocklistAIPermissions;
use ai::agent::action_result::{AskUserQuestionAnswerItem, AskUserQuestionResult};
use futures::{future::BoxFuture, FutureExt};
use futures::future::BoxFuture;
use futures::FutureExt;
use galaxyui::{Entity, EntityId, ModelContext, SingletonEntity};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use crate::ai::agent::{AIAgentActionResultType, AIAgentActionType};
use crate::ai::blocklist::BlocklistAIPermissions;
pub enum AskUserQuestionDecision {
Completed(Vec<AskUserQuestionAnswerItem>),
@@ -1,22 +1,24 @@
use ai::agent::action::AskUserQuestionItem;
use ai::agent::action_result::{AskUserQuestionAnswerItem, AskUserQuestionResult};
use warpui::{App, EntityId, ModelHandle};
use super::*;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{AIAgentAction, AIAgentActionId, AIAgentActionResultType};
use crate::ai::blocklist::{BlocklistAIHistoryModel, BlocklistAIPermissions};
use crate::ai::execution_profiles::{
profiles::AIExecutionProfilesModel, AskUserQuestionPermission,
};
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
use crate::ai::execution_profiles::AskUserQuestionPermission;
use crate::ai::mcp::templatable_manager::TemplatableMCPServerManager;
use crate::auth::AuthStateProvider;
use crate::cloud_object::model::persistence::CloudModel;
use crate::network::NetworkStatus;
use crate::server::{cloud_objects::update_manager::UpdateManager, sync_queue::SyncQueue};
use crate::server::cloud_objects::update_manager::UpdateManager;
use crate::server::sync_queue::SyncQueue;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::workspaces::{team_tester::TeamTesterStatus, user_workspaces::UserWorkspaces};
use crate::workspaces::team_tester::TeamTesterStatus;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::LaunchMode;
use ai::agent::action::AskUserQuestionItem;
use ai::agent::action_result::{AskUserQuestionAnswerItem, AskUserQuestionResult};
use galaxyui::{App, EntityId, ModelHandle};
fn build_action(action_id: &str) -> AIAgentAction {
AIAgentAction {
@@ -45,7 +47,7 @@ fn should_autoexecute_returns_false_when_autoapprove_is_enabled_and_profile_alwa
let executor = app.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
let action = build_action("ask-user-question");
let conversation_id = history.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_view_id, true, false, ctx)
history.start_new_conversation(terminal_view_id, true, false, false, ctx)
});
profiles.update(&mut app, |profiles, ctx| {
@@ -128,7 +130,7 @@ fn should_autoexecute_returns_true_when_autoapprove_is_enabled_and_profile_allow
let executor = app.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
let action = build_action("ask-user-question");
let conversation_id = history.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_view_id, true, false, ctx)
history.start_new_conversation(terminal_view_id, true, false, false, ctx)
});
let result = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
@@ -150,7 +152,7 @@ fn execute_returns_sync_skipped_question_ids_when_autoapprove_is_enabled() {
let executor = app.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
let action = build_action("ask-user-question");
let conversation_id = history.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_view_id, true, false, ctx)
history.start_new_conversation(terminal_view_id, true, false, false, ctx)
});
let execution = executor.update(&mut app, |executor, ctx| {
@@ -261,7 +263,7 @@ fn should_autoexecute_uses_active_terminal_profile_permission() {
let executor = app.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
let action = build_action("ask-user-question");
let conversation_id = history.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_view_id, false, false, ctx)
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
profiles.update(&mut app, |profiles, ctx| {
@@ -1,10 +1,15 @@
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use crate::terminal::model::session::active_session::ActiveSession;
use futures::{future::BoxFuture, FutureExt};
use futures::future::BoxFuture;
use futures::FutureExt;
#[cfg(not(target_family = "wasm"))]
use itertools::Itertools;
#[cfg(not(target_family = "wasm"))]
use galaxyui::SingletonEntity;
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle};
#[cfg(not(target_family = "wasm"))]
use super::get_server_output_id;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use crate::terminal::model::session::active_session::ActiveSession;
#[cfg(not(target_family = "wasm"))]
use crate::{
ai::{
@@ -14,10 +19,6 @@ use crate::{
},
send_telemetry_from_app_ctx, TelemetryEvent,
};
#[cfg(not(target_family = "wasm"))]
use galaxyui::SingletonEntity;
#[cfg(not(target_family = "wasm"))]
use itertools::Itertools;
pub struct CallMCPToolExecutor {
_active_session: ModelHandle<ActiveSession>,
@@ -158,10 +159,10 @@ impl CallMCPToolExecutor {
ActionExecution::new_async(
async move {
reconnecting_peer
.call_tool(rmcp::model::CallToolRequestParam {
name: name_owned_inner.into(),
arguments: Some(arguments),
})
.call_tool(
rmcp::model::CallToolRequestParams::new(name_owned_inner)
.with_arguments(arguments),
)
.await
},
move |res, ctx| handle_call_tool_result(res, server_output_id, name_clone, ctx),
@@ -189,30 +190,116 @@ impl Entity for CallMCPToolExecutor {
/// MCP tool args round-trip through `google.protobuf.Struct` on the wire, whose
/// `NumberValue` stores everything as `f64`. Without this fix, serde_json emits
/// whole-number floats as `"5.0"`, which strict MCP servers reject for integer fields.
///
/// Walks the schema recursively so integer fields nested inside objects, arrays,
/// or `oneOf`/`anyOf`/`allOf` branches are all coerced. `$ref` is not resolved
/// (the root schema would be required) and is skipped.
pub(crate) fn coerce_integer_args(
args: &mut serde_json::Map<String, serde_json::Value>,
input_schema: &serde_json::Map<String, serde_json::Value>,
) {
let Some(properties) = input_schema.get("properties").and_then(|p| p.as_object()) else {
return;
};
// Delegate to the recursive walker by wrapping `args` in a borrowed `Value`.
// This keeps the root-level traversal consistent with nested levels, so
// top-level `oneOf`/`anyOf`/`allOf` and `additionalProperties` are honored
// the same way they are deeper in the schema.
let mut wrapped = serde_json::Value::Object(std::mem::take(args));
let schema_value = serde_json::Value::Object(input_schema.clone());
coerce_value_against_schema(&mut wrapped, &schema_value);
if let serde_json::Value::Object(restored) = wrapped {
*args = restored;
}
}
for (key, prop_def) in properties {
let is_integer = prop_def.get("type").and_then(|t| t.as_str()) == Some("integer");
if !is_integer {
continue;
/// Returns true if the schema's `type` declares `"integer"`, including the
/// nullable form `"type": ["integer", "null"]`.
fn schema_declares_integer(schema: &serde_json::Value) -> bool {
match schema.get("type") {
Some(serde_json::Value::String(s)) => s == "integer",
Some(serde_json::Value::Array(types)) => {
types.iter().any(|t| t.as_str() == Some("integer"))
}
let Some(serde_json::Value::Number(n)) = args.get_mut(key) else {
continue;
};
let Some(f) = n.as_f64() else { continue };
if f.fract() != 0.0 {
continue;
_ => false,
}
}
/// In-place coerces a whole-number `f64` `Number` to `i64`.
fn coerce_number_to_int(n: &mut serde_json::Number) {
let Some(f) = n.as_f64() else { return };
if n.is_i64() || n.is_u64() {
return;
}
if f.fract() != 0.0 {
return;
}
if let Ok(i) = i64::try_from(f as i128) {
*n = serde_json::Number::from(i);
}
}
/// Recursively walks `value` against `schema`, coercing whole-number f64s to
/// i64 wherever the schema declares `"type": "integer"`. Safe to call against
/// multiple `oneOf`/`anyOf`/`allOf` branches: coercion is a no-op on values
/// the schema does not match.
fn coerce_value_against_schema(value: &mut serde_json::Value, schema: &serde_json::Value) {
if schema_declares_integer(schema) {
if let serde_json::Value::Number(n) = value {
coerce_number_to_int(n);
}
if let Ok(i) = i64::try_from(f as i128) {
*n = serde_json::Number::from(i);
}
// Visit every combinator key independently — a schema may declare more than
// one of {oneOf, anyOf, allOf} at the same level, and we need to walk every
// branch in every present combinator. Coercion is monotonic, so visiting
// branches whose constraints don't match `value` is a safe no-op.
for combinator in ["oneOf", "anyOf", "allOf"] {
if let Some(branches) = schema.get(combinator).and_then(|b| b.as_array()) {
for branch in branches {
coerce_value_against_schema(value, branch);
}
}
}
match value {
serde_json::Value::Object(map) => {
let properties = schema.get("properties").and_then(|p| p.as_object());
let additional = schema.get("additionalProperties");
// Per-key handling for the outer schema only. Per-branch handling
// for keys covered by `oneOf`/`anyOf`/`allOf` is reached through
// the top-level combinator recursion above: each branch is invoked
// with the full `value`, so the branch's own object handling runs
// and walks its `properties[k]`. Coercion is monotonic so the two
// passes stack safely.
for (k, v) in map.iter_mut() {
if let Some(prop_schema) = properties.and_then(|p| p.get(k)) {
coerce_value_against_schema(v, prop_schema);
} else if let Some(extra_schema) = additional {
if extra_schema.is_object() {
coerce_value_against_schema(v, extra_schema);
}
}
}
}
serde_json::Value::Array(items) => {
if let Some(item_schema) = schema.get("items") {
match item_schema {
// `items` as an object schema: applies to every element.
serde_json::Value::Object(_) => {
for elem in items.iter_mut() {
coerce_value_against_schema(elem, item_schema);
}
}
// `items` as an array (tuple validation): positional schemas.
serde_json::Value::Array(schemas) => {
for (elem, elem_schema) in items.iter_mut().zip(schemas.iter()) {
coerce_value_against_schema(elem, elem_schema);
}
}
_ => {}
}
}
}
_ => {}
}
}
#[cfg(test)]
@@ -1,8 +1,9 @@
//! Unit tests for the `coerce_integer_args` helper.
use super::*;
use serde_json::json;
use super::*;
fn obj(value: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
match value {
serde_json::Value::Object(m) => m,
@@ -46,3 +47,274 @@ fn no_coercion_when_not_typed_as_integer() {
assert_eq!(serde_json::to_string(&args["x"]).unwrap(), "1.0");
}
}
#[test]
fn nested_object_integer_is_coerced() {
let mut args = obj(json!({ "outer": { "inner": 5.0 } }));
let schema = obj(json!({
"properties": {
"outer": {
"type": "object",
"properties": { "inner": { "type": "integer" } }
}
}
}));
coerce_integer_args(&mut args, &schema);
assert_eq!(args["outer"]["inner"].as_i64(), Some(5));
assert_eq!(serde_json::to_string(&args["outer"]["inner"]).unwrap(), "5");
}
#[test]
fn array_items_integer_is_coerced() {
let mut args = obj(json!({ "ids": [1.0, 2.0, 3.5] }));
let schema = obj(json!({
"properties": {
"ids": { "type": "array", "items": { "type": "integer" } }
}
}));
coerce_integer_args(&mut args, &schema);
// Whole-number floats become i64; fractional values are left alone.
assert_eq!(args["ids"][0].as_i64(), Some(1));
assert_eq!(args["ids"][1].as_i64(), Some(2));
assert_eq!(args["ids"][2].as_f64(), Some(3.5));
}
#[test]
fn nullable_integer_type_array_is_coerced() {
let mut args = obj(json!({ "n": 7.0, "m": null }));
let schema = obj(json!({
"properties": {
"n": { "type": ["integer", "null"] },
"m": { "type": ["integer", "null"] }
}
}));
coerce_integer_args(&mut args, &schema);
assert_eq!(args["n"].as_i64(), Some(7));
assert!(args["m"].is_null());
}
#[test]
fn one_of_branch_with_integer_is_coerced() {
// Mirrors the schema shape from issue #10596: a `filters` array whose items
// are a oneOf where one branch has `value: integer` (a millisecond timestamp).
let mut args = obj(json!({
"filters": [
{ "value": ["a", "b"] },
{ "value": 1730419200000.0 }
]
}));
let schema = obj(json!({
"properties": {
"filters": {
"type": "array",
"items": {
"oneOf": [
{ "properties": { "value": { "type": "array", "items": { "type": "string" } } } },
{ "properties": { "value": { "type": "integer" } } }
]
}
}
}
}));
coerce_integer_args(&mut args, &schema);
assert_eq!(args["filters"][1]["value"].as_i64(), Some(1730419200000));
assert_eq!(
serde_json::to_string(&args["filters"][1]["value"]).unwrap(),
"1730419200000"
);
// The string-array branch value is untouched.
assert_eq!(args["filters"][0]["value"][0].as_str(), Some("a"));
}
#[test]
fn any_of_at_property_level_is_coerced() {
let mut args = obj(json!({ "x": 9.0 }));
let schema = obj(json!({
"properties": {
"x": {
"anyOf": [
{ "type": "string" },
{ "type": "integer" }
]
}
}
}));
coerce_integer_args(&mut args, &schema);
assert_eq!(args["x"].as_i64(), Some(9));
}
#[test]
fn all_of_with_integer_branch_is_coerced() {
let mut args = obj(json!({ "x": 4.0 }));
let schema = obj(json!({
"properties": {
"x": {
"allOf": [
{ "type": "integer" },
{ "minimum": 0 }
]
}
}
}));
coerce_integer_args(&mut args, &schema);
assert_eq!(args["x"].as_i64(), Some(4));
}
#[test]
fn additional_properties_schema_is_applied() {
let mut args = obj(json!({ "meta": { "a": 1.0, "b": 2.0 } }));
let schema = obj(json!({
"properties": {
"meta": {
"type": "object",
"additionalProperties": { "type": "integer" }
}
}
}));
coerce_integer_args(&mut args, &schema);
assert_eq!(args["meta"]["a"].as_i64(), Some(1));
assert_eq!(args["meta"]["b"].as_i64(), Some(2));
}
#[test]
fn fractional_value_is_not_coerced_to_int() {
let mut args = obj(json!({ "x": 2.5 }));
let schema = obj(json!({
"properties": { "x": { "type": "integer" } }
}));
coerce_integer_args(&mut args, &schema);
// Schema mismatch (server will reject) but we must not silently truncate.
assert_eq!(args["x"].as_f64(), Some(2.5));
}
#[test]
fn root_level_one_of_branch_with_integer_is_coerced() {
// When the root schema uses a combinator instead of (or alongside)
// `properties`, the entrypoint must walk every branch, not stop at the
// first one.
let mut args = obj(json!({ "x": 6.0 }));
let schema = obj(json!({
"oneOf": [
{ "properties": { "x": { "type": "string" } } },
{ "properties": { "x": { "type": "integer" } } }
]
}));
coerce_integer_args(&mut args, &schema);
assert_eq!(args["x"].as_i64(), Some(6));
}
#[test]
fn root_level_additional_properties_is_applied() {
let mut args = obj(json!({ "anything": 8.0, "more": 9.0 }));
let schema = obj(json!({ "additionalProperties": { "type": "integer" } }));
coerce_integer_args(&mut args, &schema);
assert_eq!(args["anything"].as_i64(), Some(8));
assert_eq!(args["more"].as_i64(), Some(9));
}
#[test]
fn negative_whole_float_is_coerced() {
let mut args = obj(json!({ "x": -42.0 }));
let schema = obj(json!({
"properties": { "x": { "type": "integer" } }
}));
coerce_integer_args(&mut args, &schema);
assert_eq!(args["x"].as_i64(), Some(-42));
assert_eq!(serde_json::to_string(&args["x"]).unwrap(), "-42");
}
#[test]
fn tuple_style_items_coerces_positional_schemas() {
let mut args = obj(json!({ "pair": [1.0, "hi", 2.0] }));
let schema = obj(json!({
"properties": {
"pair": {
"type": "array",
"items": [
{ "type": "integer" },
{ "type": "string" },
{ "type": "integer" }
]
}
}
}));
coerce_integer_args(&mut args, &schema);
assert_eq!(args["pair"][0].as_i64(), Some(1));
assert_eq!(args["pair"][1].as_str(), Some("hi"));
assert_eq!(args["pair"][2].as_i64(), Some(2));
}
#[test]
fn multiple_combinators_at_same_level_are_all_traversed() {
// A schema may declare more than one of {oneOf, anyOf, allOf} at the
// same level, and every combinator must be walked — not just the first
// present key.
let mut args = obj(json!({ "a": 1.0, "b": 2.0, "c": 3.0 }));
let schema = obj(json!({
"oneOf": [{ "properties": { "a": { "type": "integer" } } }],
"anyOf": [{ "properties": { "b": { "type": "integer" } } }],
"allOf": [{ "properties": { "c": { "type": "integer" } } }]
}));
coerce_integer_args(&mut args, &schema);
assert_eq!(args["a"].as_i64(), Some(1));
assert_eq!(args["b"].as_i64(), Some(2));
assert_eq!(args["c"].as_i64(), Some(3));
}
#[test]
fn one_of_with_multiple_branches_all_visited() {
// Every branch under a combinator must be visited, not just the first
// match. The same property name across branches with different types
// should still get the integer branch's coercion when applicable.
let mut args = obj(json!({ "v": 11.0 }));
let schema = obj(json!({
"oneOf": [
{ "properties": { "v": { "type": "string" } } },
{ "properties": { "v": { "type": "number" } } },
{ "properties": { "v": { "type": "integer" } } }
]
}));
coerce_integer_args(&mut args, &schema);
assert_eq!(args["v"].as_i64(), Some(11));
}
#[test]
fn already_integer_value_is_unchanged() {
let mut args = obj(json!({ "x": 5 }));
let schema = obj(json!({
"properties": { "x": { "type": "integer" } }
}));
coerce_integer_args(&mut args, &schema);
assert_eq!(args["x"].as_i64(), Some(5));
assert_eq!(serde_json::to_string(&args["x"]).unwrap(), "5");
}
@@ -1,21 +1,19 @@
use futures::{future::BoxFuture, FutureExt};
use futures::future::BoxFuture;
use futures::FutureExt;
use galaxyui::{Entity, ModelContext, ModelHandle, SingletonEntity};
use crate::{
ai::{
agent::{
conversation::AIConversationId, AIAgentAction, AIAgentActionType,
CreateDocumentsRequest, CreateDocumentsResult, DocumentContext,
},
artifacts::Artifact,
blocklist::BlocklistAIHistoryModel,
document::ai_document_model::{AIDocumentModel, AIDocumentVersion},
},
notebooks::editor::model::FileLinkResolutionContext,
terminal::model::session::active_session::ActiveSession,
};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{
AIAgentAction, AIAgentActionType, CreateDocumentsRequest, CreateDocumentsResult,
DocumentContext,
};
use crate::ai::artifacts::Artifact;
use crate::ai::blocklist::BlocklistAIHistoryModel;
use crate::ai::document::ai_document_model::{AIDocumentModel, AIDocumentVersion};
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
use crate::notebooks::editor::model::FileLinkResolutionContext;
use crate::terminal::model::session::active_session::ActiveSession;
pub struct CreateDocumentsExecutor {
active_session: ModelHandle<ActiveSession>,
@@ -1,18 +1,17 @@
use ai::diff_validation::DiffDelta;
use futures::{future::BoxFuture, FutureExt};
use galaxyui::{Entity, ModelContext, SingletonEntity};
use std::collections::HashMap;
use crate::ai::{
agent::{
AIAgentAction, AIAgentActionType, DocumentContext, EditDocumentsRequest,
EditDocumentsResult,
},
document::ai_document_model::{AIDocumentId, AIDocumentModel, AIDocumentUpdateSource},
};
use crate::notebooks::post_process_notebook;
use ai::diff_validation::DiffDelta;
use futures::future::BoxFuture;
use futures::FutureExt;
use galaxyui::{Entity, ModelContext, SingletonEntity};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use crate::ai::agent::{
AIAgentAction, AIAgentActionType, DocumentContext, EditDocumentsRequest, EditDocumentsResult,
};
use crate::ai::document::ai_document_model::{
AIDocumentId, AIDocumentModel, AIDocumentUpdateSource,
};
pub struct EditDocumentsExecutor;
@@ -66,8 +65,8 @@ impl EditDocumentsExecutor {
// Apply the diff using fuzzy matching logic
let search_replace = ai::diff_validation::SearchAndReplace {
search: post_process_notebook(&diff.search),
replace: post_process_notebook(&diff.replace),
search: diff.search.clone(),
replace: diff.replace.clone(),
};
let content_name = format!("document_{}", diff.document_id);
@@ -1,17 +1,14 @@
use crate::ai::agent::conversation::AIConversation;
use crate::ai::agent::conversation_yaml;
use crate::ai::agent::AIAgentActionResultType;
use crate::ai::blocklist::history_model::CloudConversationData;
use ai::agent::action_result::FetchConversationResult;
use futures::future::BoxFuture;
use futures::FutureExt;
use galaxyui::{Entity, ModelContext, SingletonEntity};
use crate::ai::agent::api::ServerConversationToken;
use crate::ai::agent::AIAgentActionType;
use crate::BlocklistAIHistoryModel;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use crate::ai::agent::api::ServerConversationToken;
use crate::ai::agent::conversation::AIConversation;
use crate::ai::agent::{conversation_yaml, AIAgentActionResultType, AIAgentActionType};
use crate::ai::blocklist::history_model::CloudConversationData;
use crate::BlocklistAIHistoryModel;
pub struct FetchConversationExecutor;
@@ -41,8 +38,9 @@ impl FetchConversationExecutor {
let conversation_id = conversation_id.clone();
let server_token = ServerConversationToken::new(conversation_id.clone());
let history = BlocklistAIHistoryModel::as_ref(ctx);
let load_future = history.load_conversation_by_server_token(&server_token, ctx);
let load_future = BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.load_conversation_by_server_token(&server_token, ctx)
});
ActionExecution::new_async(load_future, move |cloud_conversation, _ctx| {
// TODO(REMOTE-1203): FetchConversation can't materialize non-Oz conversation transcripts yet.
@@ -7,24 +7,21 @@ use futures::FutureExt;
use galaxyui::r#async::FutureExt as AsyncFutureExt;
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use itertools::Itertools;
use galaxy_core::features::FeatureFlag;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{
conversation::AIConversationId, AIAgentAction, AIAgentActionType, FileGlobResult,
FileGlobV2Match, FileGlobV2Result,
AIAgentAction, AIAgentActionResultType, AIAgentActionType, FileGlobResult, FileGlobV2Match,
FileGlobV2Result,
};
use crate::ai::blocklist::BlocklistAIPermissions;
use crate::ai::paths::{host_native_absolute_path, join_paths, shell_native_absolute_path};
use crate::terminal::model::session::ExecuteCommandOptions;
use crate::{
ai::agent::AIAgentActionResultType,
send_telemetry_from_app_ctx,
terminal::{
model::session::active_session::ActiveSession, model::session::Session, shell::ShellType,
ShellLaunchData,
},
TelemetryEvent,
};
use galaxy_core::features::FeatureFlag;
use crate::terminal::model::session::active_session::ActiveSession;
use crate::terminal::model::session::command_executor::shell_quote_arg;
use crate::terminal::model::session::{ExecuteCommandOptions, Session};
use crate::terminal::shell::ShellType;
use crate::terminal::ShellLaunchData;
use crate::{send_telemetry_from_app_ctx, TelemetryEvent};
const FILE_GLOB_TIMEOUT: Duration = Duration::from_secs(10);
@@ -214,6 +211,7 @@ async fn run_file_glob(
let Some(session) = session else {
return Err(anyhow::anyhow!("No session provided to file_glob"));
};
let shell_type = session.shell().shell_type();
let is_in_git_repo = is_git_repository(&absolute_path, session.as_ref())
.await
@@ -228,12 +226,13 @@ async fn run_file_glob(
&absolute_path,
session.as_ref(),
shell_launch_data,
shell_type,
)
.await
} else if session.shell().shell_type() == ShellType::PowerShell {
} else if shell_type == ShellType::PowerShell {
run_powershell_get_childitem_command(&patterns, &absolute_path, session.as_ref()).await
} else {
run_find_command(&patterns, &absolute_path, session.as_ref()).await
run_find_command(&patterns, &absolute_path, session.as_ref(), shell_type).await
}
}
@@ -243,20 +242,14 @@ async fn run_git_ls_files_command(
target_path: &str,
session: &Session,
shell_launch_data: Option<ShellLaunchData>,
shell_type: ShellType,
) -> anyhow::Result<FileGlobV2Result> {
let pattern_args = patterns
.iter()
.flat_map(|pattern| {
[
// Matches on files in the target path.
join_paths(&[target_path, pattern], shell_launch_data.as_ref()),
// Matches on files in any subdirectory of the target path.
join_paths(&[target_path, "*", pattern], shell_launch_data.as_ref()),
]
})
.map(|pattern| format!("'{pattern}'"))
.join(" ");
let command = format!("git ls-files -c -o --exclude-standard -- {pattern_args}");
let command = build_git_ls_files_command(
patterns,
target_path,
shell_launch_data.as_ref(),
shell_type,
);
let command_output = session
.execute_command(
@@ -291,13 +284,9 @@ async fn run_find_command(
patterns: &[String],
target_path: &str,
session: &Session,
shell_type: ShellType,
) -> anyhow::Result<FileGlobV2Result> {
// Build a find command with -name for each pattern
let pattern_args = patterns
.iter()
.map(|pattern| format!(" -name '{pattern}'"))
.join(" -o");
let find_command = format!("find \"{target_path}\" -type f {pattern_args}");
let find_command = build_find_command(patterns, target_path, shell_type);
let command_output = session
.execute_command(
@@ -336,13 +325,7 @@ async fn run_powershell_get_childitem_command(
target_path: &str,
session: &Session,
) -> anyhow::Result<FileGlobV2Result> {
let pattern_args = patterns
.iter()
.map(|pattern| format!("'{pattern}'"))
.join(",");
let command = format!(
"Get-ChildItem -File -Recurse -Include {pattern_args} -Path \"{target_path}\" | ForEach-Object {{ $_.FullName }}"
);
let command = build_powershell_get_childitem_command(patterns, target_path);
let command_output = session
.execute_command(
@@ -367,6 +350,55 @@ async fn run_powershell_get_childitem_command(
}
}
fn build_git_ls_files_command(
patterns: &[String],
target_path: &str,
shell_launch_data: Option<&ShellLaunchData>,
shell_type: ShellType,
) -> String {
let pattern_args = patterns
.iter()
.flat_map(|pattern| {
[
// Matches on files in the target path.
join_paths(&[target_path, pattern], shell_launch_data),
// Matches on files in any subdirectory of the target path.
join_paths(&[target_path, "*", pattern], shell_launch_data),
]
})
// Patterns are model-controlled action input. Quote after joining with
// the target path so metacharacters stay inside the git pathspec.
.map(|pattern| shell_quote_arg(&pattern, shell_type))
.join(" ");
format!("git ls-files -c -o --exclude-standard -- {pattern_args}")
}
fn build_find_command(patterns: &[String], target_path: &str, shell_type: ShellType) -> String {
// Preserve the existing `find` expression while making each model-provided
// pattern a quoted `-name` argument instead of shell syntax.
let pattern_args = patterns
.iter()
.map(|pattern| format!("-name {}", shell_quote_arg(pattern, shell_type)))
.join(" -o ");
format!(
"find {} -type f {pattern_args}",
shell_quote_arg(target_path, shell_type)
)
}
fn build_powershell_get_childitem_command(patterns: &[String], target_path: &str) -> String {
let pattern_args = patterns
.iter()
// PowerShell expands expressions in double-quoted strings. Single quote
// each pattern so it is passed unchanged to -Include.
.map(|pattern| shell_quote_arg(pattern, ShellType::PowerShell))
.join(",");
format!(
"Get-ChildItem -File -Recurse -Include {pattern_args} -Path {} | ForEach-Object {{ $_.FullName }}",
shell_quote_arg(target_path, ShellType::PowerShell)
)
}
fn non_empty_lines(str: &str) -> impl Iterator<Item = &str> {
str.lines().filter(|line| !line.is_empty())
}
@@ -374,3 +406,7 @@ fn non_empty_lines(str: &str) -> impl Iterator<Item = &str> {
impl Entity for FileGlobExecutor {
type Event = ();
}
#[cfg(test)]
#[path = "file_glob_tests.rs"]
mod tests;
@@ -0,0 +1,57 @@
use std::path::PathBuf;
use super::*;
use crate::terminal::shell::ShellType;
#[test]
fn build_find_command_single_quotes_patterns_and_path() {
let patterns = vec![
"$(touch /tmp/warp-poc)*.rs".to_string(),
"owner's*.rs".to_string(),
];
let command = build_find_command(&patterns, "/tmp/repo path", ShellType::Bash);
assert_eq!(
command,
r#"find '/tmp/repo path' -type f -name '$(touch /tmp/warp-poc)*.rs' -o -name 'owner'"'"'s*.rs'"#
);
}
#[test]
fn build_git_ls_files_command_single_quotes_joined_patterns() {
let pattern = "$(touch /tmp/warp-poc)*.rs";
let patterns = vec![pattern.to_string()];
let target_path = PathBuf::from(std::path::MAIN_SEPARATOR_STR)
.join("tmp")
.join("repo");
let command = build_git_ls_files_command(
&patterns,
target_path.to_str().unwrap(),
None,
ShellType::Bash,
);
let expected = format!(
"git ls-files -c -o --exclude-standard -- '{}' '{}'",
target_path.join(pattern).display(),
target_path.join("*").join(pattern).display(),
);
assert_eq!(command, expected);
}
#[test]
fn build_powershell_get_childitem_command_single_quotes_patterns_and_path() {
let patterns = vec![
r#"$(New-Item C:\pwn)*.rs"#.to_string(),
"owner's*.rs".to_string(),
];
let command = build_powershell_get_childitem_command(&patterns, r#"C:\repo path"#);
assert_eq!(
command,
r#"Get-ChildItem -File -Recurse -Include '$(New-Item C:\pwn)*.rs','owner''s*.rs' -Path 'C:\repo path' | ForEach-Object { $_.FullName }"#
);
}
@@ -12,7 +12,8 @@ use crate::{
},
blocklist::BlocklistAIPermissions,
get_relevant_files::controller::{
GetRelevantFilesController, GetRelevantFilesError, GetRelevantFilesStatus,
GetRelevantFilesController, GetRelevantFilesError, GetRelevantFilesRequestTarget,
GetRelevantFilesStatus,
},
paths::host_native_absolute_path,
},
@@ -199,7 +200,9 @@ impl GetFilesExecutor {
.get_relevant_files_controller
.update(ctx, |controller, ctx| {
controller.send_request(
&current_working_directory,
GetRelevantFilesRequestTarget::Local {
directory: current_working_directory.clone(),
},
query.clone(),
partial_paths.as_ref(),
id.clone(),
@@ -308,7 +311,8 @@ impl GetFilesExecutor {
ActionExecution::Async {
execute_future: Box::pin(async move {
let result =
read_local_file_context(&files, current_working_directory, shell, None, None).await?;
read_local_file_context(&files, current_working_directory, shell, None, None)
.await?;
if result.missing_files.is_empty() {
Ok(GetFilesResult::Success {
files: result.file_contexts,
@@ -4,49 +4,34 @@ use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use galaxy_util::standardized_path::StandardizedPath;
use futures::future::BoxFuture;
use futures::FutureExt;
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui::r#async::FutureExt as AsyncFutureExt;
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use crate::ai::agent::redaction::redact_secrets;
use crate::ai::agent::{
conversation::AIConversationId, AIAgentAction, AIAgentActionType, GrepResult, ServerOutputId,
};
use crate::ai::blocklist::{
telemetry_banner::should_collect_ai_ugc_telemetry, BlocklistAIPermissions,
};
use crate::ai::paths::{host_native_absolute_path, shell_native_absolute_path};
use crate::terminal::model::session::ExecuteCommandOptions;
use crate::PrivacySettings;
use crate::{
ai::agent::{AIAgentActionResultType, GrepFileMatch, GrepLineMatch},
send_telemetry_from_app_ctx,
terminal::{
model::session::active_session::ActiveSession, model::session::Session, shell::ShellType,
ShellLaunchData,
},
TelemetryEvent,
};
use super::{
get_server_output_id, is_file_path, is_git_repository, ActionExecution, AnyActionExecution,
ExecuteActionInput, PreprocessActionInput,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::redaction::redact_secrets;
use crate::ai::agent::{
AIAgentAction, AIAgentActionResultType, AIAgentActionType, GrepFileMatch, GrepLineMatch,
GrepResult, ServerOutputId,
};
use crate::ai::blocklist::telemetry_banner::should_collect_ai_ugc_telemetry;
use crate::ai::blocklist::BlocklistAIPermissions;
use crate::ai::paths::{host_native_absolute_path, shell_native_absolute_path};
use crate::terminal::model::session::active_session::ActiveSession;
use crate::terminal::model::session::{shell_quote_arg, ExecuteCommandOptions, Session};
use crate::terminal::shell::ShellType;
use crate::terminal::ShellLaunchData;
use crate::{send_telemetry_from_app_ctx, PrivacySettings, TelemetryEvent};
const GREP_TIMEOUT: Duration = Duration::from_secs(10);
const NON_ZERO_EXIT_CODE_ERROR: &str = "Grep command exited with non-zero exit code";
fn escape_double_quotes(s: &str) -> String {
s.replace('"', "\\\"")
}
fn powershell_escape_double_quotes(s: &str) -> String {
s.replace('"', "`\"")
}
/// Information about the Grep call that resulted in an error, used to send
/// telemetry about the error.
struct GrepError {
@@ -432,6 +417,7 @@ async fn run_grep(
&absolute_path,
&session,
shell_launch_data,
shell_type,
&execute_directory,
)
.await
@@ -477,20 +463,7 @@ async fn run_git_grep_command(
shell_type: ShellType,
execute_directory: &str,
) -> Result<GrepResult, GrepError> {
// This command works on all the shells we support (even PowerShell).
let mut grep_command = "git --no-pager grep --color=never --untracked -nIE".to_string();
for query in queries {
let escaped_query = format!(
"\"{}\"",
if shell_type == ShellType::PowerShell {
powershell_escape_double_quotes(query)
} else {
escape_double_quotes(query)
}
);
grep_command.push_str(format!(" -e {escaped_query}").as_str());
}
grep_command.push_str(format!(" \"{target_path}\"").as_str());
let grep_command = build_git_grep_command(queries, target_path, shell_type);
let command_output = session
.execute_command(
@@ -536,20 +509,10 @@ async fn run_grep_command(
target_path: &str,
session: &Session,
shell_launch_data: Option<ShellLaunchData>,
shell_type: ShellType,
execute_directory: &str,
) -> Result<GrepResult, GrepError> {
// Summary of the options we use:
// * "--color=never" ensures we don't get colorized output which is harder to parse due to escape sequences
// * "-n" includes line numbers
// * "-r" performs a recursive search
// * "-I" ignores binary files
// * "-H" prints file name headers
// * "-E" uses extended regex expressions
let mut grep_command = "grep --color=never -nrIHE --devices=skip".to_string();
for query in queries {
grep_command.push_str(format!(" -e \"{}\"", escape_double_quotes(query)).as_str());
}
grep_command.push_str(format!(" \"{target_path}\"").as_str());
let grep_command = build_grep_command(queries, target_path, shell_type);
let command_output = session
.execute_command(
@@ -598,17 +561,7 @@ async fn run_select_string_command(
shell_launch_data: Option<ShellLaunchData>,
execute_directory: &str,
) -> Result<GrepResult, GrepError> {
// We enable the `-CaseSensitive` flag to match the default behavior of grep.
// TODO(CODE-239): Make this command more efficient when searching a file.
let select_string_command = format!(
"Get-ChildItem -Path \"{}\" -Recurse -File | Select-String -NoEmphasis -CaseSensitive -Pattern {}",
target_path,
queries
.iter()
.map(|q| format!("\"{}\"", powershell_escape_double_quotes(q)))
.collect::<Vec<_>>()
.join(",")
);
let select_string_command = build_select_string_command(queries, target_path);
let command_output = session
.execute_command(
@@ -640,6 +593,52 @@ async fn run_select_string_command(
}
}
fn build_git_grep_command(queries: &[String], target_path: &str, shell_type: ShellType) -> String {
// This command works on all the shells we support (even PowerShell).
let mut grep_command = "git --no-pager grep --color=never --untracked -nIE".to_string();
for query in queries {
// Queries can originate from model output and project instructions. Keep
// them as grep arguments so shell substitutions like $() are inert.
grep_command.push_str(format!(" -e {}", shell_quote_arg(query, shell_type)).as_str());
}
grep_command.push_str(format!(" {}", shell_quote_arg(target_path, shell_type)).as_str());
grep_command
}
fn build_grep_command(queries: &[String], target_path: &str, shell_type: ShellType) -> String {
// Summary of the options we use:
// * "--color=never" ensures we don't get colorized output which is harder to parse due to escape sequences
// * "-n" includes line numbers
// * "-r" performs a recursive search
// * "-I" ignores binary files
// * "-H" prints file name headers
// * "-E" uses extended regex expressions
let mut grep_command = "grep --color=never -nrIHE --devices=skip".to_string();
for query in queries {
// Queries can originate from model output and project instructions. Keep
// them as grep arguments so shell substitutions like $() are inert.
grep_command.push_str(format!(" -e {}", shell_quote_arg(query, shell_type)).as_str());
}
grep_command.push_str(format!(" {}", shell_quote_arg(target_path, shell_type)).as_str());
grep_command
}
fn build_select_string_command(queries: &[String], target_path: &str) -> String {
// We enable the `-CaseSensitive` flag to match the default behavior of grep.
// TODO(CODE-239): Make this command more efficient when searching a file.
format!(
"Get-ChildItem -Path {} -Recurse -File | Select-String -NoEmphasis -CaseSensitive -Pattern {}",
shell_quote_arg(target_path, ShellType::PowerShell),
queries
.iter()
// PowerShell evaluates command substitutions in double-quoted
// strings, so patterns must be single-quoted data arguments.
.map(|q| shell_quote_arg(q, ShellType::PowerShell))
.collect::<Vec<_>>()
.join(",")
)
}
/// Parses the output of grep or a grep-like command into the format that we pass
/// back to the agent.
///
@@ -1,5 +1,6 @@
use super::*;
use crate::terminal::{model::secrets::regexes::FIREBASE_AUTH_DOMAIN, shell::ShellType};
use crate::terminal::model::secrets::regexes::FIREBASE_AUTH_DOMAIN;
use crate::terminal::shell::ShellType;
#[test]
fn test_create_redacted_grep_error_event() {
@@ -71,3 +72,39 @@ fn test_create_redacted_grep_error_event() {
panic!("Expected GrepToolFailed event");
}
}
#[test]
fn build_git_grep_command_single_quotes_shell_substitution() {
let queries = vec!["$(touch /tmp/warp-poc); `id`".to_string()];
let command = build_git_grep_command(&queries, "/tmp/repo path", ShellType::Bash);
assert_eq!(
command,
"git --no-pager grep --color=never --untracked -nIE -e '$(touch /tmp/warp-poc); `id`' '/tmp/repo path'"
);
}
#[test]
fn build_grep_command_escapes_single_quotes() {
let queries = vec!["owner's code".to_string()];
let command = build_grep_command(&queries, "/tmp/repo", ShellType::Bash);
assert_eq!(
command,
r#"grep --color=never -nrIHE --devices=skip -e 'owner'"'"'s code' '/tmp/repo'"#
);
}
#[test]
fn build_select_string_command_single_quotes_powershell_substitution() {
let queries = vec![r#"$(New-Item C:\pwn); 'literal'"#.to_string()];
let command = build_select_string_command(&queries, r#"C:\repo path"#);
assert_eq!(
command,
r#"Get-ChildItem -Path 'C:\repo path' -Recurse -File | Select-String -NoEmphasis -CaseSensitive -Pattern '$(New-Item C:\pwn); ''literal'''"#
);
}
@@ -1,15 +1,12 @@
use futures::{future::BoxFuture, FutureExt};
use galaxyui::{Entity, ModelContext, SingletonEntity};
use crate::ai::{
agent::{
AIAgentAction, AIAgentActionType, DocumentContext, ReadDocumentsRequest,
ReadDocumentsResult,
},
document::ai_document_model::AIDocumentModel,
};
use futures::future::BoxFuture;
use futures::FutureExt;
use galaxyui::{AppContext, Entity, ModelContext, SingletonEntity};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use crate::ai::agent::{
AIAgentAction, AIAgentActionType, DocumentContext, ReadDocumentsRequest, ReadDocumentsResult,
};
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentModel};
pub struct ReadDocumentsExecutor;
@@ -32,7 +29,10 @@ impl ReadDocumentsExecutor {
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let ExecuteActionInput { action, .. } = input;
let ExecuteActionInput {
action,
conversation_id,
} = input;
let AIAgentAction {
action: AIAgentActionType::ReadDocuments(ReadDocumentsRequest { document_ids }),
..
@@ -41,22 +41,41 @@ impl ReadDocumentsExecutor {
return ActionExecution::<ReadDocumentsResult>::InvalidAction;
};
// Access the model synchronously before the async block
let model = AIDocumentModel::handle(ctx);
let documents: Vec<DocumentContext> = document_ids
.iter()
.filter_map(|id| {
let model = model.as_ref(ctx);
let content = model.get_document_content(id, ctx)?;
let version = model.get_current_document(id)?.version;
Some(DocumentContext {
document_id: *id,
content,
line_ranges: vec![],
document_version: version,
})
})
.collect();
// A requested plan may live in Warp Drive without being loaded into this conversation's
// document model (e.g. orchestration children reading parent plans, or plan IDs
// copy-pasted from another conversation), so fall back to hydrating it on a miss.
let mut documents = Vec::with_capacity(document_ids.len());
let mut missing_documents = Vec::new();
for id in document_ids {
let mut document = try_read_document(id, ctx);
if document.is_none() {
AIDocumentModel::handle(ctx).update(ctx, |model, ctx| {
if let Err(error) =
model.hydrate_saved_plan_from_warp_drive(*id, conversation_id, ctx)
{
log::warn!(
"Failed to hydrate requested plan document {id} from Warp Drive: {error}"
);
}
});
document = try_read_document(id, ctx);
}
match document {
Some(document) => documents.push(document),
None => missing_documents.push(*id),
}
}
if !missing_documents.is_empty() {
let missing_list = missing_documents
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ");
return ActionExecution::Sync(
ReadDocumentsResult::Error(format!("Document(s) not found: {missing_list}")).into(),
);
}
ActionExecution::Sync(ReadDocumentsResult::Success { documents }.into())
}
@@ -73,3 +92,20 @@ impl ReadDocumentsExecutor {
impl Entity for ReadDocumentsExecutor {
type Event = ();
}
/// Reads one document, returning `None` if it is not loaded locally.
fn try_read_document(id: &AIDocumentId, ctx: &AppContext) -> Option<DocumentContext> {
let model = AIDocumentModel::as_ref(ctx);
let content = model.get_document_content(id, ctx)?;
let version = model.get_current_document(id)?.version;
Some(DocumentContext {
document_id: *id,
content,
line_ranges: vec![],
document_version: version,
})
}
#[cfg(test)]
#[path = "read_documents_tests.rs"]
mod tests;
@@ -0,0 +1,151 @@
use warpui::{App, EntityId};
use super::*;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType,
ReadDocumentsRequest, ReadDocumentsResult,
};
use crate::ai::blocklist::BlocklistAIHistoryModel;
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentModel};
use crate::appearance::Appearance;
use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::{
CloudObjectMetadata, CloudObjectPermissions, CloudObjectStatuses, CloudObjectSyncStatus, Owner,
};
use crate::notebooks::{CloudNotebook, CloudNotebookModel};
use crate::server::ids::SyncId;
use crate::test_util::settings::initialize_settings_for_tests;
fn initialize_app(app: &mut App) {
initialize_settings_for_tests(app);
app.add_singleton_model(|_| Appearance::mock());
app.add_singleton_model(|_| CloudModel::new(None, Vec::new(), None));
app.add_singleton_model(|_| AIDocumentModel::new_for_test());
app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
}
fn read_action(document_id: AIDocumentId) -> AIAgentAction {
AIAgentAction {
id: AIAgentActionId::from("read-documents-action".to_string()),
task_id: TaskId::new("read-documents-task".to_string()),
requires_result: true,
action: AIAgentActionType::ReadDocuments(ReadDocumentsRequest {
document_ids: vec![document_id],
}),
}
}
fn add_saved_plan_notebook(app: &mut App, document_id: AIDocumentId, content: &str) {
let sync_id = SyncId::ServerId(123.into());
let notebook = CloudNotebook::new(
sync_id,
CloudNotebookModel {
title: "Saved plan".to_string(),
data: content.to_string(),
ai_document_id: Some(document_id),
conversation_id: None,
},
CloudObjectMetadata {
pending_changes_statuses: CloudObjectStatuses {
content_sync_status: CloudObjectSyncStatus::NoLocalChanges,
has_pending_metadata_change: false,
has_pending_permissions_change: false,
pending_untrash: false,
pending_delete: false,
},
folder_id: None,
revision: Default::default(),
metadata_last_updated_ts: Default::default(),
current_editor_uid: Default::default(),
trashed_ts: Default::default(),
is_welcome_object: false,
creator_uid: None,
last_editor_uid: None,
last_task_run_ts: None,
},
CloudObjectPermissions {
owner: Owner::mock_current_user(),
guests: Vec::new(),
permissions_last_updated_ts: None,
anyone_with_link: None,
},
);
CloudModel::handle(app).update(app, |cloud_model, _| {
cloud_model.add_object(sync_id, notebook);
});
}
#[test]
fn execute_lazily_hydrates_missing_plan_for_remote_child_without_local_parent() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let executor = app.add_model(|_| ReadDocumentsExecutor::new());
let document_id = AIDocumentId::new();
add_saved_plan_notebook(&mut app, document_id, "# Remote child plan");
let child_conversation_id =
BlocklistAIHistoryModel::handle(&app).update(&mut app, |history, ctx| {
let child_conversation_id =
history.start_new_conversation(EntityId::new(), false, false, false, ctx);
history
.conversation_mut(&child_conversation_id)
.expect("child conversation should exist")
.set_parent_agent_id("non-local-parent-run-id".to_string());
child_conversation_id
});
let action = read_action(document_id);
let execution: AnyActionExecution = executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: child_conversation_id,
},
ctx,
)
.into()
});
let AnyActionExecution::Sync(AIAgentActionResultType::ReadDocuments(
ReadDocumentsResult::Success { documents },
)) = execution
else {
panic!("expected read_documents success");
};
assert_eq!(documents.len(), 1);
assert_eq!(documents[0].document_id, document_id);
assert_eq!(documents[0].content, "# Remote child plan\n");
});
}
#[test]
fn execute_returns_error_for_missing_document_id() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let executor = app.add_model(|_| ReadDocumentsExecutor::new());
let missing_document_id = AIDocumentId::new();
let action = read_action(missing_document_id);
let execution: AnyActionExecution = executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: AIConversationId::new(),
},
ctx,
)
.into()
});
let AnyActionExecution::Sync(AIAgentActionResultType::ReadDocuments(
ReadDocumentsResult::Error(error),
)) = execution
else {
panic!("expected read_documents error");
};
assert!(error.contains(&missing_document_id.to_string()));
});
}
@@ -1,24 +1,20 @@
use std::path::{Path, PathBuf};
use futures::{future::BoxFuture, FutureExt};
use futures::future::BoxFuture;
use futures::FutureExt;
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use crate::{
ai::{
agent::{
AIAgentAction, AIAgentActionResultType, AIAgentActionType, ReadFilesRequest,
ReadFilesResult,
},
blocklist::BlocklistAIPermissions,
paths::host_native_absolute_path,
},
terminal::model::session::{active_session::ActiveSession, SessionType},
};
use super::{
read_local_file_context, ActionExecution, AnyActionExecution, ExecuteActionInput,
PreprocessActionInput,
};
use crate::ai::agent::{
AIAgentAction, AIAgentActionResultType, AIAgentActionType, ReadFilesRequest, ReadFilesResult,
};
use crate::ai::blocklist::BlocklistAIPermissions;
use crate::ai::paths::host_native_absolute_path;
use crate::terminal::model::session::active_session::ActiveSession;
use crate::terminal::model::session::SessionType;
pub struct ReadFilesExecutor {
active_session: ModelHandle<ActiveSession>,
@@ -118,20 +114,21 @@ impl ReadFilesExecutor {
// Check if this is a remote session with a connected host.
let session_type = self.active_session.as_ref(ctx).session_type(ctx);
let remote_client = match &session_type {
let host_request_handle = match &session_type {
Some(SessionType::WarpifiedRemote {
host_id: Some(host_id),
}) => remote_server::manager::RemoteServerManager::as_ref(ctx)
.client_for_host(host_id)
.cloned(),
}) => Some(
remote_server::manager::RemoteServerManager::as_ref(ctx)
.host_request_handle(host_id),
),
_ => None,
};
// Remote session without a usable remote server client. File reading
// Remote session without a usable remote server connection. File reading
// requires either local access or a connected remote server, neither
// of which is available.
if matches!(session_type, Some(SessionType::WarpifiedRemote { .. }))
&& remote_client.is_none()
&& host_request_handle.is_none()
{
return ActionExecution::Sync(AIAgentActionResultType::ReadFiles(
ReadFilesResult::Error(
@@ -142,7 +139,7 @@ impl ReadFilesExecutor {
));
}
if let Some(client) = remote_client {
if let Some(handle) = host_request_handle {
return ActionExecution::Async {
execute_future: Box::pin(async move {
let request = remote_server::proto::ReadFileContextRequest {
@@ -171,7 +168,7 @@ impl ReadFilesExecutor {
max_batch_bytes: None,
};
let response = client
let response = handle
.read_file_context(request)
.await
.map_err(|e| anyhow::anyhow!("Remote read failed: {e}"))?;
@@ -1,10 +1,12 @@
use futures::future::BoxFuture;
use futures::FutureExt;
#[cfg(not(target_family = "wasm"))]
use warpui::SingletonEntity;
use warpui::{Entity, EntityId, ModelContext, ModelHandle};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
#[cfg(not(target_family = "wasm"))]
use crate::ai::mcp::TemplatableMCPServerManager;
use crate::terminal::model::session::active_session::ActiveSession;
use futures::{future::BoxFuture, FutureExt};
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle};
#[cfg(not(target_family = "wasm"))]
use crate::ai::{
agent::{AIAgentActionResultType, ReadMCPResourceResult},
@@ -13,8 +15,7 @@ use crate::ai::{
BlocklistAIPermissions,
},
};
#[cfg(not(target_family = "wasm"))]
use galaxyui::SingletonEntity;
use crate::terminal::model::session::active_session::ActiveSession;
pub struct ReadMCPResourceExecutor {
_active_session: ModelHandle<ActiveSession>,
@@ -128,7 +129,7 @@ impl ReadMCPResourceExecutor {
ActionExecution::new_async(
async move {
reconnecting_peer
.read_resource(rmcp::model::ReadResourceRequestParam { uri })
.read_resource(rmcp::model::ReadResourceRequestParams::new(uri))
.await
},
|res, _ctx| handle_read_resource_result(res),
@@ -1,21 +1,21 @@
use ai::agent::action_result::{AnyFileContent, FileContext};
use futures::future::{BoxFuture, FutureExt};
use warpui::{Entity, ModelContext, ModelHandle, SingletonEntity};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use crate::ai::agent::{AIAgentActionType, ReadSkillRequest, ReadSkillResult};
use crate::ai::blocklist::SessionContext;
use crate::ai::skills::{SkillManager, SkillTelemetryEvent};
use crate::send_telemetry_from_ctx;
use ai::agent::action_result::AnyFileContent;
use galaxyui::{ModelContext, SingletonEntity};
use crate::terminal::model::session::active_session::ActiveSession;
use crate::ai::agent::AIAgentActionType;
use crate::ai::agent::ReadSkillRequest;
use crate::ai::agent::ReadSkillResult;
use ai::agent::action_result::FileContext;
use futures::future::{BoxFuture, FutureExt};
use galaxyui::Entity;
pub struct ReadSkillExecutor;
pub struct ReadSkillExecutor {
active_session: ModelHandle<ActiveSession>,
}
impl ReadSkillExecutor {
pub fn new() -> Self {
Self
pub fn new(active_session: ModelHandle<ActiveSession>) -> Self {
Self { active_session }
}
pub(super) fn should_autoexecute(
@@ -38,8 +38,17 @@ impl ReadSkillExecutor {
return ActionExecution::<ReadSkillResult>::InvalidAction;
};
match SkillManager::as_ref(ctx).skill_by_reference(skill_ref) {
Some(skill) => {
// Resolve from the catalog selected by the active session's host, so
// remote sessions read the host-rendered bundled skill.
let path_origin =
SessionContext::from_session(self.active_session.as_ref(ctx), ctx).skill_path_origin();
match SkillManager::as_ref(ctx).active_skill_by_reference_with_origin(
skill_ref,
&path_origin,
ctx,
) {
Ok(skill) => {
send_telemetry_from_ctx!(
SkillTelemetryEvent::Read {
reference: skill_ref.clone(),
@@ -51,14 +60,14 @@ impl ReadSkillExecutor {
ctx
);
let content = FileContext::new(
skill.path.to_string_lossy().into_owned(),
skill.path.display_path(),
AnyFileContent::StringContent(skill.content.clone()),
skill.line_range.clone(),
None,
);
ActionExecution::Sync(ReadSkillResult::Success { content }.into())
}
None => {
Err(error) => {
send_telemetry_from_ctx!(
SkillTelemetryEvent::Read {
reference: skill_ref.clone(),
@@ -69,9 +78,7 @@ impl ReadSkillExecutor {
},
ctx
);
ActionExecution::Sync(
ReadSkillResult::Error(format!("Skill not found: {:?}", skill_ref)).into(),
)
ActionExecution::Sync(ReadSkillResult::Error(error.to_string()).into())
}
}
}
@@ -1,30 +1,65 @@
use super::*;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::AIAgentActionResultType;
use crate::ai::agent::ReadSkillRequest;
use crate::ai::agent::ReadSkillResult;
use crate::ai::agent::{AIAgentAction, AIAgentActionId, AIAgentActionType};
use crate::ai::blocklist::action_model::AIConversationId;
use crate::ai::skills::SkillManager;
use crate::galaxy_managed_paths_watcher::GalaxyManagedPathsWatcher;
use ai::skills::{parse_skill, SkillReference};
use galaxyui::App;
use repo_metadata::{
repositories::DetectedRepositories, watcher::DirectoryWatcher, RepoMetadataModel,
};
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use ai::skills::{parse_skill, ParsedSkill, SkillProvider, SkillReference, SkillScope};
use async_channel::unbounded;
use repo_metadata::repositories::DetectedRepositories;
use repo_metadata::watcher::DirectoryWatcher;
use repo_metadata::RepoMetadataModel;
use tempfile::TempDir;
use galaxy_core::features::FeatureFlag;
use galaxy_core::HostId;
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
use galaxy_util::remote_path::RemotePath;
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui::{App, ModelHandle};
use watcher::HomeDirectoryWatcher;
use super::*;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, ReadSkillRequest,
ReadSkillResult,
};
use crate::ai::blocklist::action_model::AIConversationId;
use crate::ai::skills::{BundledSkillActivation, SkillManager};
use crate::settings::AISettings;
use crate::terminal::model::session::active_session::ActiveSession;
use crate::terminal::model::session::{BootstrapSessionType, SessionId, SessionInfo, Sessions};
use crate::terminal::model_events::ModelEventDispatcher;
use crate::warp_managed_paths_watcher::WarpManagedPathsWatcher;
fn initialize_app(app: &mut App) {
app.add_singleton_model(DirectoryWatcher::new);
app.add_singleton_model(AISettings::new_with_defaults);
app.add_singleton_model(|_| DetectedRepositories::default());
app.add_singleton_model(RepoMetadataModel::new);
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
app.add_singleton_model(GalaxyManagedPathsWatcher::new_for_testing);
app.add_singleton_model(SkillManager::new);
}
fn add_test_read_skill_executor(app: &mut App) -> ModelHandle<ReadSkillExecutor> {
let sessions = app.add_model(|_| Sessions::new_for_test());
let (_model_events_tx, model_events_rx) = unbounded();
let model_event_dispatcher =
app.add_model(|ctx| ModelEventDispatcher::new(model_events_rx, sessions.clone(), ctx));
let active_session = app
.add_model(|ctx| ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx));
app.add_model(|_| ReadSkillExecutor::new(active_session))
}
fn bundled_skill(name: &str) -> ParsedSkill {
ParsedSkill {
name: name.to_string(),
description: format!("{name} bundled skill"),
path: LocalOrRemotePath::Local(PathBuf::from(format!("/bundled/skills/{name}/SKILL.md"))),
content: format!("# {name}"),
line_range: None,
provider: SkillProvider::Warp,
scope: SkillScope::Bundled,
}
}
fn create_test_skill_file(dir: &TempDir, name: &str, description: &str) -> std::path::PathBuf {
let skill_content = format!(
@@ -68,12 +103,12 @@ fn test_read_skill_executor_success() {
manager.add_skill_for_testing(parsed_skill);
});
let executor_handle = app.add_model(|_| ReadSkillExecutor::new());
let executor_handle = add_test_read_skill_executor(&mut app);
let action = AIAgentAction {
id: AIAgentActionId::from("test-action-id".to_string()),
action: AIAgentActionType::ReadSkill(ReadSkillRequest {
skill: SkillReference::Path(skill_path.clone()),
skill: SkillReference::Path(LocalOrRemotePath::Local(skill_path.clone())),
}),
task_id: TaskId::new("test-task-id".to_string()),
requires_result: false,
@@ -99,6 +134,241 @@ fn test_read_skill_executor_success() {
});
}
#[test]
fn disconnected_remote_session_does_not_fall_back_to_client_global_bundled_skill() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let _bundled_skills = FeatureFlag::BundledSkills.override_enabled(true);
SkillManager::handle(&app).update(&mut app, |manager, _ctx| {
manager.add_bundled_skill_for_testing(
"remote-only",
bundled_skill("remote-only"),
BundledSkillActivation::Always,
);
});
let session_id = SessionId::from(42);
let sessions = app.add_model(|_| Sessions::new_for_test());
sessions.update(&mut app, |sessions, _ctx| {
sessions.register_session_for_test(
SessionInfo::new_for_test()
.with_id(session_id)
.with_session_type(BootstrapSessionType::WarpifiedRemote),
);
});
let (_model_events_tx, model_events_rx) = unbounded();
let model_event_dispatcher =
app.add_model(|ctx| ModelEventDispatcher::new(model_events_rx, sessions.clone(), ctx));
model_event_dispatcher.update(&mut app, |dispatcher, _ctx| {
dispatcher.set_active_session_id(session_id);
});
let active_session = app.add_model(|ctx| {
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
});
let executor_handle = app.add_model(|_| ReadSkillExecutor::new(active_session));
let action = AIAgentAction {
id: AIAgentActionId::from("test-action-id".to_string()),
action: AIAgentActionType::ReadSkill(ReadSkillRequest {
skill: SkillReference::BundledSkillId("remote-only".to_string()),
}),
task_id: TaskId::new("test-task-id".to_string()),
requires_result: false,
};
let input = ExecuteActionInput {
action: &action,
conversation_id: AIConversationId::new(),
};
executor_handle.update(&mut app, |executor, ctx| {
let result: AnyActionExecution = executor.execute(input, ctx).into();
assert!(matches!(
result,
AnyActionExecution::Sync(AIAgentActionResultType::ReadSkill(
ReadSkillResult::Error(error)
)) if error == "Bundled skills are not available on this remote session"
));
});
});
}
#[test]
fn remote_session_reads_remote_bundled_skill_catalog() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let _bundled_skills = FeatureFlag::BundledSkills.override_enabled(true);
let host_id = HostId::new("remote-host".to_string());
let remote_skill = ParsedSkill {
name: "host-specific".to_string(),
description: "remote bundled skill".to_string(),
path: LocalOrRemotePath::Remote(RemotePath::new(
host_id.clone(),
StandardizedPath::try_new(
"/opt/warp/resources/bundled/skills/host-specific/SKILL.md",
)
.unwrap(),
)),
content: "remote rendered content".to_string(),
line_range: None,
provider: SkillProvider::Warp,
scope: SkillScope::Bundled,
};
SkillManager::handle(&app).update(&mut app, |manager, _ctx| {
manager.add_bundled_skill_for_testing(
"host-specific",
bundled_skill("host-specific"),
BundledSkillActivation::Always,
);
manager.add_remote_bundled_skill_for_testing(
host_id.clone(),
"host-specific",
remote_skill,
BundledSkillActivation::Always,
);
});
let session_id = SessionId::from(42);
let sessions = app.add_model(|_| Sessions::new_for_test());
sessions.update(&mut app, |sessions, _ctx| {
sessions.register_session_for_test(
SessionInfo::new_for_test()
.with_id(session_id)
.with_session_type(BootstrapSessionType::WarpifiedRemote),
);
});
let session = sessions
.read(&app, |sessions, _ctx| sessions.get(session_id))
.unwrap();
session.set_remote_host_id(Some(host_id));
let (_model_events_tx, model_events_rx) = unbounded();
let model_event_dispatcher =
app.add_model(|ctx| ModelEventDispatcher::new(model_events_rx, sessions.clone(), ctx));
model_event_dispatcher.update(&mut app, |dispatcher, _ctx| {
dispatcher.set_active_session_id(session_id);
});
let active_session = app.add_model(|ctx| {
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
});
let executor_handle = app.add_model(|_| ReadSkillExecutor::new(active_session));
let action = AIAgentAction {
id: AIAgentActionId::from("test-action-id".to_string()),
action: AIAgentActionType::ReadSkill(ReadSkillRequest {
skill: SkillReference::BundledSkillId("host-specific".to_string()),
}),
task_id: TaskId::new("test-task-id".to_string()),
requires_result: false,
};
let input = ExecuteActionInput {
action: &action,
conversation_id: AIConversationId::new(),
};
executor_handle.update(&mut app, |executor, ctx| {
let result: AnyActionExecution = executor.execute(input, ctx).into();
match result {
AnyActionExecution::Sync(AIAgentActionResultType::ReadSkill(
ReadSkillResult::Success { content },
)) => {
assert_eq!(
content.file_name,
"/opt/warp/resources/bundled/skills/host-specific/SKILL.md"
);
assert_eq!(
content.content,
AnyFileContent::StringContent("remote rendered content".to_string())
);
}
_ => panic!("Remote session should read its host-specific bundled skill"),
}
});
});
}
#[test]
fn test_read_skill_executor_reads_enabled_bundled_skill() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let _bundled_skills = FeatureFlag::BundledSkills.override_enabled(true);
SkillManager::handle(&app).update(&mut app, |manager, _ctx| {
manager.add_bundled_skill_for_testing(
"pr-comments",
bundled_skill("pr-comments"),
BundledSkillActivation::Always,
);
});
let executor_handle = add_test_read_skill_executor(&mut app);
let action = AIAgentAction {
id: AIAgentActionId::from("test-action-id".to_string()),
action: AIAgentActionType::ReadSkill(ReadSkillRequest {
skill: SkillReference::BundledSkillId("pr-comments".to_string()),
}),
task_id: TaskId::new("test-task-id".to_string()),
requires_result: false,
};
let input = ExecuteActionInput {
action: &action,
conversation_id: AIConversationId::new(),
};
executor_handle.update(&mut app, |executor, ctx| {
let result: AnyActionExecution = executor.execute(input, ctx).into();
match result {
AnyActionExecution::Sync(AIAgentActionResultType::ReadSkill(
ReadSkillResult::Success { content },
)) => {
assert_eq!(content.file_name, "/bundled/skills/pr-comments/SKILL.md");
}
_ => panic!("Enabled bundled skill should return ReadSkillResult::Success"),
}
});
});
}
#[test]
fn test_read_skill_executor_rejects_warp_control_bundled_skills_when_disabled() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let _bundled_skills = FeatureFlag::BundledSkills.override_enabled(true);
let _warp_control_cli = FeatureFlag::WarpControlCli.override_enabled(false);
let skill_id = "warpctrl";
SkillManager::handle(&app).update(&mut app, |manager, _ctx| {
manager.add_bundled_skill_for_testing(
skill_id,
bundled_skill(skill_id),
BundledSkillActivation::RequiresFeature(FeatureFlag::WarpControlCli),
);
});
let executor_handle = add_test_read_skill_executor(&mut app);
let action = AIAgentAction {
id: AIAgentActionId::from(format!("test-action-id-{skill_id}")),
action: AIAgentActionType::ReadSkill(ReadSkillRequest {
skill: SkillReference::BundledSkillId(skill_id.to_string()),
}),
task_id: TaskId::new(format!("test-task-id-{skill_id}")),
requires_result: false,
};
let input = ExecuteActionInput {
action: &action,
conversation_id: AIConversationId::new(),
};
executor_handle.update(&mut app, |executor, ctx| {
let result: AnyActionExecution = executor.execute(input, ctx).into();
assert!(matches!(
result,
AnyActionExecution::Sync(AIAgentActionResultType::ReadSkill(
ReadSkillResult::Error(_)
))
));
});
});
}
#[test]
fn test_read_skill_executor_file_not_found() {
let temp_dir = TempDir::new().unwrap();
@@ -107,12 +377,12 @@ fn test_read_skill_executor_file_not_found() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let executor_handle = app.add_model(|_| ReadSkillExecutor::new());
let executor_handle = add_test_read_skill_executor(&mut app);
let action = AIAgentAction {
id: AIAgentActionId::from("test-action-id".to_string()),
action: AIAgentActionType::ReadSkill(ReadSkillRequest {
skill: SkillReference::Path(skill_path),
skill: SkillReference::Path(LocalOrRemotePath::Local(skill_path)),
}),
task_id: TaskId::new("test-task-id".to_string()),
requires_result: false,
@@ -1,16 +1,18 @@
use std::collections::HashSet;
use ai::agent::action_result::{AIAgentActionResultType, RequestComputerUseResult};
use futures::{future::BoxFuture, FutureExt};
use futures::future::BoxFuture;
use futures::FutureExt;
use galaxyui::{Entity, EntityId, ModelContext, SingletonEntity};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use crate::ai::agent::{AIAgentActionId, AIAgentActionType};
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::blocklist::BlocklistAIHistoryModel;
use crate::features::FeatureFlag;
use crate::send_telemetry_from_ctx;
use crate::server::telemetry::TelemetryEvent;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
pub struct RequestComputerUseExecutor {
terminal_view_id: EntityId,
ambient_agent_task_id: Option<AmbientAgentTaskId>,
@@ -70,9 +72,14 @@ impl RequestComputerUseExecutor {
// If we're executing, that implies that computer use has been approved.
let is_autoexecuted = self.autoexecuted_actions.remove(&action.id);
let server_conversation_id = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.and_then(|c| c.server_conversation_token())
.map(|t| t.as_str().to_string());
send_telemetry_from_ctx!(
TelemetryEvent::ComputerUseApproved {
conversation_id,
client_conversation_id: conversation_id,
server_conversation_id,
is_autoexecuted,
ambient_agent_task_id: self.ambient_agent_task_id,
},
@@ -82,10 +89,20 @@ impl RequestComputerUseExecutor {
let screenshot_params = request.screenshot_params;
let mut actor = computer_use::create_actor();
let platform = actor.platform();
// Gate per-window targeting behind the client feature flag. When off, the actor forces the
// legacy full-screen path so results are identical to the pre-existing implementation. The
// OS-capability check is folded into the request setting rather than reported in the result.
let background_enabled = FeatureFlag::BackgroundComputerUse.is_enabled();
ActionExecution::Async {
execute_future: Box::pin(async move {
let result = actor
.perform_actions(&[], computer_use::Options { screenshot_params })
.perform_actions(
&[],
computer_use::Options {
screenshot_params,
background_enabled,
},
)
.await;
(result, platform)
}),
@@ -93,6 +110,7 @@ impl RequestComputerUseExecutor {
(
Ok(computer_use::ActionResult {
screenshot: Some(screenshot),
windows,
..
}),
Some(platform),
@@ -100,6 +118,7 @@ impl RequestComputerUseExecutor {
RequestComputerUseResult::Approved {
screenshot,
platform,
windows,
},
),
(
@@ -2,22 +2,17 @@ mod apply_diff_model;
mod diff_application;
mod telemetry;
use galaxy_util::file::FileSaveError;
use std::collections::HashMap;
use std::path::PathBuf;
use ai::diff_validation::AIRequestedCodeDiff;
use futures::{channel::oneshot, future::BoxFuture, FutureExt};
use galaxy_core::send_telemetry_from_ctx;
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity as _, ViewHandle};
use itertools::Itertools;
use vec1::{vec1, Vec1};
use apply_diff_model::ApplyDiffModel;
pub(crate) use diff_application::apply_edits;
use diff_application::DiffApplicationError;
pub(crate) use diff_application::FileReadResult;
pub(crate) use diff_application::{apply_edits, FileReadResult};
use futures::channel::oneshot;
use futures::future::BoxFuture;
use futures::FutureExt;
use itertools::Itertools;
pub(crate) use telemetry::MalformedFinalLineProxyEvent;
#[allow(unused_imports)]
pub use telemetry::{EditAcceptAndContinueClickedEvent, EditAcceptClickedEvent};
@@ -25,28 +20,27 @@ pub use telemetry::{
EditReceivedEvent, EditResolvedEvent, EditStats, RequestFileEditsFormatKind,
RequestFileEditsTelemetryEvent,
};
use crate::{
ai::{
agent::{
conversation::AIConversationId, AIAgentAction, AIAgentActionId,
AIAgentActionResultType, AIAgentActionType, AIAgentOutputMessage,
AIAgentOutputMessageType, AIIdentifiers, RequestFileEditsResult, UpdatedFileContext,
},
blocklist::{
inline_action::code_diff_view::{
CodeDiffView, CodeDiffViewEvent, DiffSessionType, FileDiff,
},
BlocklistAIPermissions, RequestedEditResolution,
},
paths::host_native_absolute_path,
},
safe_warn,
terminal::model::session::{active_session::ActiveSession, SessionType},
BlocklistAIHistoryModel,
};
use vec1::{vec1, Vec1};
use galaxy_core::send_telemetry_from_ctx;
use warp_util::file::FileSaveError;
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity as _, ViewHandle};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType,
AIAgentOutputMessage, AIAgentOutputMessageType, AIIdentifiers, AnyFileContent, FileContext,
FileLocations, RequestFileEditsResult, UpdatedFileContext,
};
use crate::ai::blocklist::inline_action::code_diff_view::{
CodeDiffView, CodeDiffViewEvent, DiffSessionType, FileDiff,
};
use crate::ai::blocklist::{BlocklistAIPermissions, RequestedEditResolution};
use crate::ai::paths::host_native_absolute_path;
use crate::terminal::model::session::active_session::ActiveSession;
use crate::terminal::model::session::SessionType;
use crate::{safe_warn, BlocklistAIHistoryModel};
const APPLY_DIFF_RESULT_CONTEXT_LINES: usize = 10;
pub struct RequestFileEditsExecutor {
active_session: ModelHandle<ActiveSession>,
@@ -183,7 +177,7 @@ impl RequestFileEditsExecutor {
let (result_tx, result_rx) = oneshot::channel();
let mut result_tx = Some(result_tx);
ctx.subscribe_to_view(diff_view, move |_me, event, ctx| match event {
ctx.subscribe_to_view(diff_view, move |_me, _, event, ctx| match event {
CodeDiffViewEvent::Rejected => {
let Some(result_tx) = result_tx.take() else {
return;
@@ -238,35 +232,12 @@ impl RequestFileEditsExecutor {
// This avoids re-reading files from disk or the remote server.
let content_map: HashMap<String, String> = file_contents.iter().cloned().collect();
let mut file_edited_map = HashMap::new();
for (file_location, was_edited) in updated_files.iter() {
file_edited_map.insert(file_location.name.clone(), *was_edited);
}
let _ = result_tx.send(RequestFileEditsResult::Success {
diff: diff.unified_diff.clone(),
updated_files: updated_files
.iter()
.map(|(file_location, was_edited)| {
let content = content_map
.get(&file_location.name)
.cloned()
.unwrap_or_default();
let line_count = content.lines().count();
UpdatedFileContext {
was_edited_by_user: *was_edited,
file_context: crate::ai::agent::FileContext {
file_name: file_location.name.clone(),
content: crate::ai::agent::AnyFileContent::StringContent(
content,
),
line_range: None,
last_modified: None,
line_count,
},
}
})
.collect(),
updated_files: updated_file_contexts_from_editor_buffers(
updated_files,
&content_map,
),
deleted_files: deleted_files.clone(),
lines_added: diff.lines_added,
lines_removed: diff.lines_removed,
@@ -447,6 +418,76 @@ impl RequestFileEditsExecutor {
}
}
fn updated_file_contexts_from_editor_buffers(
updated_files: &[(FileLocations, bool)],
content_map: &HashMap<String, String>,
) -> Vec<UpdatedFileContext> {
updated_files
.iter()
.flat_map(|(file_location, was_edited)| {
let content = content_map
.get(&file_location.name)
.cloned()
.unwrap_or_default();
let line_count = content.lines().count();
let mut file_location = file_location.clone();
file_location.expand_surrounding_context(APPLY_DIFF_RESULT_CONTEXT_LINES);
clamp_to_file_context_range_start(&mut file_location);
if file_location.lines.is_empty() {
return vec![UpdatedFileContext {
was_edited_by_user: *was_edited,
file_context: FileContext {
file_name: file_location.name,
content: AnyFileContent::StringContent(content),
line_range: None,
last_modified: None,
line_count,
},
}];
}
let lines = content.lines().collect_vec();
file_location
.lines
.into_iter()
.map(|range| {
let start = range.start.saturating_sub(1).min(lines.len());
let end = range.end.saturating_sub(1).min(lines.len());
let fragment = if start >= end {
String::new()
} else {
lines[start..end].join("\n")
};
UpdatedFileContext {
was_edited_by_user: *was_edited,
file_context: FileContext {
file_name: file_location.name.clone(),
content: AnyFileContent::StringContent(fragment),
line_range: Some(range),
last_modified: None,
line_count,
},
}
})
.collect_vec()
})
.collect()
}
fn clamp_to_file_context_range_start(file_location: &mut FileLocations) {
for range in &mut file_location.lines {
range.start = range.start.max(1);
range.end = range.end.max(range.start);
}
}
impl Entity for RequestFileEditsExecutor {
type Event = ();
}
#[cfg(test)]
#[path = "request_file_edits_tests.rs"]
mod tests;
@@ -14,13 +14,12 @@ use galaxyui::r#async::BoxFuture;
use galaxyui::{Entity, ModelContext, ModelHandle, SingletonEntity as _};
use vec1::Vec1;
use super::diff_application::{apply_edits, DiffApplicationError, FileReadResult};
use crate::ai::agent::{AIIdentifiers, FileEdit};
use crate::ai::blocklist::SessionContext;
use crate::auth::AuthStateProvider;
use crate::terminal::model::session::active_session::ActiveSession;
use super::diff_application::{apply_edits, DiffApplicationError, FileReadResult};
/// Entity submodel that encapsulates filesystem access for diff application.
///
/// Held as a [`ModelHandle`] by the [`super::RequestFileEditsExecutor`].
@@ -51,17 +50,15 @@ impl ApplyDiffModel {
let auth_state = AuthStateProvider::as_ref(ctx).get().clone();
let ai_identifiers = ai_identifiers.clone();
let remote_client = session_context.host_id().and_then(|host_id| {
remote_server::manager::RemoteServerManager::as_ref(ctx)
.client_for_host(host_id)
.cloned()
let host_request_handle = session_context.host_id().map(|host_id| {
remote_server::manager::RemoteServerManager::as_ref(ctx).host_request_handle(host_id)
});
let is_remote = session_context.is_remote();
let fut = async move {
if is_remote {
match remote_client {
Some(client) => {
match host_request_handle {
Some(handle) => {
apply_edits(
edits,
&session_context,
@@ -70,8 +67,8 @@ impl ApplyDiffModel {
auth_state,
passive_diff,
|path| {
let client = client.clone();
async move { read_remote_file(&client, &path).await }
let handle = &handle;
async move { read_remote_file(handle, &path).await }
},
)
.await
@@ -109,7 +106,7 @@ impl ApplyDiffModel {
const MAX_DIFF_READ_BYTES: u32 = 10_000_000;
async fn read_remote_file(
client: &remote_server::client::RemoteServerClient,
handle: &remote_server::manager::HostRequestHandle,
path: &str,
) -> FileReadResult {
let request = remote_server::proto::ReadFileContextRequest {
@@ -120,7 +117,7 @@ async fn read_remote_file(
max_file_bytes: Some(MAX_DIFF_READ_BYTES),
max_batch_bytes: None,
};
match client.read_file_context(request).await {
match handle.read_file_context(request).await {
Ok(response) => {
if let Some(fc) = response.file_contexts.into_iter().next() {
// A whole-file read that was truncated by the byte limit will
@@ -1,11 +1,10 @@
//! Module containing helper code to apply suggested diffs from an LLM
//! to a set of files on the user's filesystem.
use std::{
collections::{hash_map::Entry, HashMap, HashSet},
future::Future,
sync::Arc,
};
use std::collections::hash_map::Entry;
use std::collections::{HashMap, HashSet};
use std::future::Future;
use std::sync::Arc;
use ai::diff_validation::{
fuzzy_match_diffs, fuzzy_match_v4a_diffs, AIRequestedCodeDiff, DiffDelta, DiffMatchFailures,
@@ -15,20 +14,15 @@ use galaxyui::r#async::executor::Background;
use itertools::Itertools;
use vec1::Vec1;
use crate::{
ai::{
agent::{AIIdentifiers, FileEdit},
blocklist::SessionContext,
paths::host_native_absolute_path,
},
auth::auth_state::AuthState,
safe_debug, safe_warn, send_telemetry_on_executor,
};
use super::telemetry::{
DiffInvalidFileEvent, DiffMatchFailedEvent, MissingLineNumbersEvent,
RequestFileEditsTelemetryEvent,
};
use crate::ai::agent::{AIIdentifiers, FileEdit};
use crate::ai::blocklist::SessionContext;
use crate::ai::paths::host_native_absolute_path;
use crate::auth::auth_state::AuthState;
use crate::{safe_debug, safe_warn, send_telemetry_on_executor};
/// Result of reading a file from disk or a remote server.
///
@@ -355,6 +349,10 @@ where
let v4a_files: HashSet<String> = v4a_deltas.keys().cloned().collect();
let new_file_paths: HashSet<String> = new_files.keys().cloned().collect();
let deleted_file_paths: HashSet<String> = deleted_files.iter().cloned().collect();
let replacement_file_paths: HashSet<String> = new_file_paths
.intersection(&deleted_file_paths)
.cloned()
.collect();
for (file_path, deltas) in search_replace_deltas {
// If a file is also being explicitly created/deleted, skip applying edits to avoid
@@ -391,12 +389,18 @@ where
result
.errors
.push(DiffApplicationError::MultipleFileCreation { file });
} else if replacement_file_paths.contains(&file) {
apply_replace_file(file, content, session_context, read_file, &mut result).await;
} else {
apply_create_file(file, content, session_context, read_file, &mut result).await;
}
}
for file in deleted_files {
if replacement_file_paths.contains(&file) {
continue;
}
if new_file_paths.contains(&file)
|| search_replace_files.contains(&file)
|| v4a_files.contains(&file)
@@ -413,6 +417,62 @@ where
result
}
async fn apply_replace_file<F, Fut>(
file_path: String,
content: String,
session_context: &SessionContext,
read_file: &F,
result: &mut DiffResult,
) where
F: Fn(String) -> Fut,
Fut: Future<Output = FileReadResult>,
{
let absolute_path = host_native_absolute_path(
&file_path,
session_context.shell(),
session_context.current_working_directory(),
);
match read_file(absolute_path.clone()).await {
FileReadResult::Found(file_content) => {
let num_lines = file_content.lines().count();
let replacement_line_range = if num_lines == 0 {
0..0
} else {
1..num_lines.saturating_add(1)
};
result.diffs.push(AIRequestedCodeDiff {
file_name: file_path,
diff_type: DiffType::update(
vec![DiffDelta {
replacement_line_range,
insertion: content,
}],
None,
),
failures: None,
original_content: file_content,
});
}
FileReadResult::NotFound => {
result
.errors
.push(DiffApplicationError::MissingFile { file: file_path });
}
FileReadResult::ReadError(err) => {
safe_warn!(
safe: ("Unable to read file for Agent Code: {err}"),
full: ("Unable to read file {absolute_path:?} for Agent Code: {err}")
);
result.errors.push(DiffApplicationError::ReadFailed {
file: file_path,
message: err,
});
}
}
}
/// Converts a file-creation request into a diff.
async fn apply_create_file<F, Fut>(
file_path: String,
@@ -7,12 +7,11 @@ use galaxyui::App;
use tempfile::NamedTempFile;
use vec1::vec1;
use super::*;
use crate::ai::agent::{AIIdentifiers, FileEdit};
use crate::ai::blocklist::SessionContext;
use crate::auth::auth_state::AuthState;
use super::*;
fn update_deltas(diff: &AIRequestedCodeDiff) -> &[DiffDelta] {
match &diff.diff_type {
DiffType::Update { deltas, .. } => deltas,
@@ -467,6 +466,124 @@ fn test_mixed_create_and_edit_for_same_path() {
});
}
#[test]
fn test_delete_and_create_same_path_replaces_existing_file() {
App::test((), |app| async move {
let mut temp_file = NamedTempFile::new().expect("Failed to create temporary file");
let file_path = temp_file.path().to_string_lossy().to_string();
writeln!(&mut temp_file, "Old line one\nOld line two").unwrap();
let delete_edit = FileEdit::Delete {
file: Some(file_path.clone()),
};
let create_edit = FileEdit::Create {
file: Some(file_path.clone()),
content: Some("New file content".to_string()),
};
let result = apply_edits(
vec![delete_edit, create_edit],
&SessionContext::new_for_test(),
&AIIdentifiers::default(),
app.background_executor(),
Arc::new(AuthState::new_for_test()),
false,
|path| async move { FileReadResult::from(std::fs::read_to_string(path)) },
)
.await;
assert!(result.is_ok(), "Expected Ok result but got: {result:?}");
let diffs = result.unwrap();
assert_eq!(diffs.len(), 1);
assert_eq!(diffs[0].file_name, file_path);
assert_eq!(diffs[0].original_content, "Old line one\nOld line two\n");
let deltas = update_deltas(&diffs[0]);
assert_eq!(deltas.len(), 1);
assert_eq!(deltas[0].replacement_line_range, 1..3);
assert_eq!(deltas[0].insertion, "New file content");
});
}
#[test]
fn test_create_then_delete_same_path_replaces_existing_file() {
App::test((), |app| async move {
let mut temp_file = NamedTempFile::new().expect("Failed to create temporary file");
let file_path = temp_file.path().to_string_lossy().to_string();
writeln!(&mut temp_file, "Old line one\nOld line two").unwrap();
let create_edit = FileEdit::Create {
file: Some(file_path.clone()),
content: Some("New file content".to_string()),
};
let delete_edit = FileEdit::Delete {
file: Some(file_path.clone()),
};
let result = apply_edits(
vec![create_edit, delete_edit],
&SessionContext::new_for_test(),
&AIIdentifiers::default(),
app.background_executor(),
Arc::new(AuthState::new_for_test()),
false,
|path| async move { FileReadResult::from(std::fs::read_to_string(path)) },
)
.await;
assert!(result.is_ok(), "Expected Ok result but got: {result:?}");
let diffs = result.unwrap();
assert_eq!(diffs.len(), 1);
assert_eq!(diffs[0].file_name, file_path);
let deltas = update_deltas(&diffs[0]);
assert_eq!(deltas.len(), 1);
assert_eq!(deltas[0].replacement_line_range, 1..3);
assert_eq!(deltas[0].insertion, "New file content");
});
}
#[test]
fn test_delete_create_and_edit_same_path_still_fails() {
App::test((), |app| async move {
let mut temp_file = NamedTempFile::new().expect("Failed to create temporary file");
let file_path = temp_file.path().to_string_lossy().to_string();
writeln!(&mut temp_file, "Existing content").unwrap();
let delete_edit = FileEdit::Delete {
file: Some(file_path.clone()),
};
let create_edit = FileEdit::Create {
file: Some(file_path.clone()),
content: Some("New file content".to_string()),
};
let edit_diff = ParsedDiff::StrReplaceEdit {
file: Some(file_path.clone()),
search: Some("1|Existing content".to_string()),
replace: Some("Modified content".to_string()),
};
let result = apply_edits(
vec![delete_edit, create_edit, FileEdit::Edit(edit_diff)],
&SessionContext::new_for_test(),
&AIIdentifiers::default(),
app.background_executor(),
Arc::new(AuthState::new_for_test()),
false,
|path| async move { FileReadResult::from(std::fs::read_to_string(path)) },
)
.await;
let errors = result.expect_err("Expected an error due to create/edit/delete same path");
match &errors[..] {
[DiffApplicationError::MultipleFileCreation { file }] => {
assert_eq!(*file, file_path);
}
other => panic!("Expected a single MultipleFileCreation error, got {other:?}"),
}
});
}
#[test]
fn test_create_edit_for_existing_file() {
App::test((), |app| async move {
@@ -8,7 +8,8 @@ use serde::Serialize;
use serde_json::json;
use strum_macros::{EnumDiscriminants, EnumIter};
use crate::ai::{agent::AIIdentifiers, blocklist::RequestedEditResolution};
use crate::ai::agent::AIIdentifiers;
use crate::ai::blocklist::RequestedEditResolution;
/// Telemetry events associated with the `RequestFileEdits` AI agent action.
#[derive(Serialize, Debug, EnumDiscriminants)]
@@ -0,0 +1,63 @@
use std::collections::HashMap;
use ai::agent::action_result::AnyFileContent;
use ai::agent::FileLocations;
use super::updated_file_contexts_from_editor_buffers;
#[test]
fn updated_file_contexts_from_editor_buffers_returns_changed_lines_with_context() {
let updated_files = vec![(
FileLocations {
name: "src/main.rs".to_string(),
lines: std::iter::once(12..13).collect(),
},
true,
)];
let content = (1..=30)
.map(|line| format!("line {line}"))
.collect::<Vec<_>>()
.join("\n");
let content_map = HashMap::from([("src/main.rs".to_string(), content)]);
let contexts = updated_file_contexts_from_editor_buffers(&updated_files, &content_map);
assert_eq!(contexts.len(), 1);
assert!(contexts[0].was_edited_by_user);
assert_eq!(contexts[0].file_context.file_name, "src/main.rs");
assert_eq!(contexts[0].file_context.line_range, Some(2..23));
assert_eq!(contexts[0].file_context.line_count, 30);
assert_eq!(
contexts[0].file_context.content,
AnyFileContent::StringContent(
(2..=22)
.map(|line| format!("line {line}"))
.collect::<Vec<_>>()
.join("\n")
)
);
}
#[test]
fn updated_file_contexts_from_editor_buffers_preserves_full_file_when_no_ranges() {
let updated_files = vec![(
FileLocations {
name: "src/main.rs".to_string(),
lines: vec![],
},
false,
)];
let content = "line 1\nline 2\n".to_string();
let content_map = HashMap::from([("src/main.rs".to_string(), content.clone())]);
let contexts = updated_file_contexts_from_editor_buffers(&updated_files, &content_map);
assert_eq!(contexts.len(), 1);
assert!(!contexts[0].was_edited_by_user);
assert_eq!(contexts[0].file_context.line_range, None);
assert_eq!(contexts[0].file_context.line_count, 2);
assert_eq!(
contexts[0].file_context.content,
AnyFileContent::StringContent(content)
);
}
@@ -0,0 +1,788 @@
//! Async executor for `AIAgentActionType::RunAgents`.
//!
//! Fans out per-child via [`super::start_agent::StartAgentExecutor::dispatch`]
//! and aggregates the outcomes into a single `RunAgentsResult`.
use std::collections::HashMap;
use std::time::Duration;
use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest};
use ai::agent::action_result::{
RunAgentsAgentOutcome, RunAgentsAgentOutcomeKind, RunAgentsLaunchedExecutionMode,
RunAgentsResult,
};
use ai::agent::orchestration_config::OrchestrationConfig;
use ai::skills::SkillReference;
use futures::future::BoxFuture;
use futures::FutureExt;
use settings::Setting;
use warp_cli::agent::Harness;
use galaxy_core::execution_mode::AppExecutionMode;
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use super::start_agent::{StartAgentExecutor, StartAgentOutcome};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, AIAgentInput,
StartAgentExecutionMode,
};
use crate::ai::auth_secret_types::auth_secret_types_for_harness;
use crate::ai::blocklist::inline_action::orchestration_controls::OrchestrationEditState;
use crate::ai::blocklist::{BlocklistAIHistoryModel, BlocklistAIPermissions};
use crate::ai::cloud_agent_settings::CloudAgentSettings;
use crate::ai::document::plan_publication::{
prepare_plan_publications, wait_for_plan_publications,
};
use crate::ai::local_harness_setup::local_harness_product_disabled_message;
/// Per-child spawn timeout. If a child agent doesn't report back within
/// this window (e.g. binary not found, server error), the slot is failed
/// rather than hanging the "Spawning agents" UI indefinitely.
const SPAWN_TIMEOUT: Duration = Duration::from_secs(30);
/// Snapshot of an in-flight dispatch, carried through
/// [`RunAgentsExecutorEvent::SpawningStarted`].
#[derive(Debug, Clone, Copy)]
pub struct RunAgentsSpawningSnapshot {
pub agent_count: usize,
}
/// In-flight tracking per `RunAgents` action (idempotency guard).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PendingRunAgents {
Publishing,
Spawning,
}
#[derive(Debug, Clone)]
struct ExistingLaunchedAgent {
name: String,
agent_id: String,
}
pub struct RunAgentsExecutor {
pending: HashMap<AIAgentActionId, PendingRunAgents>,
launched_agents: HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>,
start_agent_executor: ModelHandle<StartAgentExecutor>,
terminal_view_id: EntityId,
}
/// Lifecycle events for in-flight dispatches.
pub enum RunAgentsExecutorEvent {
SpawningStarted {
action_id: AIAgentActionId,
snapshot: RunAgentsSpawningSnapshot,
},
SpawningFinished {
action_id: AIAgentActionId,
},
}
impl Entity for RunAgentsExecutor {
type Event = RunAgentsExecutorEvent;
}
impl RunAgentsExecutor {
pub fn new(
start_agent_executor: ModelHandle<StartAgentExecutor>,
terminal_view_id: EntityId,
) -> Self {
Self {
pending: HashMap::new(),
launched_agents: HashMap::new(),
start_agent_executor,
terminal_view_id,
}
}
pub fn is_pending(&self, action_id: &AIAgentActionId) -> bool {
self.pending.contains_key(action_id)
}
/// Cancels a pending run so publication completion cannot fan out children.
pub(super) fn cancel_execution(
&mut self,
action_id: &AIAgentActionId,
ctx: &mut ModelContext<Self>,
) {
if matches!(
self.pending.get(action_id),
Some(PendingRunAgents::Publishing)
) {
self.pending.remove(action_id);
ctx.emit(RunAgentsExecutorEvent::SpawningFinished {
action_id: action_id.clone(),
});
}
}
fn record_launched_agents(
&mut self,
conversation_id: AIConversationId,
agents: &[RunAgentsAgentOutcome],
) {
for agent in agents {
let RunAgentsAgentOutcomeKind::Launched { agent_id } = &agent.kind else {
continue;
};
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
continue;
};
self.launched_agents
.entry(conversation_id)
.or_default()
.insert(
normalized_name,
ExistingLaunchedAgent {
name: agent.name.clone(),
agent_id: agent_id.clone(),
},
);
}
}
fn duplicate_launched_agents_reason(
&self,
request: &RunAgentsRequest,
parent_conversation_id: AIConversationId,
ctx: &ModelContext<Self>,
) -> Option<String> {
duplicate_launched_agents_reason(
request,
parent_conversation_id,
&self.launched_agents,
ctx,
)
}
/// Publishes parent plans and dispatches children after a bounded best-effort wait.
fn dispatch_prepared_run_agents(
&mut self,
action_id: AIAgentActionId,
request: RunAgentsRequest,
parent_conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) -> async_channel::Receiver<RunAgentsResult> {
let (sender, receiver) = async_channel::bounded(1);
if self.pending.contains_key(&action_id) {
log::warn!("RunAgentsExecutor: dispatch reentered for {action_id:?}; rejecting");
let _ = sender.try_send(RunAgentsResult::Cancelled);
return receiver;
}
if let Err(error) = validate_request(&request) {
log::warn!("RunAgentsExecutor: validation failure: {error}");
let _ = sender.try_send(RunAgentsResult::Failure { error });
return receiver;
}
let pending_plan_publications = prepare_plan_publications(parent_conversation_id, ctx);
let snapshot = RunAgentsSpawningSnapshot {
agent_count: request.agent_run_configs.len(),
};
self.pending
.insert(action_id.clone(), PendingRunAgents::Publishing);
ctx.emit(RunAgentsExecutorEvent::SpawningStarted {
action_id: action_id.clone(),
snapshot,
});
let action_id_for_wait = action_id.clone();
ctx.spawn(
async move {
// Wait briefly for each plan to become server-backed without blocking
// launch on a failed or slow publication. Resolves immediately when
// there is nothing to wait on.
wait_for_plan_publications(pending_plan_publications).await;
request
},
move |me, request, ctx| {
if !me.is_pending(&action_id_for_wait) {
return;
}
me.dispatch_children_for_prepared_request(
action_id_for_wait.clone(),
request,
parent_conversation_id,
sender,
ctx,
)
},
);
receiver
}
fn dispatch_children_for_prepared_request(
&mut self,
action_id: AIAgentActionId,
request: RunAgentsRequest,
parent_conversation_id: AIConversationId,
sender: async_channel::Sender<RunAgentsResult>,
ctx: &mut ModelContext<Self>,
) {
self.pending
.insert(action_id.clone(), PendingRunAgents::Spawning);
let parent_run_id = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&parent_conversation_id)
.and_then(|c| c.run_id());
let RunAgentsRequest {
execution_mode: run_execution_mode,
harness_type,
model_id,
skills,
agent_run_configs,
base_prompt,
harness_auth_secret_name,
..
} = request;
let mut slots: Vec<ChildSlot> = Vec::with_capacity(agent_run_configs.len());
for cfg in &agent_run_configs {
let prompt = compose_run_agents_child_prompt(&base_prompt, &cfg.prompt);
let mode = match run_agents_to_start_agent_mode(
&run_execution_mode,
&harness_type,
&model_id,
&skills,
harness_auth_secret_name.as_deref(),
cfg,
) {
Ok(mode) => mode,
Err(err) => {
slots.push(ChildSlot::Failed(err));
continue;
}
};
if matches!(run_execution_mode, RunAgentsExecutionMode::Remote { .. })
&& parent_run_id.is_none()
{
slots.push(ChildSlot::Failed(
"Remote child agents require the parent run_id to be available.".to_string(),
));
continue;
}
let recv = self.start_agent_executor.update(ctx, |executor, exec_ctx| {
executor.dispatch(
cfg.name.clone(),
prompt,
mode,
None, /* lifecycle_subscription */
parent_conversation_id,
parent_run_id.clone(),
exec_ctx,
)
});
slots.push(ChildSlot::Pending(recv));
}
let agent_run_configs_for_result = agent_run_configs.clone();
let action_id_for_aggr = action_id.clone();
let run_model_id = model_id.clone();
let run_harness_type = harness_type.clone();
let run_execution_mode_for_aggr = run_execution_mode.clone();
let parent_conversation_id_for_result = parent_conversation_id;
ctx.spawn(
async move {
let mut outcomes: Vec<RunAgentsAgentOutcomeKind> = Vec::with_capacity(slots.len());
for slot in slots {
let kind = match slot {
ChildSlot::Failed(error) => RunAgentsAgentOutcomeKind::Failed { error },
ChildSlot::Pending(recv) => {
let timeout = warpui::r#async::Timer::after(SPAWN_TIMEOUT);
match futures::future::select(Box::pin(recv.recv()), Box::pin(timeout))
.await
{
futures::future::Either::Left((
Ok(StartAgentOutcome::Started { agent_id }),
_,
)) => RunAgentsAgentOutcomeKind::Launched { agent_id },
futures::future::Either::Left((
Ok(StartAgentOutcome::Error(error)),
_,
)) => RunAgentsAgentOutcomeKind::Failed { error },
futures::future::Either::Left((Err(_), _)) => {
RunAgentsAgentOutcomeKind::Failed {
error: "Cancelled before launch".to_string(),
}
}
futures::future::Either::Right((_, _)) => {
log::warn!(
"Agent spawn timed out after {} seconds",
SPAWN_TIMEOUT.as_secs()
);
RunAgentsAgentOutcomeKind::Failed {
error: format!(
"Agent failed to start within {} seconds. \
The harness binary may not be installed.",
SPAWN_TIMEOUT.as_secs()
),
}
}
}
}
};
outcomes.push(kind);
}
outcomes
},
move |me, outcomes, ctx| {
let agents: Vec<RunAgentsAgentOutcome> = agent_run_configs_for_result
.iter()
.zip(outcomes)
.map(|(cfg, kind)| RunAgentsAgentOutcome {
name: cfg.name.clone(),
kind,
})
.collect();
me.record_launched_agents(parent_conversation_id_for_result, &agents);
let launched_mode = match &run_execution_mode_for_aggr {
RunAgentsExecutionMode::Local => RunAgentsLaunchedExecutionMode::Local,
RunAgentsExecutionMode::Remote {
environment_id,
worker_host,
computer_use_enabled,
} => RunAgentsLaunchedExecutionMode::Remote {
environment_id: environment_id.clone(),
worker_host: worker_host.clone(),
computer_use_enabled: *computer_use_enabled,
},
};
let result = RunAgentsResult::Launched {
model_id: run_model_id,
harness_type: run_harness_type,
execution_mode: launched_mode,
agents,
};
me.pending.remove(&action_id_for_aggr);
ctx.emit(RunAgentsExecutorEvent::SpawningFinished {
action_id: action_id_for_aggr,
});
let _ = sender.try_send(result);
},
);
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let AIAgentAction { action, id, .. } = input.action;
let AIAgentActionType::RunAgents(request) = action else {
return ActionExecution::InvalidAction;
};
let mut request = request.clone();
let action_id = id.clone();
let parent_conversation_id = input.conversation_id;
if let Some(reason) = prepare_request_for_execution(
&mut request,
parent_conversation_id,
self.terminal_view_id,
&self.launched_agents,
ctx,
) {
return ActionExecution::Sync(AIAgentActionResultType::RunAgents(
RunAgentsResult::Denied { reason },
));
}
let receiver =
self.dispatch_prepared_run_agents(action_id, request, parent_conversation_id, ctx);
ActionExecution::new_async(
async move { receiver.recv().await },
|result, _| match result {
Ok(r) => AIAgentActionResultType::RunAgents(r),
Err(_) => AIAgentActionResultType::RunAgents(RunAgentsResult::Cancelled),
},
)
}
pub(super) fn should_autoexecute(
&self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> bool {
let AIAgentActionType::RunAgents(request) = &input.action.action else {
return false;
};
if AppExecutionMode::as_ref(ctx).is_autonomous() {
return true;
}
let mut resolved_request = request.clone();
resolve_request_from_approved_config(&mut resolved_request, input.conversation_id, ctx);
populate_default_auth_secret_for_execution(&mut resolved_request, ctx);
if self
.duplicate_launched_agents_reason(&resolved_request, input.conversation_id, ctx)
.is_some()
{
return true;
}
approved_orchestration_config_can_autoexecute(request, input.conversation_id, ctx)
|| BlocklistAIPermissions::as_ref(ctx)
.get_run_agents_setting(ctx, Some(self.terminal_view_id))
.is_always_allow()
}
pub(super) fn preprocess_action(
&mut self,
_action: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
}
#[cfg(test)]
#[path = "run_agents_tests.rs"]
mod tests;
enum ChildSlot {
Failed(String),
Pending(async_channel::Receiver<StartAgentOutcome>),
}
fn approved_orchestration_config_can_autoexecute(
request: &RunAgentsRequest,
parent_conversation_id: AIConversationId,
ctx: &ModelContext<RunAgentsExecutor>,
) -> bool {
let mut resolved_request = request.clone();
resolve_request_from_approved_config(&mut resolved_request, parent_conversation_id, ctx)
.is_some_and(|status| status.is_approved())
&& can_execute_with_auth_secret(&resolved_request, ctx)
}
fn resolve_request_from_approved_config(
request: &mut RunAgentsRequest,
parent_conversation_id: AIConversationId,
ctx: &ModelContext<RunAgentsExecutor>,
) -> Option<ai::agent::orchestration_config::OrchestrationConfigStatus> {
let conversation =
BlocklistAIHistoryModel::as_ref(ctx).conversation(&parent_conversation_id)?;
let (config, status) = conversation.orchestration_config_for_plan(&request.plan_id)?;
if status.is_approved() {
resolve_request_from_config(request, config);
}
Some(status)
}
/// Normalizes the request and returns a denial reason when launch is blocked.
///
/// Autonomous agents always run: their calls may still inherit approved plan
/// config fields and default auth secrets, but they bypass interactive policy
/// denials because they cannot present a confirmation card.
fn prepare_request_for_execution(
request: &mut RunAgentsRequest,
parent_conversation_id: AIConversationId,
terminal_view_id: EntityId,
launched_agents: &HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>,
ctx: &ModelContext<RunAgentsExecutor>,
) -> Option<String> {
let status = resolve_request_from_approved_config(request, parent_conversation_id, ctx);
populate_default_auth_secret_for_execution(request, ctx);
if let Some(reason) =
duplicate_launched_agents_reason(request, parent_conversation_id, launched_agents, ctx)
{
return Some(reason);
}
if AppExecutionMode::as_ref(ctx).is_autonomous() {
return None;
}
if status.is_some_and(|status| status.is_disapproved()) {
return Some("Orchestration config was disapproved".to_string());
}
if BlocklistAIPermissions::as_ref(ctx)
.get_run_agents_setting(ctx, Some(terminal_view_id))
.is_never_allow()
{
return Some(
"Running child agents is disabled by the active execution profile.".to_string(),
);
}
if !can_execute_with_auth_secret(request, ctx) {
return Some(
"Cloud child agents using this harness require an API key before they can run."
.to_string(),
);
}
None
}
fn duplicate_launched_agents_reason(
request: &RunAgentsRequest,
parent_conversation_id: AIConversationId,
launched_agents: &HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>,
ctx: &ModelContext<RunAgentsExecutor>,
) -> Option<String> {
let requested_agents = request
.agent_run_configs
.iter()
.map(|cfg| normalize_agent_name(&cfg.name).map(|name| (name, cfg.name.clone())))
.collect::<Option<Vec<_>>>()?;
if requested_agents.is_empty() {
return None;
}
let existing_agents =
existing_launched_agents_for_conversation(parent_conversation_id, launched_agents, ctx);
if existing_agents.is_empty() {
return None;
}
let duplicates = requested_agents
.iter()
.map(|(normalized_name, _)| existing_agents.get(normalized_name))
.collect::<Option<Vec<_>>>()?;
let duplicate_list = duplicates
.iter()
.map(|agent| format!("{} ({})", agent.name, agent.agent_id))
.collect::<Vec<_>>()
.join(", ");
let addresses = duplicates
.iter()
.map(|agent| agent.agent_id.as_str())
.collect::<Vec<_>>()
.join(", ");
Some(format!(
"Requested agent(s) have already been launched: {duplicate_list}. \
Do not start duplicate child agents; send any follow-up with send_message_to_agent \
using the existing agent id(s): {addresses}."
))
}
fn existing_launched_agents_for_conversation(
parent_conversation_id: AIConversationId,
launched_agents: &HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>,
ctx: &ModelContext<RunAgentsExecutor>,
) -> HashMap<String, ExistingLaunchedAgent> {
let mut existing_agents = launched_agents
.get(&parent_conversation_id)
.cloned()
.unwrap_or_default();
if let Some(conversation) =
BlocklistAIHistoryModel::as_ref(ctx).conversation(&parent_conversation_id)
{
for exchange in conversation.all_exchanges() {
for input in &exchange.input {
let AIAgentInput::ActionResult { result, .. } = input else {
continue;
};
let AIAgentActionResultType::RunAgents(RunAgentsResult::Launched {
agents, ..
}) = &result.result
else {
continue;
};
for agent in agents {
let RunAgentsAgentOutcomeKind::Launched { agent_id } = &agent.kind else {
continue;
};
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
continue;
};
existing_agents.entry(normalized_name).or_insert_with(|| {
ExistingLaunchedAgent {
name: agent.name.clone(),
agent_id: agent_id.clone(),
}
});
}
}
}
}
existing_agents
}
fn normalize_agent_name(name: &str) -> Option<String> {
let trimmed = name.trim();
(!trimmed.is_empty()).then(|| trimmed.to_ascii_lowercase())
}
fn requires_default_auth_secret_for_execution(request: &RunAgentsRequest) -> bool {
if !request.execution_mode.is_remote() {
return false;
}
let Some(harness) = Harness::parse_orchestration_harness(&request.harness_type) else {
return false;
};
harness != Harness::Oz && !auth_secret_types_for_harness(harness).is_empty()
}
fn can_execute_with_auth_secret(
request: &RunAgentsRequest,
ctx: &ModelContext<RunAgentsExecutor>,
) -> bool {
if !requires_default_auth_secret_for_execution(request) {
return true;
}
if request
.harness_auth_secret_name
.as_deref()
.is_some_and(|name| !name.trim().is_empty())
{
return true;
}
default_auth_secret_name_for_harness(&request.harness_type, ctx).is_some()
}
fn default_auth_secret_name_for_harness(
harness_type: &str,
ctx: &ModelContext<RunAgentsExecutor>,
) -> Option<String> {
let harness = Harness::parse_orchestration_harness(harness_type)?;
if harness == Harness::Oz {
return None;
}
CloudAgentSettings::as_ref(ctx)
.last_selected_auth_secret
.value()
.get(harness.config_name())
.cloned()
.filter(|name| !name.trim().is_empty())
}
fn populate_default_auth_secret_for_execution(
request: &mut RunAgentsRequest,
ctx: &ModelContext<RunAgentsExecutor>,
) {
if !requires_default_auth_secret_for_execution(request)
|| request
.harness_auth_secret_name
.as_deref()
.is_some_and(|name| !name.trim().is_empty())
{
return;
}
request.harness_auth_secret_name =
default_auth_secret_name_for_harness(&request.harness_type, ctx);
}
/// Unconditionally overrides run-wide fields on a `RunAgentsRequest`
/// from the approved orchestration config, delegating to
/// `OrchestrationEditState::override_from_approved_config`.
fn resolve_request_from_config(request: &mut RunAgentsRequest, config: &OrchestrationConfig) {
// The approved plan config is the source of truth for these run-wide fields,
// so callers pass a mutable request and continue with the normalized value.
let mut edit_state = OrchestrationEditState::from_run_agents_fields(
&request.model_id,
&request.harness_type,
&request.execution_mode,
);
edit_state.override_from_approved_config(config);
request.model_id = edit_state.model_id;
request.harness_type = edit_state.harness_type;
request.execution_mode = edit_state.execution_mode;
}
/// Defence-in-depth validation; mirrors the card view's
/// `accept_disabled_reason` check.
fn validate_request(request: &RunAgentsRequest) -> Result<(), String> {
if request.agent_run_configs.is_empty() {
return Err("orchestrate: empty agent_run_configs".to_string());
}
if matches!(request.execution_mode, RunAgentsExecutionMode::Local) {
if let Some(harness) = Harness::parse_local_child_harness(&request.harness_type) {
if let Some(message) = local_harness_product_disabled_message(harness) {
return Err(message.to_string());
}
}
}
if matches!(
request.execution_mode,
RunAgentsExecutionMode::Remote { .. }
) && request.harness_type.eq_ignore_ascii_case("opencode")
{
return Err("Remote child agents do not support the opencode harness yet.".to_string());
}
Ok(())
}
/// Joins `base_prompt` and a per-agent prompt with `"\n\n"`,
/// falling back to whichever is non-empty.
pub fn compose_run_agents_child_prompt(base_prompt: &str, per_agent_prompt: &str) -> String {
let base_trimmed = base_prompt.trim();
let per_agent_trimmed = per_agent_prompt.trim();
match (base_trimmed.is_empty(), per_agent_trimmed.is_empty()) {
(false, false) => format!("{base_prompt}\n\n{per_agent_prompt}"),
(false, true) => base_prompt.to_string(),
(true, false) => per_agent_prompt.to_string(),
(true, true) => String::new(),
}
}
/// Translates run-wide config into a per-child
/// [`StartAgentExecutionMode`]. Returns `Err` for rejected
/// combinations (e.g. OpenCode+Remote).
///
/// `run_auth_secret_name` is the managed-secret name the orchestration UI
/// resolved for the run-wide harness; only Remote mode currently consumes
/// it (Local children inherit auth from the user's shell environment).
pub fn run_agents_to_start_agent_mode(
run_execution_mode: &RunAgentsExecutionMode,
run_harness_type: &str,
run_model_id: &str,
run_skills: &[SkillReference],
run_auth_secret_name: Option<&str>,
cfg: &RunAgentsAgentRunConfig,
) -> Result<StartAgentExecutionMode, String> {
match run_execution_mode {
RunAgentsExecutionMode::Local => {
let trimmed = run_harness_type.trim();
// Propagate run-wide model selection for local launches.
let trimmed_model_id = run_model_id.trim();
let model_id = (!trimmed_model_id.is_empty()).then(|| trimmed_model_id.to_string());
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("oz") {
Ok(StartAgentExecutionMode::Local {
harness_type: None,
model_id,
})
} else {
if let Some(harness) = Harness::parse_local_child_harness(trimmed) {
if let Some(message) = local_harness_product_disabled_message(harness) {
return Err(message.to_string());
}
}
Ok(StartAgentExecutionMode::Local {
harness_type: Some(trimmed.to_string()),
model_id,
})
}
}
RunAgentsExecutionMode::Remote {
environment_id,
worker_host,
computer_use_enabled,
} => {
// OpenCode is unsupported on Remote.
if run_harness_type.eq_ignore_ascii_case("opencode") {
return Err(
"Remote child agents do not support the opencode harness yet.".to_string(),
);
}
Ok(StartAgentExecutionMode::Remote {
environment_id: environment_id.clone(),
skill_references: run_skills.to_vec(),
model_id: run_model_id.to_string(),
computer_use_enabled: *computer_use_enabled,
worker_host: worker_host.clone(),
harness_type: run_harness_type.to_string(),
title: cfg.title.clone(),
auth_secret_name: run_auth_secret_name
.map(str::to_string)
.filter(|s| !s.trim().is_empty()),
})
}
}
}
@@ -0,0 +1,732 @@
use std::collections::HashMap;
use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest};
use ai::agent::orchestration_config::{
OrchestrationConfig, OrchestrationConfigStatus, OrchestrationExecutionMode,
};
use settings::Setting;
use galaxy_core::execution_mode::ExecutionMode;
use galaxy_core::features::FeatureFlag;
use warpui::{App, Entity, EntityId, ModelHandle};
use super::*;
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
use crate::ai::agent::task::TaskId;
use crate::ai::blocklist::{
BlocklistAIHistoryModel, BlocklistAIPermissions, StartAgentExecutorEvent, StartAgentRequest,
};
use crate::ai::cloud_agent_settings::CloudAgentSettings;
use crate::ai::document::ai_document_model::{AIDocumentModel, AIDocumentSaveStatus};
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
use crate::ai::execution_profiles::RunAgentsPermission;
use crate::ai::mcp::templatable_manager::TemplatableMCPServerManager;
use crate::appearance::Appearance;
use crate::auth::AuthStateProvider;
use crate::cloud_object::model::persistence::CloudModel;
use crate::network::NetworkStatus;
use crate::server::cloud_objects::update_manager::UpdateManager;
use crate::server::ids::SyncId;
use crate::server::sync_queue::SyncQueue;
use crate::settings::PrivacySettings;
use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel;
use crate::test_util::settings::initialize_settings_for_tests_with_mode;
use crate::workspaces::team_tester::TeamTesterStatus;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::{
AgentNotificationsModel, GlobalResourceHandles, GlobalResourceHandlesProvider, LaunchMode,
};
struct RunAgentsTestState {
conversation_id: AIConversationId,
executor: ModelHandle<RunAgentsExecutor>,
start_agent_executor: ModelHandle<StartAgentExecutor>,
}
#[derive(Default)]
struct CapturedStartAgentRequests(Vec<StartAgentRequest>);
impl Entity for CapturedStartAgentRequests {
type Event = ();
}
fn with_plan_id(mut action: AIAgentAction, plan_id: &str) -> AIAgentAction {
let AIAgentActionType::RunAgents(request) = &mut action.action else {
panic!("expected run_agents action");
};
request.plan_id = plan_id.to_string();
action
}
fn persist_plan_config(
app: &mut App,
conversation_id: AIConversationId,
plan_id: &str,
status: OrchestrationConfigStatus,
) {
persist_plan_config_with_harness(app, conversation_id, plan_id, "oz", status);
}
fn persist_plan_config_with_harness(
app: &mut App,
conversation_id: AIConversationId,
plan_id: &str,
harness_type: &str,
status: OrchestrationConfigStatus,
) {
BlocklistAIHistoryModel::handle(app).update(app, |history, _ctx| {
history
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.set_orchestration_config_for_plan(
plan_id.to_string(),
OrchestrationConfig {
model_id: "auto".to_string(),
harness_type: harness_type.to_string(),
execution_mode: OrchestrationExecutionMode::Remote {
environment_id: "env-1".to_string(),
worker_host: "warp".to_string(),
},
},
status,
);
});
}
#[test]
fn should_autoexecute_duplicate_launched_agent_denial() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
state.executor.update(&mut app, |executor, _ctx| {
executor.record_launched_agents(
state.conversation_id,
&[RunAgentsAgentOutcome {
name: "child".to_string(),
kind: RunAgentsAgentOutcomeKind::Launched {
agent_id: "agent-123".to_string(),
},
}],
);
});
let action = remote_run_agents_action("oz");
let should_autoexecute = state.executor.update(&mut app, |executor, ctx| {
executor.should_autoexecute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
});
assert!(should_autoexecute);
});
}
#[test]
fn execute_denies_duplicate_launched_agent() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
state.executor.update(&mut app, |executor, _ctx| {
executor.record_launched_agents(
state.conversation_id,
&[RunAgentsAgentOutcome {
name: "child".to_string(),
kind: RunAgentsAgentOutcomeKind::Launched {
agent_id: "agent-123".to_string(),
},
}],
);
});
let action = with_agent_name(remote_run_agents_action("oz"), "Child");
let execution = state.executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
.into()
});
let AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(RunAgentsResult::Denied {
reason,
})) = execution
else {
panic!("expected synchronous run_agents denial");
};
assert!(reason.contains("child (agent-123)"));
assert!(reason.contains("send_message_to_agent"));
});
}
fn initialize_run_agents_test(app: &mut App, mode: ExecutionMode) -> RunAgentsTestState {
initialize_settings_for_tests_with_mode(app, mode, false);
let global_resource_handles = GlobalResourceHandles::mock(app);
app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resource_handles));
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[]));
app.add_singleton_model(|_| CLIAgentSessionsModel::new());
app.add_singleton_model(|_| ActiveAgentViewsModel::new());
app.add_singleton_model(AgentNotificationsModel::new);
app.add_singleton_model(BlocklistAIPermissions::new);
let terminal_view_id = EntityId::new();
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
app.add_singleton_model(SyncQueue::mock);
app.add_singleton_model(|_| NetworkStatus::new());
app.add_singleton_model(TeamTesterStatus::mock);
app.add_singleton_model(UpdateManager::mock);
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(|_| Appearance::mock());
app.add_singleton_model(|_| AIDocumentModel::new_for_test());
app.add_singleton_model(|_| TemplatableMCPServerManager::default());
app.add_singleton_model(|ctx| {
AIExecutionProfilesModel::new(&LaunchMode::new_for_unit_test(), ctx)
});
app.add_singleton_model(PrivacySettings::mock);
app.add_singleton_model(UserWorkspaces::default_mock);
let conversation_id = history.update(app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let start_agent_executor = app.add_model(StartAgentExecutor::new);
let executor =
app.add_model(|_| RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id));
RunAgentsTestState {
conversation_id,
executor,
start_agent_executor,
}
}
fn subscribe_to_start_agent_requests(
app: &mut App,
start_agent_executor: &ModelHandle<StartAgentExecutor>,
) -> ModelHandle<CapturedStartAgentRequests> {
let captured = app.add_model(|_| CapturedStartAgentRequests::default());
captured.update(app, |_, ctx| {
ctx.subscribe_to_model(start_agent_executor, |captured, _, event, _ctx| {
if let StartAgentExecutorEvent::CreateAgent(request) = event {
captured.0.push(request.as_ref().clone());
}
});
});
captured
}
fn remote_run_agents_action(harness_type: &str) -> AIAgentAction {
AIAgentAction {
id: AIAgentActionId::from("run-agents-action".to_string()),
task_id: TaskId::new("run-agents-task".to_string()),
requires_result: true,
action: AIAgentActionType::RunAgents(RunAgentsRequest {
summary: "Run child agent".to_string(),
base_prompt: "Help".to_string(),
skills: vec![],
model_id: String::new(),
harness_type: harness_type.to_string(),
execution_mode: RunAgentsExecutionMode::Remote {
environment_id: "env-1".to_string(),
worker_host: "warp".to_string(),
computer_use_enabled: false,
},
agent_run_configs: vec![RunAgentsAgentRunConfig {
name: "child".to_string(),
prompt: "Help".to_string(),
title: String::new(),
}],
plan_id: String::new(),
harness_auth_secret_name: None,
}),
}
}
fn with_agent_name(mut action: AIAgentAction, name: &str) -> AIAgentAction {
let AIAgentActionType::RunAgents(request) = &mut action.action else {
panic!("expected run_agents action");
};
request.agent_run_configs[0].name = name.to_string();
action
}
#[test]
fn local_codex_run_agents_maps_to_local_harness_mode_when_flag_enabled() {
let _local_codex = FeatureFlag::LocalClaudeCodexChildHarnesses.override_enabled(true);
let cfg = RunAgentsAgentRunConfig {
name: "child".to_string(),
prompt: "Investigate the failure".to_string(),
title: String::new(),
};
let mode = run_agents_to_start_agent_mode(
&RunAgentsExecutionMode::Local,
"codex",
"",
&[],
None,
&cfg,
)
.expect("local Codex should be accepted when the feature flag is enabled");
assert_eq!(
mode,
StartAgentExecutionMode::Local {
harness_type: Some("codex".to_string()),
model_id: None,
}
);
}
fn persist_default_auth_secret(app: &mut App, harness_config_name: &str, secret_name: &str) {
CloudAgentSettings::handle(app).update(app, |settings, ctx| {
let mut secrets = settings.last_selected_auth_secret.value().clone();
secrets.insert(harness_config_name.to_string(), secret_name.to_string());
settings
.last_selected_auth_secret
.set_value(secrets, ctx)
.unwrap();
settings
.inherit_auth_secret_harnesses
.set_value(HashMap::new(), ctx)
.unwrap();
});
}
#[test]
fn should_autoexecute_when_plan_has_approved_orchestration_config() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
persist_plan_config(
&mut app,
state.conversation_id,
"plan-1",
OrchestrationConfigStatus::Approved,
);
let action = with_plan_id(remote_run_agents_action("oz"), "plan-1");
let should_autoexecute = state.executor.update(&mut app, |executor, ctx| {
executor.should_autoexecute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
});
assert!(should_autoexecute);
});
}
#[test]
fn should_not_autoexecute_approved_remote_non_warp_plan_without_default_auth_secret() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
persist_plan_config_with_harness(
&mut app,
state.conversation_id,
"plan-1",
"codex",
OrchestrationConfigStatus::Approved,
);
let action = with_plan_id(remote_run_agents_action("oz"), "plan-1");
let should_autoexecute = state.executor.update(&mut app, |executor, ctx| {
executor.should_autoexecute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
});
assert!(!should_autoexecute);
});
}
#[test]
fn execute_denies_disapproved_plan_config() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
persist_plan_config(
&mut app,
state.conversation_id,
"plan-1",
OrchestrationConfigStatus::Disapproved,
);
let action = with_plan_id(remote_run_agents_action("oz"), "plan-1");
let execution = state.executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
.into()
});
let AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(RunAgentsResult::Denied {
reason,
})) = execution
else {
panic!("expected synchronous run_agents denial");
};
assert_eq!(reason, "Orchestration config was disapproved");
});
}
#[test]
fn execute_denies_never_allow_profile_setting() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
set_run_agents_permission(&mut app, RunAgentsPermission::NeverAllow);
let action = remote_run_agents_action("oz");
let execution = state.executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
.into()
});
let AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(RunAgentsResult::Denied {
reason,
})) = execution
else {
panic!("expected synchronous run_agents denial");
};
assert_eq!(
reason,
"Running child agents is disabled by the active execution profile."
);
});
}
#[test]
fn autonomous_mode_autoexecutes_and_does_not_deny_missing_api_key() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::Sdk);
set_run_agents_permission(&mut app, RunAgentsPermission::NeverAllow);
let action = remote_run_agents_action("codex");
let should_autoexecute = state.executor.update(&mut app, |executor, ctx| {
executor.should_autoexecute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
});
assert!(should_autoexecute);
let execution = state.executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
.into()
});
assert!(matches!(execution, AnyActionExecution::Async { .. }));
});
}
#[test]
fn execute_publishes_every_parent_owned_plan_before_dispatch() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::Sdk);
BlocklistAIHistoryModel::handle(&app).update(&mut app, |model, ctx| {
model.assign_run_id_for_conversation(
state.conversation_id,
"00000000-0000-0000-0000-000000000001".to_string(),
None,
EntityId::new(),
ctx,
);
});
let unrelated_conversation_id = AIConversationId::new();
let (first_plan_id, second_plan_id, unrelated_plan_id) = AIDocumentModel::handle(&app)
.update(&mut app, |model, ctx| {
(
model.create_document(
"First plan",
"# First",
state.conversation_id,
None,
ctx,
),
model.create_document(
"Second plan",
"# Second",
state.conversation_id,
None,
ctx,
),
model.create_document(
"Unrelated plan",
"# Unrelated",
unrelated_conversation_id,
None,
ctx,
),
)
});
let captured = subscribe_to_start_agent_requests(&mut app, &state.start_agent_executor);
let action = remote_run_agents_action("oz");
let execution = state.executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
.into()
});
assert!(matches!(execution, AnyActionExecution::Async { .. }));
captured.read(&app, |captured, _ctx| {
assert!(captured.0.is_empty());
});
AIDocumentModel::handle(&app).read(&app, |model, _ctx| {
assert!(matches!(
model.get_document_save_status(&first_plan_id),
AIDocumentSaveStatus::Saving
));
assert!(matches!(
model.get_document_save_status(&second_plan_id),
AIDocumentSaveStatus::Saving
));
assert!(matches!(
model.get_document_save_status(&unrelated_plan_id),
AIDocumentSaveStatus::NotSaved
));
});
});
}
/// A run_agents call holds in the `Publishing` state while it waits for the parent's
/// plans to become server-backed, then dispatches children. This verifies that
/// cancelling mid-publication prevents fan-out: even when the plan finishes publishing
/// afterwards (resolving the wait), the post-wait dispatch is skipped because
/// `cancel_execution` cleared the pending marker that `is_pending` guards on.
#[test]
fn cancel_during_plan_publication_does_not_dispatch_children() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::Sdk);
BlocklistAIHistoryModel::handle(&app).update(&mut app, |model, ctx| {
model.assign_run_id_for_conversation(
state.conversation_id,
"00000000-0000-0000-0000-000000000001".to_string(),
None,
EntityId::new(),
ctx,
);
});
let plan_id = AIDocumentModel::handle(&app).update(&mut app, |model, ctx| {
model.create_document("Plan", "# Plan", state.conversation_id, None, ctx)
});
let captured = subscribe_to_start_agent_requests(&mut app, &state.start_agent_executor);
let action = remote_run_agents_action("oz");
let action_id = action.id.clone();
let execution = state.executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
.into()
});
// The action is awaiting plan publication, so it's pending but no children dispatched yet.
assert!(matches!(execution, AnyActionExecution::Async { .. }));
state.executor.update(&mut app, |executor, ctx| {
assert!(executor.is_pending(&action_id));
executor.cancel_execution(&action_id, ctx);
assert!(!executor.is_pending(&action_id));
});
// Finish publishing the plan, which resolves the wait the dispatch was blocked on.
AIDocumentModel::handle(&app).update(&mut app, |model, ctx| {
model.create_document_from_notebook(
plan_id,
SyncId::ServerId(123.into()),
"Plan",
"# Plan",
state.conversation_id,
None,
ctx,
);
});
for _ in 0..3 {
futures_lite::future::yield_now().await;
}
// Cancellation won the race: the resolved wait does not fan out children.
captured.read(&app, |captured, _ctx| {
assert!(captured.0.is_empty());
});
});
}
fn set_run_agents_permission(app: &mut App, permission: RunAgentsPermission) {
AIExecutionProfilesModel::handle(app).update(app, |profiles, ctx| {
let profile_id = *profiles.active_profile(None, ctx).id();
profiles.set_run_agents(profile_id, permission, ctx);
});
}
#[test]
fn should_not_autoexecute_without_approved_plan_or_always_allow_profile() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
let action = remote_run_agents_action("oz");
let should_autoexecute = state.executor.update(&mut app, |executor, ctx| {
executor.should_autoexecute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
});
assert!(!should_autoexecute);
});
}
#[test]
fn execute_denies_remote_non_warp_harness_without_default_auth_secret() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
let action = remote_run_agents_action("codex");
let execution = state.executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
.into()
});
let AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(RunAgentsResult::Denied {
reason,
})) = execution
else {
panic!("expected synchronous run_agents denial");
};
assert_eq!(
reason,
"Cloud child agents using this harness require an API key before they can run."
);
});
}
#[test]
fn should_autoexecute_remote_non_warp_harness_with_always_allow_even_without_default_auth_secret() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow);
let action = remote_run_agents_action("codex");
let should_autoexecute = state.executor.update(&mut app, |executor, ctx| {
executor.should_autoexecute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
});
assert!(should_autoexecute);
});
}
#[test]
fn should_autoexecute_remote_non_warp_harness_with_default_auth_secret() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow);
persist_default_auth_secret(&mut app, "codex", "default-openai-key");
let action = remote_run_agents_action("codex");
let should_autoexecute = state.executor.update(&mut app, |executor, ctx| {
executor.should_autoexecute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
});
assert!(should_autoexecute);
});
}
#[test]
fn should_autoexecute_remote_warp_harness_without_default_auth_secret() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow);
let action = remote_run_agents_action("oz");
let should_autoexecute = state.executor.update(&mut app, |executor, ctx| {
executor.should_autoexecute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
});
assert!(should_autoexecute);
});
}
#[test]
fn populate_default_auth_secret_for_autoexecute_uses_persisted_secret() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
persist_default_auth_secret(&mut app, "claude", "default-anthropic-key");
let AIAgentActionType::RunAgents(mut request) = remote_run_agents_action("claude").action
else {
panic!("expected run_agents action");
};
state.executor.update(&mut app, |_, ctx| {
populate_default_auth_secret_for_execution(&mut request, ctx);
});
assert_eq!(
request.harness_auth_secret_name.as_deref(),
Some("default-anthropic-key")
);
});
}
@@ -1,33 +1,28 @@
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use futures::{channel::oneshot, future::BoxFuture, FutureExt};
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use futures::channel::oneshot;
use futures::future::BoxFuture;
use futures::FutureExt;
use itertools::Itertools;
use crate::{
ai::{
agent::{
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType,
SearchCodebaseFailureReason, SearchCodebaseRequest, SearchCodebaseResult,
},
blocklist::{action_model::execute::get_server_output_id, BlocklistAIPermissions},
get_relevant_files::controller::{
GetRelevantFilesController, GetRelevantFilesControllerEvent, GetRelevantFilesError,
},
},
features::FeatureFlag,
send_telemetry_from_ctx,
terminal::model::session::active_session::ActiveSession,
TelemetryEvent,
};
use super::{
read_local_file_context, ActionExecution, AnyActionExecution, ExecuteActionInput,
PreprocessActionInput,
};
use crate::ai::agent::{
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType,
SearchCodebaseFailureReason, SearchCodebaseRequest, SearchCodebaseResult,
};
use crate::ai::blocklist::action_model::execute::get_server_output_id;
use crate::ai::blocklist::{BlocklistAIPermissions, SessionContext};
use crate::ai::get_relevant_files::controller::{
GetRelevantFilesController, GetRelevantFilesControllerEvent, GetRelevantFilesControllerResult,
GetRelevantFilesError, GetRelevantFilesRequestTarget,
};
use crate::features::FeatureFlag;
use crate::terminal::model::session::active_session::ActiveSession;
use crate::{send_telemetry_from_ctx, TelemetryEvent};
pub struct SearchCodebaseExecutor {
active_session: ModelHandle<ActiveSession>,
@@ -48,13 +43,27 @@ impl SearchCodebaseExecutor {
terminal_view_id: EntityId,
ctx: &mut ModelContext<Self>,
) -> Self {
ctx.subscribe_to_model(&get_relevant_files_controller, |me, event, ctx| {
ctx.subscribe_to_model(&get_relevant_files_controller, |me, _, event, ctx| {
if !me.active_searches.contains_key(event.action_id()) {
return;
}
match event {
GetRelevantFilesControllerEvent::Success { fragments, .. } => {
GetRelevantFilesControllerEvent::Success {
action_id,
result: GetRelevantFilesControllerResult::SearchResult(result),
} => {
let Some(result_tx) = me.active_searches.remove(action_id) else {
return;
};
if let Err(e) = result_tx.send(result.clone()) {
log::warn!("Failed to send search codebase results to receiver {e:?}.");
}
}
GetRelevantFilesControllerEvent::Success {
result: GetRelevantFilesControllerResult::Locations(fragments),
..
} => {
let action_id = event.action_id().clone();
let locations = fragments
.iter()
@@ -188,108 +197,196 @@ impl SearchCodebaseExecutor {
else {
return ActionExecution::InvalidAction;
};
let codebase_path = codebase_path.as_ref().map(PathBuf::from);
let Some(current_working_directory) = self
.active_session
.as_ref(ctx)
.current_working_directory()
.map(PathBuf::from)
else {
// This should really never happen; it implies that we don't know what the
// current working directory is, which is never the case.
return ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(
SearchCodebaseResult::Failed {
reason: SearchCodebaseFailureReason::MissingCurrentWorkingDirectory,
message: "The search failed. Try another way to locate the relevant files."
.to_string(),
let session_context = SessionContext::from_session(self.active_session.as_ref(ctx), ctx);
if session_context.is_remote() {
let requested_codebase_path = codebase_path
.as_deref()
.filter(|path| !path.is_empty() && *path != ".")
.map(ToOwned::to_owned);
let server_output_id = get_server_output_id(conversation_id, ctx);
send_telemetry_from_ctx!(
TelemetryEvent::SearchCodebaseRequested {
action_id: id.clone(),
server_output_id,
is_cross_repo: requested_codebase_path.is_some(),
},
));
};
ctx
);
let search_dir;
let is_cross_repo;
if FeatureFlag::CrossRepoContext.is_enabled() {
is_cross_repo = codebase_path
.as_ref()
.is_some_and(|path| !current_working_directory.starts_with(path));
search_dir = codebase_path.unwrap_or(current_working_directory);
} else {
is_cross_repo = false;
search_dir = current_working_directory;
}
let server_output_id = get_server_output_id(input.conversation_id, ctx);
send_telemetry_from_ctx!(
TelemetryEvent::SearchCodebaseRequested {
action_id: id.clone(),
server_output_id,
is_cross_repo,
},
ctx
);
let root_dir_for_search = self.root_repo_paths.get(id).cloned().or_else(|| {
self.get_relevant_files_controller
.as_ref(ctx)
.root_directory_for_remote_search(
&session_context,
requested_codebase_path.as_deref(),
ctx,
)
});
let Some(root_dir_for_search) = root_dir_for_search else {
return ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(
SearchCodebaseResult::Failed {
reason: SearchCodebaseFailureReason::CodebaseNotIndexed,
message: "The search failed because the codebase is not available. Try another way to locate the relevant files.".to_owned(),
},
));
};
let Some(root_dir_for_search) = self.root_repo_paths.get(id) else {
let action_id = id.clone();
// Check if directory exists on background thread since its a sys call; no need to block
// main thread since its just for telemetry.
let _ = ctx.spawn(async move { search_dir.exists() }, |_, exists, ctx| {
let error = if exists {
"The codebase isn't indexed".to_string()
} else {
"The codebase doesn't exist".to_string()
};
send_telemetry_from_ctx!(
TelemetryEvent::SearchCodebaseRepoUnavailable { action_id, error },
ctx
// Add the repo root as a temporary permission; if the user gave us permission to
// search the repo, we can certainly search files within it for the rest of the convo.
BlocklistAIPermissions::handle(ctx).update(ctx, |model, _ctx| {
model.add_temporary_file_read_permissions(
conversation_id,
vec![root_dir_for_search.to_owned()],
);
});
return ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(SearchCodebaseResult::Failed {
message: "The search failed because the codebase is not available. Try another way to locate the relevant files.".to_owned(),
reason: SearchCodebaseFailureReason::CodebaseNotIndexed
}));
};
// Add the repo root as a temporary permission; if the user gave us permission to
// search the repo, we can certainly search files within it for the rest of the convo.
BlocklistAIPermissions::handle(ctx).update(ctx, |model, _ctx| {
model.add_temporary_file_read_permissions(
conversation_id,
vec![root_dir_for_search.to_owned()],
);
});
let (result_tx, result_rx) = oneshot::channel();
self.active_searches.insert(id.clone(), result_tx);
let (result_tx, result_rx) = oneshot::channel();
self.active_searches.insert(id.clone(), result_tx);
match self
.get_relevant_files_controller
.update(ctx, |controller, ctx| {
controller.send_request(
GetRelevantFilesRequestTarget::Remote {
session_context,
requested_codebase_path,
},
query.clone(),
partial_paths.as_ref(),
id.clone(),
ctx,
)
}) {
Ok(_) => ActionExecution::Async {
execute_future: Box::pin(result_rx),
on_complete: Box::new(
|res: Result<SearchCodebaseResult, oneshot::Canceled>, _ctx| {
let action_result =
res.unwrap_or_else(|e| SearchCodebaseResult::Failed {
message: e.to_string(),
reason: SearchCodebaseFailureReason::ClientError,
});
AIAgentActionResultType::SearchCodebase(action_result)
},
),
},
Err(e) => {
log::warn!("Failed to send remote get_relevant_files request: {e:?}");
ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(
SearchCodebaseResult::Failed {
reason: SearchCodebaseFailureReason::CodebaseNotIndexed,
message: "Remote codebase search is unavailable.".to_owned(),
},
))
}
}
} else {
let codebase_path = codebase_path.as_ref().map(PathBuf::from);
// Start the actual search.
match self
.get_relevant_files_controller
.update(ctx, |controller, ctx| {
controller.send_request(
root_dir_for_search,
query.clone(),
partial_paths.as_ref(),
id.clone(),
ctx,
)
}) {
Ok(_) => ActionExecution::Async {
execute_future: Box::pin(result_rx),
on_complete: Box::new(
|res: Result<SearchCodebaseResult, oneshot::Canceled>, _ctx| {
let action_result = res.unwrap_or_else(|e| SearchCodebaseResult::Failed {
message: e.to_string(),
reason: SearchCodebaseFailureReason::ClientError,
});
AIAgentActionResultType::SearchCodebase(action_result)
let Some(current_working_directory) = self
.active_session
.as_ref(ctx)
.current_working_directory()
.map(PathBuf::from)
else {
// This should really never happen; it implies that we don't know what the
// current working directory is, which is never the case.
return ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(
SearchCodebaseResult::Failed {
reason: SearchCodebaseFailureReason::MissingCurrentWorkingDirectory,
message: "The search failed. Try another way to locate the relevant files."
.to_string(),
},
),
},
Err(e) => {
log::warn!("Failed to send get_relevant_files request for directory: {e:?}");
));
};
let error_message = match e {
let search_dir;
let is_cross_repo;
if FeatureFlag::CrossRepoContext.is_enabled() {
is_cross_repo = codebase_path
.as_ref()
.is_some_and(|path| !current_working_directory.starts_with(path));
search_dir = codebase_path.unwrap_or(current_working_directory);
} else {
is_cross_repo = false;
search_dir = current_working_directory;
}
let server_output_id = get_server_output_id(conversation_id, ctx);
send_telemetry_from_ctx!(
TelemetryEvent::SearchCodebaseRequested {
action_id: id.clone(),
server_output_id,
is_cross_repo,
},
ctx
);
let Some(root_dir_for_search) = self.root_repo_paths.get(id) else {
let action_id = id.clone();
// Check if directory exists on background thread since its a sys call; no need to block
// main thread since its just for telemetry.
let _ = ctx.spawn(async move { search_dir.exists() }, |_, exists, ctx| {
let error = if exists {
"The codebase isn't indexed".to_string()
} else {
"The codebase doesn't exist".to_string()
};
send_telemetry_from_ctx!(
TelemetryEvent::SearchCodebaseRepoUnavailable { action_id, error },
ctx
);
});
return ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(SearchCodebaseResult::Failed {
message: "The search failed because the codebase is not available. Try another way to locate the relevant files.".to_owned(),
reason: SearchCodebaseFailureReason::CodebaseNotIndexed
}));
};
// Add the repo root as a temporary permission; if the user gave us permission to
// search the repo, we can certainly search files within it for the rest of the convo.
BlocklistAIPermissions::handle(ctx).update(ctx, |model, _ctx| {
model.add_temporary_file_read_permissions(
conversation_id,
vec![root_dir_for_search.to_owned()],
);
});
let (result_tx, result_rx) = oneshot::channel();
self.active_searches.insert(id.clone(), result_tx);
// Start the actual search.
match self
.get_relevant_files_controller
.update(ctx, |controller, ctx| {
controller.send_request(
GetRelevantFilesRequestTarget::Local {
directory: root_dir_for_search.clone(),
},
query.clone(),
partial_paths.as_ref(),
id.clone(),
ctx,
)
}) {
Ok(_) => ActionExecution::Async {
execute_future: Box::pin(result_rx),
on_complete: Box::new(
|res: Result<SearchCodebaseResult, oneshot::Canceled>, _ctx| {
let action_result =
res.unwrap_or_else(|e| SearchCodebaseResult::Failed {
message: e.to_string(),
reason: SearchCodebaseFailureReason::ClientError,
});
AIAgentActionResultType::SearchCodebase(action_result)
},
),
},
Err(e) => {
log::warn!("Failed to send get_relevant_files request for directory: {e:?}");
let error_message = match e {
GetRelevantFilesError::Pending => {
"The current git repository is still being indexed, so search is unavailable right now. You can try again later".to_owned()
}
@@ -300,12 +397,13 @@ impl SearchCodebaseExecutor {
"The current directory isn't within a git repository, which is necessary to search for relevant files.".to_owned()
}
};
ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(
SearchCodebaseResult::Failed {
reason: SearchCodebaseFailureReason::CodebaseNotIndexed,
message: error_message,
},
))
ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(
SearchCodebaseResult::Failed {
reason: SearchCodebaseFailureReason::CodebaseNotIndexed,
message: error_message,
},
))
}
}
}
}
@@ -333,6 +431,17 @@ impl SearchCodebaseExecutor {
app: &AppContext,
) -> Option<PathBuf> {
let SearchCodebaseRequest { codebase_path, .. } = request;
let session_context = SessionContext::from_session(self.active_session.as_ref(app), app);
if session_context.is_remote() {
let requested_codebase_path = codebase_path
.as_deref()
.filter(|path| !path.is_empty() && *path != ".");
return self
.get_relevant_files_controller
.as_ref(app)
.root_directory_for_remote_search(&session_context, requested_codebase_path, app);
}
let codebase_path = codebase_path.as_deref().map(PathBuf::from);
let Some(pwd) = self
.active_session
@@ -1,28 +1,131 @@
use futures::{future::BoxFuture, FutureExt};
use galaxyui::{Entity, ModelContext, SingletonEntity};
#[cfg(not(target_family = "wasm"))]
use std::time::Duration;
#[cfg(not(target_family = "wasm"))]
use anyhow::anyhow;
use futures::future::BoxFuture;
#[cfg(not(target_family = "wasm"))]
use futures::future::Either;
use futures::FutureExt;
use galaxy_core::send_telemetry_from_ctx;
#[cfg(not(target_family = "wasm"))]
use warpui::r#async::Timer;
use warpui::{AppContext, Entity, ModelContext, SingletonEntity};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{
AIAgentAction, AIAgentActionResultType, AIAgentActionType, SendMessageToAgentResult,
};
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
use crate::ai::blocklist::orchestration_events::{OrchestrationEventService, SendMessageResult};
use crate::ai::blocklist::telemetry::{
BlocklistOrchestrationTelemetryEvent, TeamAgentCommunicationFailedEvent,
TeamAgentCommunicationFailureReason, TeamAgentCommunicationKind,
TeamAgentCommunicationTransport, TeamAgentOrchestrationVersion,
};
use crate::server::server_api::ai::SendAgentMessageRequest;
use crate::server::server_api::ai::{SendAgentMessageRequest, SendAgentMessageResponse};
use crate::server::server_api::ServerApiProvider;
use galaxy_core::features::FeatureFlag;
use galaxy_core::send_telemetry_from_ctx;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
#[cfg(not(target_family = "wasm"))]
const SEND_AGENT_MESSAGE_TIMEOUT: Duration = Duration::from_secs(15);
pub struct SendMessageToAgentExecutor;
pub struct SendMessageToAgentExecutor {
ambient_agent_task_id: Option<AmbientAgentTaskId>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SendMessageTaskResolution {
ConversationTask,
AmbientTaskFallback,
NoTaskContext,
}
fn sender_run_id_and_task_id_for_send(
conversation_id: AIConversationId,
ambient_agent_task_id: Option<AmbientAgentTaskId>,
ctx: &AppContext,
) -> (
String,
Option<AmbientAgentTaskId>,
SendMessageTaskResolution,
) {
let conversation = BlocklistAIHistoryModel::as_ref(ctx).conversation(&conversation_id);
let conversation_task_id = conversation.and_then(|conversation| conversation.task_id());
let (task_id, task_resolution) = match (conversation_task_id, ambient_agent_task_id) {
(Some(task_id), _) => (Some(task_id), SendMessageTaskResolution::ConversationTask),
(None, Some(task_id)) => (
Some(task_id),
SendMessageTaskResolution::AmbientTaskFallback,
),
(None, None) => (None, SendMessageTaskResolution::NoTaskContext),
};
let sender_run_id = conversation
.and_then(|conversation| conversation.run_id())
.or_else(|| task_id.map(|task_id| task_id.to_string()))
.unwrap_or_default();
(sender_run_id, task_id, task_resolution)
}
#[cfg(not(target_family = "wasm"))]
async fn send_agent_message_with_timeout(
server_api: std::sync::Arc<crate::server::server_api::ServerApi>,
ai_client: std::sync::Arc<dyn crate::server::server_api::ai::AIClient>,
task_id: Option<AmbientAgentTaskId>,
request: SendAgentMessageRequest,
) -> anyhow::Result<SendAgentMessageResponse, anyhow::Error> {
let task_id_for_timeout = task_id.map(|task_id| task_id.to_string());
let send_message = async move {
match task_id {
Some(task_id) => {
server_api
.send_agent_message_for_task(&task_id, request)
.await
}
None => ai_client.send_agent_message(request).await,
}
};
let timeout = Timer::after(SEND_AGENT_MESSAGE_TIMEOUT);
futures::pin_mut!(send_message);
futures::pin_mut!(timeout);
match futures::future::select(send_message, timeout).await {
Either::Left((result, _)) => result,
Either::Right(_) => Err(anyhow!(
"Timed out sending orchestration message{}",
task_id_for_timeout
.map(|task_id| format!(" for task {task_id}"))
.unwrap_or_default()
)),
}
}
#[cfg(target_family = "wasm")]
async fn send_agent_message_with_timeout(
server_api: std::sync::Arc<crate::server::server_api::ServerApi>,
ai_client: std::sync::Arc<dyn crate::server::server_api::ai::AIClient>,
task_id: Option<AmbientAgentTaskId>,
request: SendAgentMessageRequest,
) -> anyhow::Result<SendAgentMessageResponse, anyhow::Error> {
match task_id {
Some(task_id) => {
server_api
.send_agent_message_for_task(&task_id, request)
.await
}
None => ai_client.send_agent_message(request).await,
}
}
impl SendMessageToAgentExecutor {
pub fn new() -> Self {
Self
Self {
ambient_agent_task_id: None,
}
}
pub fn set_ambient_agent_task_id(&mut self, id: Option<AmbientAgentTaskId>) {
self.ambient_agent_task_id = id;
}
pub(super) fn should_autoexecute(
@@ -56,96 +159,67 @@ impl SendMessageToAgentExecutor {
let subject = subject.clone();
let message_body = message.clone();
if FeatureFlag::OrchestrationV2.is_enabled() {
let sender_run_id = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.and_then(|c| c.run_id())
.map(|s| s.to_string())
.unwrap_or_default();
// In local Bedrock mode, child agents run autonomously in their
// own conversation loop — there's no server API to relay messages.
// Return an error explaining the limitation so the model doesn't
// keep polling in a loop.
if sender_run_id.is_empty() {
log::info!(
"[send_message] No run_id for conversation {:?}, assuming local mode — skipping server API call",
conversation_id
);
return ActionExecution::<()>::Sync(AIAgentActionResultType::SendMessageToAgent(
SendMessageToAgentResult::Error(
"Child agents are running autonomously in local mode. \
You cannot send messages to them. They will complete their \
tasks independently. Continue with your own work or wait \
for the user to share the results."
.to_string(),
),
))
.into();
}
let log_addresses = addresses.clone();
let log_subject = subject.clone();
let log_sender_run_id = sender_run_id.clone();
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
let request = SendAgentMessageRequest {
to: addresses,
subject,
body: message_body,
sender_run_id,
};
return ActionExecution::new_async(
async move { ai_client.send_agent_message(request).await },
move |result, ctx| match result {
Ok(response) => {
let message_id =
response.message_ids.into_iter().next().unwrap_or_default();
AIAgentActionResultType::SendMessageToAgent(
SendMessageToAgentResult::Success { message_id },
)
}
Err(err) => {
let error_message = err.to_string();
send_telemetry_from_ctx!(
BlocklistOrchestrationTelemetryEvent::TeamAgentCommunicationFailed(
TeamAgentCommunicationFailedEvent {
communication_kind: TeamAgentCommunicationKind::Message,
transport: TeamAgentCommunicationTransport::ServerApi,
orchestration_version: TeamAgentOrchestrationVersion::V2,
failure_reason:
TeamAgentCommunicationFailureReason::RequestFailed,
source_conversation_id: conversation_id,
source_run_id: (!log_sender_run_id.is_empty())
.then(|| log_sender_run_id.clone()),
target_count: Some(log_addresses.len()),
lifecycle_event_type: None,
error_message: Some(error_message.clone()),
}
),
ctx
);
log::warn!(
"Failed to send child-agent message via server API: conversation_id={conversation_id:?} sender_run_id={log_sender_run_id:?} target_agent_ids={log_addresses:?} subject={log_subject:?} error={err:#}"
);
AIAgentActionResultType::SendMessageToAgent(
SendMessageToAgentResult::Error(error_message),
)
}
},
)
.into();
}
let result = OrchestrationEventService::handle(ctx).update(ctx, |svc, ctx| {
svc.send_message(conversation_id, &addresses, subject, message_body, ctx)
});
let result = match result {
SendMessageResult::MessageSent { message_id } => {
SendMessageToAgentResult::Success { message_id }
}
SendMessageResult::Error(error) => SendMessageToAgentResult::Error(error),
let (sender_run_id, task_id, task_resolution) =
sender_run_id_and_task_id_for_send(conversation_id, self.ambient_agent_task_id, ctx);
let log_addresses = addresses.clone();
let log_subject = subject.clone();
let log_sender_run_id = sender_run_id.clone();
let log_task_id = task_id.map(|task_id| task_id.to_string());
let log_body_len = message_body.chars().count();
let server_api = ServerApiProvider::as_ref(ctx).get();
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
log::info!(
"Sending orchestration message: conversation_id={conversation_id:?} resolution={task_resolution:?} sender_run_id={log_sender_run_id:?} task_id={log_task_id:?} target_agent_ids={log_addresses:?} subject={log_subject:?} body_len={log_body_len}"
);
let request = SendAgentMessageRequest {
to: addresses,
subject,
body: message_body,
sender_run_id,
};
ActionExecution::<()>::Sync(AIAgentActionResultType::SendMessageToAgent(result)).into()
ActionExecution::new_async(
async move {
send_agent_message_with_timeout(server_api, ai_client, task_id, request).await
},
move |result, ctx| match result {
Ok(response) => {
let message_id = response.message_ids.into_iter().next().unwrap_or_default();
log::info!(
"Sent orchestration message: conversation_id={conversation_id:?} resolution={task_resolution:?} sender_run_id={log_sender_run_id:?} task_id={log_task_id:?} target_agent_ids={log_addresses:?} subject={log_subject:?} body_len={log_body_len} message_id={message_id:?}"
);
AIAgentActionResultType::SendMessageToAgent(
SendMessageToAgentResult::Success { message_id },
)
}
Err(err) => {
let error_message = err.to_string();
send_telemetry_from_ctx!(
BlocklistOrchestrationTelemetryEvent::TeamAgentCommunicationFailed(
TeamAgentCommunicationFailedEvent {
communication_kind: TeamAgentCommunicationKind::Message,
transport: TeamAgentCommunicationTransport::ServerApi,
orchestration_version: TeamAgentOrchestrationVersion::V2,
failure_reason: TeamAgentCommunicationFailureReason::RequestFailed,
source_conversation_id: conversation_id,
source_run_id: (!log_sender_run_id.is_empty())
.then(|| log_sender_run_id.clone()),
target_count: Some(log_addresses.len()),
lifecycle_event_type: None,
error_message: Some(error_message.clone()),
}
),
ctx
);
log::warn!(
"Failed to send child-agent message via server API: conversation_id={conversation_id:?} resolution={task_resolution:?} sender_run_id={log_sender_run_id:?} task_id={log_task_id:?} target_agent_ids={log_addresses:?} subject={log_subject:?} body_len={log_body_len} error={err:#}"
);
AIAgentActionResultType::SendMessageToAgent(
SendMessageToAgentResult::Error(error_message),
)
}
},
)
.into()
}
pub(super) fn preprocess_action(
@@ -166,3 +240,7 @@ impl Default for SendMessageToAgentExecutor {
impl Entity for SendMessageToAgentExecutor {
type Event = ();
}
#[cfg(test)]
#[path = "send_message_tests.rs"]
mod tests;
@@ -0,0 +1,61 @@
use warpui::{App, EntityId};
use super::*;
use crate::ai::blocklist::BlocklistAIHistoryModel;
#[test]
fn sender_run_id_and_task_id_for_send_falls_back_to_ambient_task_id() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let ambient_task_id = "11111111-1111-1111-1111-111111111111"
.parse()
.expect("valid ambient task id");
let (sender_run_id, task_id, task_resolution) = app.read(|ctx| {
sender_run_id_and_task_id_for_send(conversation_id, Some(ambient_task_id), ctx)
});
assert_eq!(sender_run_id, ambient_task_id.to_string());
assert_eq!(task_id, Some(ambient_task_id));
assert_eq!(
task_resolution,
SendMessageTaskResolution::AmbientTaskFallback
);
});
}
#[test]
fn sender_run_id_and_task_id_for_send_prefers_conversation_task_id() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let conversation_task_id = "22222222-2222-2222-2222-222222222222"
.parse()
.expect("valid conversation task id");
let ambient_task_id = "33333333-3333-3333-3333-333333333333"
.parse()
.expect("valid ambient task id");
history_model.update(&mut app, |history_model, _| {
history_model
.conversation_mut(&conversation_id)
.expect("conversation exists")
.set_task_id(conversation_task_id);
});
let (sender_run_id, task_id, task_resolution) = app.read(|ctx| {
sender_run_id_and_task_id_for_send(conversation_id, Some(ambient_task_id), ctx)
});
assert_eq!(sender_run_id, conversation_task_id.to_string());
assert_eq!(task_id, Some(conversation_task_id));
assert_eq!(task_resolution, SendMessageTaskResolution::ConversationTask);
});
}
@@ -3,6 +3,7 @@ use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use chrono::{DateTime, Local};
use futures::channel::oneshot;
use futures::future::BoxFuture;
use futures::{select, FutureExt};
@@ -15,9 +16,10 @@ use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use itertools::Itertools;
use parking_lot::FairMutex;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use crate::ai::agent::{
AIAgentActionId, AIAgentActionType, AIAgentPtyWriteMode, ReadShellCommandOutputResult,
RequestCommandOutputResult, ShellCommandDelay, ShellCommandError,
AIAgentActionId, AIAgentActionResultType, AIAgentActionType, AIAgentPtyWriteMode,
ReadShellCommandOutputResult, RequestCommandOutputResult, ShellCommandDelay, ShellCommandError,
TransferShellCommandControlToUserResult, WriteToLongRunningShellCommandResult,
};
use crate::ai::blocklist::permissions::CommandExecutionPermission;
@@ -27,19 +29,12 @@ use crate::terminal::event::BlockMetadataReceivedEvent;
use crate::terminal::model::block::{
formatted_terminal_contents_for_input, Block, BlockId, CURSOR_MARKER,
};
use crate::terminal::model::session::active_session::ActiveSession;
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::terminal::shell::ShellType;
use crate::{
ai::agent::AIAgentActionResultType,
terminal::{
model::session::active_session::ActiveSession,
model_events::{ModelEvent, ModelEventDispatcher},
TerminalModel,
},
};
use crate::terminal::TerminalModel;
use crate::{send_telemetry_from_ctx, TelemetryEvent};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
pub struct ShellCommandExecutor {
active_session: ModelHandle<ActiveSession>,
block_finished_senders: HashMap<BlockSelector, oneshot::Sender<()>>,
@@ -79,7 +74,12 @@ impl ShellCommandExecutor {
}
}
fn handle_terminal_model_event(&mut self, event: &ModelEvent, _ctx: &mut ModelContext<Self>) {
fn handle_terminal_model_event(
&mut self,
_: ModelHandle<ModelEventDispatcher>,
event: &ModelEvent,
_ctx: &mut ModelContext<Self>,
) {
// We wait for precmd for the block _after_ the requested command's block so that
// downstream checks for current working directory are fresh. The precmd hook is when
// the shell relays current working directory to warp.
@@ -252,6 +252,18 @@ impl ShellCommandExecutor {
},
));
}
// If another conversation has taken over the agent view since this command
// was requested, cancel instead of executing.
let is_displaced_by_other_conversation = model
.block_list()
.agent_view_state()
.active_conversation_id()
.is_some_and(|active_id| active_id != input.conversation_id);
if is_displaced_by_other_conversation {
return ActionExecution::Sync(AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::CancelledBeforeExecution,
));
}
// If the command might use pager and can't be interacted with,
// we pipe its output to cat so we can prevent activating the altscreen.
// The parentheses here ensures the command always gets evaluated first.
@@ -307,12 +319,16 @@ impl ShellCommandExecutor {
if block.finished() {
let output: String = block.output_with_secrets_unobfuscated();
let exit_code = block.exit_code();
let start_ts = block.start_ts().cloned();
let completed_ts = block.completed_ts().cloned();
return ActionExecution::Sync(
AIAgentActionResultType::WriteToLongRunningShellCommand(
WriteToLongRunningShellCommandResult::CommandFinished {
block_id: block.id().clone(),
output,
exit_code,
start_ts,
completed_ts,
},
),
);
@@ -360,12 +376,16 @@ impl ShellCommandExecutor {
let command = block.command_with_secrets_unobfuscated(false);
let output: String = block.output_with_secrets_unobfuscated();
let exit_code = block.exit_code();
let start_ts = block.start_ts().cloned();
let completed_ts = block.completed_ts().cloned();
return ActionExecution::Sync(AIAgentActionResultType::ReadShellCommandOutput(
ReadShellCommandOutputResult::CommandFinished {
command,
block_id: block_id.clone(),
output,
exit_code,
start_ts,
completed_ts,
},
));
}
@@ -451,6 +471,8 @@ impl ShellCommandExecutor {
block_id: block.id().clone(),
output: block.output_with_secrets_unobfuscated(),
exit_code: block.exit_code(),
start_ts: block.start_ts().cloned(),
completed_ts: block.completed_ts().cloned(),
}
} else {
let grid_contents = if model.is_alt_screen_active() {
@@ -593,6 +615,8 @@ impl ShellCommandExecutor {
block_id: block.id().clone(),
output: block.output_with_secrets_unobfuscated(),
exit_code: block.exit_code(),
start_ts: block.start_ts().cloned(),
completed_ts: block.completed_ts().cloned(),
}
} else {
let grid_contents = if model.is_alt_screen_active() {
@@ -709,11 +733,15 @@ fn action_result_for_requested_command(
block_id,
output,
exit_code,
start_ts,
completed_ts,
} => AIAgentActionResultType::RequestCommandOutput(RequestCommandOutputResult::Completed {
command,
block_id,
output,
exit_code,
start_ts,
completed_ts,
}),
ActionResult::LongRunningCommandSnapshot {
block_id,
@@ -747,11 +775,15 @@ fn action_result_for_write_to_long_running_shell_command(
block_id,
output,
exit_code,
start_ts,
completed_ts,
} => AIAgentActionResultType::WriteToLongRunningShellCommand(
WriteToLongRunningShellCommandResult::CommandFinished {
block_id,
output,
exit_code,
start_ts,
completed_ts,
},
),
ActionResult::LongRunningCommandSnapshot {
@@ -788,12 +820,16 @@ fn action_result_for_read_shell_command_output(
output,
exit_code,
block_id,
start_ts,
completed_ts,
} => AIAgentActionResultType::ReadShellCommandOutput(
ReadShellCommandOutputResult::CommandFinished {
command,
block_id,
output,
exit_code,
start_ts,
completed_ts,
},
),
ActionResult::LongRunningCommandSnapshot {
@@ -830,11 +866,15 @@ fn action_result_for_transfer_shell_command_control_to_user(
block_id,
output,
exit_code,
start_ts,
completed_ts,
} => AIAgentActionResultType::TransferShellCommandControlToUser(
TransferShellCommandControlToUserResult::CommandFinished {
block_id,
output,
exit_code,
start_ts,
completed_ts,
},
),
ActionResult::LongRunningCommandSnapshot {
@@ -898,6 +938,8 @@ enum ActionResult {
block_id: BlockId,
output: String,
exit_code: ExitCode,
start_ts: Option<DateTime<Local>>,
completed_ts: Option<DateTime<Local>>,
},
LongRunningCommandSnapshot {
block_id: BlockId,
@@ -909,3 +951,7 @@ enum ActionResult {
Cancelled,
BlockNotFound,
}
#[cfg(test)]
#[path = "shell_command_tests.rs"]
mod tests;
@@ -0,0 +1,91 @@
use std::sync::Arc;
use async_channel::unbounded;
use futures::channel::oneshot;
use parking_lot::FairMutex;
use warpui::{App, EntityId};
use super::{BlockSelector, ShellCommandExecutor};
use crate::terminal::event::{BlockMetadataReceivedEvent, BlockWorkingDirectoryUpdatedEvent};
use crate::terminal::model::block::{BlockId, BlockMetadata};
use crate::terminal::model::session::active_session::ActiveSession;
use crate::terminal::model::session::Sessions;
use crate::terminal::model::terminal_model::{BlockIndex, TerminalModel};
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
/// Locks in the contract that `ShellCommandExecutor`'s requested-command finish
/// detector reacts only to `BlockMetadataReceived` (precmd) and not to
/// `BlockWorkingDirectoryUpdated` (OSC 7). The detector relies on
/// `BlockMetadataReceived` firing exactly once per block; OSC 7 can fire many
/// times per block, so wiring it into the detector would resolve the wait
/// future before the requested command actually finishes.
#[test]
fn block_working_directory_updated_does_not_drain_finish_senders() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let sessions = app.add_model(|_| Sessions::new_for_test());
let (_model_events_tx, model_events_rx) = unbounded();
let model_event_dispatcher =
app.add_model(|ctx| ModelEventDispatcher::new(model_events_rx, sessions.clone(), ctx));
let active_session = app.add_model(|ctx| {
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
});
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
let executor = app.add_model(|ctx| {
ShellCommandExecutor::new(
active_session,
terminal_model.clone(),
&model_event_dispatcher,
terminal_view_id,
ctx,
)
});
let block_id = BlockId::new();
let selector = BlockSelector::Id(block_id);
let (tx, _rx) = oneshot::channel::<()>();
executor.update(&mut app, |executor, _ctx| {
executor.block_finished_senders.insert(selector, tx);
});
assert_eq!(
app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()),
1
);
// OSC 7 update — must NOT drain or resolve the finish sender.
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
ctx.emit(ModelEvent::BlockWorkingDirectoryUpdated(
BlockWorkingDirectoryUpdatedEvent {
block_metadata: BlockMetadata::new(None, Some("/tmp/new".to_string())),
block_index: BlockIndex::zero(),
is_for_in_band_command: false,
is_done_bootstrapping: true,
},
));
});
assert_eq!(
app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()),
1,
"BlockWorkingDirectoryUpdated must not touch block_finished_senders — \
that map is reserved for precmd (BlockMetadataReceived)"
);
// Precmd event — the senders map should be drained (and since the
// block isn't in the terminal model, the sender is dropped).
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
ctx.emit(ModelEvent::BlockMetadataReceived(
BlockMetadataReceivedEvent {
block_metadata: BlockMetadata::new(None, Some("/tmp/precmd".to_string())),
block_index: BlockIndex::zero(),
is_after_in_band_command: false,
is_done_bootstrapping: true,
},
));
});
assert_eq!(
app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()),
0,
"BlockMetadataReceived should drain the finish senders"
);
});
}
@@ -1,25 +1,27 @@
use futures::{future::BoxFuture, FutureExt};
use galaxyui::{Entity, ModelContext, SingletonEntity};
use std::collections::HashMap;
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
use futures::future::BoxFuture;
use futures::FutureExt;
use shell_words::split as split_shell_words;
use galaxy_cli::agent::Harness;
use galaxyui::{Entity, ModelContext, ModelHandle, SingletonEntity};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
use crate::ai::agent::{
AIAgentAction, AIAgentActionResultType, AIAgentActionType, LifecycleEventType,
StartAgentExecutionMode, StartAgentResult,
};
use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer;
use crate::ai::blocklist::orchestration_events::OrchestrationEventService;
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
use galaxy_cli::agent::Harness;
use galaxy_core::features::FeatureFlag;
use crate::ai::local_harness_setup::local_harness_product_disabled_message;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
/// The result sent back to the executor after observing the child agent's lifecycle.
enum StartAgentDecision {
/// The child conversation was created successfully.
Started { agent_id: String },
/// The child agent completed and here is its output (local/blocking mode).
Completed { agent_id: String, output: String },
/// Per-request outcome of a StartAgent dispatch.
#[derive(Debug, Clone)]
pub enum StartAgentOutcome {
Started {
agent_id: String,
},
/// An error occurred while starting the agent.
Error(String),
}
@@ -33,10 +35,72 @@ fn invalid_local_child_harness_error(harness_type: &str) -> String {
}
}
/// Groups the data for a single StartAgent invocation as it flows from the
/// executor through the terminal view and pane group into the controller.
/// Handles local child launch requests produced by older agents, where the
/// prompt encoded the target CLI command and `execution_mode.harness_type` was
/// still unset. Normalizing here keeps those requests routed through the Codex
/// local harness path instead of launching them as Oz child prompts.
fn parse_legacy_local_child_harness_command(command: &str) -> Option<(String, String)> {
let args = split_shell_words(command.trim()).ok()?;
match args.as_slice() {
[binary, flag, child_prompt]
if binary == "codex"
&& flag == "--dangerously-bypass-approvals-and-sandbox"
&& !child_prompt.trim().is_empty() =>
{
Some(("codex".to_string(), child_prompt.clone()))
}
_ => None,
}
}
fn normalize_legacy_local_child_harness_command(
prompt: String,
execution_mode: StartAgentExecutionMode,
) -> (String, StartAgentExecutionMode) {
match execution_mode {
StartAgentExecutionMode::Local {
harness_type: None,
model_id,
} => {
if let Some((harness_type, child_prompt)) =
parse_legacy_local_child_harness_command(&prompt)
{
(
child_prompt,
StartAgentExecutionMode::Local {
harness_type: Some(harness_type),
model_id,
},
)
} else {
(
prompt,
StartAgentExecutionMode::Local {
harness_type: None,
model_id,
},
)
}
}
execution_mode => (prompt, execution_mode),
}
}
/// Opaque, monotonically increasing request identifier.
/// Disambiguates parallel in-flight StartAgent requests.
#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq, Default)]
pub struct StartAgentRequestId(u64);
impl StartAgentRequestId {
#[cfg(test)]
pub const fn from_raw_for_test(value: u64) -> Self {
Self(value)
}
}
#[derive(Clone)]
pub struct StartAgentRequest {
pub id: StartAgentRequestId,
pub name: String,
pub prompt: String,
pub execution_mode: StartAgentExecutionMode,
@@ -45,22 +109,16 @@ pub struct StartAgentRequest {
pub parent_run_id: Option<String>,
}
/// Tracks a single in-flight StartAgent action.
struct PendingStartAgent {
parent_conversation_id: AIConversationId,
/// Set when `StartedNewConversation` fires for a conversation whose
/// `parent_conversation_id` matches.
/// Set once the child conversation is synchronously created.
child_conversation_id: Option<AIConversationId>,
sender: async_channel::Sender<StartAgentDecision>,
/// When true, the executor blocks until the child agent finishes and
/// returns its output as the tool result (local Bedrock mode).
wait_for_completion: bool,
sender: async_channel::Sender<StartAgentOutcome>,
}
pub struct StartAgentExecutor {
/// All in-flight StartAgent actions. Multiple agents can be spawned
/// concurrently from the same parent.
pending: Vec<PendingStartAgent>,
pending: HashMap<StartAgentRequestId, PendingStartAgent>,
next_request_id: u64,
}
impl StartAgentExecutor {
@@ -69,213 +127,183 @@ impl StartAgentExecutor {
ctx.subscribe_to_model(&history_model, Self::handle_history_event);
Self {
pending: Vec::new(),
pending: HashMap::new(),
next_request_id: 0,
}
}
fn next_request_id(&mut self) -> StartAgentRequestId {
let id = self.next_request_id;
self.next_request_id = self.next_request_id.wrapping_add(1);
StartAgentRequestId(id)
}
/// Links a pending request to its freshly-created child
/// conversation so subsequent history events can find it.
fn record_child_conversation(
&mut self,
request_id: StartAgentRequestId,
child_conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
let Some(pending) = self.pending.get_mut(&request_id) else {
return;
};
pending.child_conversation_id = Some(child_conversation_id);
self.maybe_complete_pending_for_child_state(request_id, child_conversation_id, ctx);
}
fn find_pending_by_child(
&self,
child_conversation_id: &AIConversationId,
) -> Option<StartAgentRequestId> {
self.pending.iter().find_map(|(id, pending)| {
(pending.child_conversation_id.as_ref() == Some(child_conversation_id)).then_some(*id)
})
}
fn complete_pending_as_started(
&mut self,
request_id: StartAgentRequestId,
child_conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
let Some(pending) = self.pending.remove(&request_id) else {
return;
};
let agent_id = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&child_conversation_id)
.and_then(|conversation| conversation.orchestration_agent_id());
match agent_id {
Some(id) => {
let _ = pending.sender.try_send(StartAgentOutcome::Started {
agent_id: id.clone(),
});
OrchestrationEventStreamer::handle(ctx).update(ctx, |streamer, ctx| {
streamer.register_watched_run_id(pending.parent_conversation_id, id, ctx);
});
}
None => {
log::error!(
"No agent identifier found for child conversation {child_conversation_id:?}"
);
let _ = pending.sender.try_send(StartAgentOutcome::Error(
"Server did not assign an agent identifier".to_string(),
));
}
}
}
fn complete_pending_as_error(
&mut self,
request_id: StartAgentRequestId,
child_conversation_id: AIConversationId,
error_msg: String,
ctx: &mut ModelContext<Self>,
) {
let Some(pending) = self.pending.remove(&request_id) else {
return;
};
let _ = pending.sender.try_send(StartAgentOutcome::Error(error_msg));
// A child that reaches `complete_pending_as_error` never obtained an
// agent id, so it failed at the launch stage. Clean up its hidden
// pane + conversation so the orchestration pill bar does not retain a
// dead chip — but only for terminal failures, leaving recoverable
// `Blocked` startup states (e.g. awaiting GitHub auth) intact.
let should_cleanup = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&child_conversation_id)
.is_some_and(|conversation| should_cleanup_failed_child_launch(conversation.status()));
if should_cleanup {
ctx.emit(StartAgentExecutorEvent::CleanupFailedChildLaunch {
conversation_id: child_conversation_id,
});
}
}
fn maybe_complete_pending_for_child_state(
&mut self,
request_id: StartAgentRequestId,
child_conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
let Some(conversation) =
BlocklistAIHistoryModel::as_ref(ctx).conversation(&child_conversation_id)
else {
return;
};
if let Some(error_msg) = start_agent_error_message_for_status(
conversation.status(),
conversation.status_error_message().as_deref(),
) {
self.complete_pending_as_error(request_id, child_conversation_id, error_msg, ctx);
return;
}
if conversation.orchestration_agent_id().is_some() {
self.complete_pending_as_started(request_id, child_conversation_id, ctx);
}
}
fn handle_history_event(
&mut self,
_: ModelHandle<BlocklistAIHistoryModel>,
event: &BlocklistAIHistoryEvent,
ctx: &mut ModelContext<Self>,
) {
match event {
BlocklistAIHistoryEvent::StartedNewConversation {
new_conversation_id,
..
} => {
if self.pending.is_empty() {
return;
}
let history = BlocklistAIHistoryModel::as_ref(ctx);
let Some(conversation) = history.conversation(new_conversation_id) else {
return;
};
let parent_id = conversation.parent_conversation_id();
// Find the first pending entry that matches this parent and hasn't been assigned a child yet.
if let Some(pending) = self.pending.iter_mut().find(|p| {
p.child_conversation_id.is_none() && parent_id == Some(p.parent_conversation_id)
}) {
pending.child_conversation_id = Some(*new_conversation_id);
}
}
BlocklistAIHistoryEvent::ConversationServerTokenAssigned {
conversation_id, ..
} => {
let Some(idx) = self
.pending
.iter()
.position(|p| p.child_conversation_id.as_ref() == Some(conversation_id))
else {
let Some(request_id) = self.find_pending_by_child(conversation_id) else {
return;
};
// Don't remove yet if we're waiting for completion — we need
// the entry to stay so UpdatedConversationStatus can find it.
if self.pending[idx].wait_for_completion {
// Just log and continue — we'll resolve on Success status.
let conversation =
BlocklistAIHistoryModel::as_ref(ctx).conversation(conversation_id);
let agent_id = conversation
.and_then(|c| c.orchestration_agent_id())
.or_else(|| {
conversation.and_then(|c| {
c.server_conversation_token()
.map(|t| t.as_str().to_string())
})
})
.unwrap_or_else(|| conversation_id.to_string());
log::info!(
"[start_agent] Child agent started: conversation_id={:?}, agent_id={}",
conversation_id,
agent_id
);
log::info!(
"[start_agent] Local mode: waiting for child {:?} to complete",
conversation_id
);
return;
}
let pending = self.pending.remove(idx);
let conversation =
BlocklistAIHistoryModel::as_ref(ctx).conversation(conversation_id);
// orchestration_agent_id() uses run_id in v2 mode, which won't
// exist for locally-spawned Bedrock child agents. Fall back to
// the server conversation token (set by the stream Init event)
// or the conversation ID itself as the agent identifier.
let agent_id = conversation
.and_then(|c| c.orchestration_agent_id())
.or_else(|| {
conversation.and_then(|c| {
c.server_conversation_token()
.map(|t| t.as_str().to_string())
})
})
.unwrap_or_else(|| conversation_id.to_string());
log::info!(
"[start_agent] Child agent started: conversation_id={:?}, agent_id={}",
conversation_id,
agent_id
);
// Only register with the orchestration streamer if the parent
// has a run_id (server-assigned). Local Bedrock child agents
// don't have server-side orchestration — attempting to open an
// SSE stream to a non-existent server freezes the app.
let parent_has_run_id = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&pending.parent_conversation_id)
.and_then(|c| c.run_id())
.is_some();
if pending.wait_for_completion {
// Local mode: don't resolve yet — wait for the child to
// finish (UpdatedConversationStatus → Success) so we can
// return its output as the tool result.
log::info!(
"[start_agent] Local mode: waiting for child {:?} to complete",
conversation_id
);
} else {
let _ = pending.sender.try_send(StartAgentDecision::Started {
agent_id: agent_id.clone(),
});
if parent_has_run_id {
if FeatureFlag::OrchestrationV2.is_enabled() {
OrchestrationEventStreamer::handle(ctx).update(ctx, |streamer, ctx| {
streamer.register_watched_run_id(
pending.parent_conversation_id,
agent_id,
ctx,
);
});
} else {
OrchestrationEventService::handle(ctx).update(ctx, |svc, ctx| {
svc.emit_child_startup_started(*conversation_id, ctx);
});
}
}
}
self.complete_pending_as_started(request_id, *conversation_id, ctx);
}
BlocklistAIHistoryEvent::UpdatedConversationStatus {
conversation_id, ..
} => {
let Some(idx) = self
.pending
.iter()
.position(|p| p.child_conversation_id.as_ref() == Some(conversation_id))
else {
let Some(request_id) = self.find_pending_by_child(conversation_id) else {
return;
};
let history = BlocklistAIHistoryModel::as_ref(ctx);
let Some(conversation) = history.conversation(conversation_id) else {
return;
};
match conversation.status() {
ConversationStatus::Success => {
if !self.pending[idx].wait_for_completion {
// Non-blocking mode — already resolved on token assignment.
return;
}
let pending = self.pending.remove(idx);
// Extract the child's text output from its last exchange.
let output = extract_child_output(conversation);
log::info!(
"[start_agent] Child agent {:?} completed with {} chars of output",
conversation_id,
output.len()
);
let agent_id = conversation
.orchestration_agent_id()
.or_else(|| {
conversation
.server_conversation_token()
.map(|t| t.as_str().to_string())
})
.unwrap_or_else(|| conversation_id.to_string());
let _ = pending
.sender
.try_send(StartAgentDecision::Completed { agent_id, output });
}
status => {
let error_msg = start_agent_error_message_for_status(
status,
conversation.status_error_message(),
);
if let Some(error_msg) = error_msg {
let pending = self.pending.remove(idx);
let _ = pending
.sender
.try_send(StartAgentDecision::Error(error_msg.clone()));
if !FeatureFlag::OrchestrationV2.is_enabled() {
OrchestrationEventService::handle(ctx).update(ctx, |svc, ctx| {
svc.emit_child_startup_errored(
*conversation_id,
"conversation_status".to_string(),
error_msg,
ctx,
);
});
}
}
}
let error_msg = start_agent_error_message_for_status(
conversation.status(),
conversation.status_error_message().as_deref(),
);
if let Some(error_msg) = error_msg {
self.complete_pending_as_error(request_id, *conversation_id, error_msg, ctx);
}
}
BlocklistAIHistoryEvent::CreatedSubtask { .. }
BlocklistAIHistoryEvent::NewConversationRequestComplete {
request_id,
conversation_id,
} => {
self.record_child_conversation(*request_id, *conversation_id, ctx);
}
BlocklistAIHistoryEvent::StartedNewConversation { .. }
| BlocklistAIHistoryEvent::CreatedSubtask { .. }
| BlocklistAIHistoryEvent::UpgradedTask { .. }
| BlocklistAIHistoryEvent::AppendedExchange { .. }
| BlocklistAIHistoryEvent::ReassignedExchange { .. }
| BlocklistAIHistoryEvent::UpdatedStreamingExchange { .. }
| BlocklistAIHistoryEvent::SetActiveConversation { .. }
| BlocklistAIHistoryEvent::ClearedActiveConversation { .. }
| BlocklistAIHistoryEvent::ClearedConversationsInTerminalView { .. }
| BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface { .. }
| BlocklistAIHistoryEvent::UpdatedTodoList { .. }
| BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. }
| BlocklistAIHistoryEvent::SplitConversation { .. }
| BlocklistAIHistoryEvent::RemoveConversation { .. }
| BlocklistAIHistoryEvent::DeletedConversation { .. }
| BlocklistAIHistoryEvent::RestoredConversations { .. }
| BlocklistAIHistoryEvent::UpdatedConversationTitle { .. }
| BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. }
| BlocklistAIHistoryEvent::UpdatedConversationArtifacts { .. } => {}
| BlocklistAIHistoryEvent::UpdatedConversationArtifacts { .. }
| BlocklistAIHistoryEvent::ConversationTransferredBetweenTerminalSurfaces { .. } => {}
BlocklistAIHistoryEvent::OrchestrationConfigUpdated { .. }
| BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { .. }
| BlocklistAIHistoryEvent::LocalSharedSessionEstablished { .. } => {}
}
}
@@ -311,21 +339,48 @@ impl StartAgentExecutor {
let prompt = prompt.clone();
let version = *version;
let parent_conversation_id = input.conversation_id;
let (execution_mode, parent_run_id) = match execution_mode.clone() {
StartAgentExecutionMode::Local { harness_type: None } => {
// Legacy local Oz child agents do not use
// StartAgentRequest.parent_run_id. Instead, the child
// conversation is linked back to its parent on the first
// request via Request.metadata.parent_agent_id, sourced
// from the conversation's versioned orchestration_agent_id()
// (run_id in v2, server conversation token in v1). Remote
// child agents and local third-party harness children need
// parent_run_id here because their run is spawned before that
// first child request exists.
(StartAgentExecutionMode::Local { harness_type: None }, None)
let (prompt, execution_mode) =
normalize_legacy_local_child_harness_command(prompt, execution_mode.clone());
let (execution_mode, parent_run_id) = match execution_mode {
StartAgentExecutionMode::Local {
harness_type: None,
model_id,
} => {
// Oz local children resolve their parent's run id from the
// parent conversation. This mirrors the third-party-harness
// and remote-child branches below; the child task row is
// created eagerly at dispatch (see
// `launch_local_no_harness_child`) using this value as the
// `parent_run_id` on `CreateAgentTask`. Bail out if the
// parent has no `run_id` yet — the eager-create path has no
// late-binding fallback (the pre-change lazy path would have
// linked via `Request.metadata.parent_agent_id` later), so
// proceeding would mint an orphan child with no server-side
// parent linkage.
let parent_run_id = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&parent_conversation_id)
.and_then(|conversation| conversation.run_id());
let Some(parent_run_id) = parent_run_id else {
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
StartAgentResult::Error {
error:
"Local Oz child agents require the parent run_id to be available."
.to_string(),
version,
},
));
};
(
StartAgentExecutionMode::Local {
harness_type: None,
model_id,
},
Some(parent_run_id),
)
}
StartAgentExecutionMode::Local {
harness_type: Some(harness_type),
model_id,
} => {
let Some(harness) = Harness::parse_local_child_harness(&harness_type) else {
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
@@ -335,12 +390,10 @@ impl StartAgentExecutor {
},
));
};
if !FeatureFlag::OrchestrationV2.is_enabled() {
if let Some(message) = local_harness_product_disabled_message(harness) {
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
StartAgentResult::Error {
error: "Local harness child agents require orchestration v2."
.to_string(),
error: message.to_string(),
version,
},
));
@@ -363,6 +416,7 @@ impl StartAgentExecutor {
(
StartAgentExecutionMode::Local {
harness_type: Some(harness.to_string()),
model_id,
},
Some(parent_run_id),
)
@@ -375,16 +429,8 @@ impl StartAgentExecutor {
worker_host,
harness_type,
title,
auth_secret_name,
} => {
if !FeatureFlag::OrchestrationV2.is_enabled() {
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
StartAgentResult::Error {
error: "Remote child agents require orchestration v2.".to_string(),
version,
},
));
}
let harness_type = Harness::parse_orchestration_harness(&harness_type)
.map(|harness| harness.to_string())
.unwrap_or(harness_type);
@@ -408,7 +454,6 @@ impl StartAgentExecutor {
with an empty environment."
);
}
let parent_run_id = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&parent_conversation_id)
.and_then(|conversation| conversation.run_id());
@@ -431,6 +476,7 @@ impl StartAgentExecutor {
worker_host,
harness_type,
title,
auth_secret_name,
},
Some(parent_run_id),
)
@@ -442,40 +488,37 @@ impl StartAgentExecutor {
let wait_for_completion = parent_run_id.is_none();
let (sender, receiver) = async_channel::bounded(1);
self.pending.push(PendingStartAgent {
parent_conversation_id,
child_conversation_id: None,
sender,
wait_for_completion,
});
let request_id = self.next_request_id();
self.pending.insert(
request_id,
PendingStartAgent {
parent_conversation_id,
child_conversation_id: None,
sender,
},
);
ctx.emit(StartAgentExecutorEvent::CreateAgent(StartAgentRequest {
name: name.clone(),
prompt,
execution_mode,
lifecycle_subscription: lifecycle_subscription.clone(),
parent_conversation_id,
parent_run_id,
}));
ctx.emit(StartAgentExecutorEvent::CreateAgent(Box::new(
StartAgentRequest {
id: request_id,
name: name.clone(),
prompt,
execution_mode,
lifecycle_subscription: lifecycle_subscription.clone(),
parent_conversation_id,
parent_run_id,
},
)));
ActionExecution::new_async(async move { receiver.recv().await }, move |result, _ctx| {
match result {
Ok(StartAgentDecision::Started { agent_id }) => {
Ok(StartAgentOutcome::Started { agent_id }) => {
AIAgentActionResultType::StartAgent(StartAgentResult::Success {
agent_id,
version,
})
}
Ok(StartAgentDecision::Completed { agent_id, output }) => {
// Return the child's output as a "success" with the output
// embedded in the agent_id field. The Display impl on
// StartAgentResult will show this to the model.
AIAgentActionResultType::StartAgent(StartAgentResult::Success {
agent_id: format!("{agent_id}\n\nAgent output:\n{output}"),
version,
})
}
Ok(StartAgentDecision::Error(error)) => {
Ok(StartAgentOutcome::Error(error)) => {
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
}
Err(_) => {
@@ -485,6 +528,45 @@ impl StartAgentExecutor {
})
}
/// Dispatch a pre-validated StartAgent request. Returns a receiver
/// for the resulting [`StartAgentOutcome`].
#[allow(clippy::too_many_arguments)]
pub fn dispatch(
&mut self,
name: String,
prompt: String,
execution_mode: StartAgentExecutionMode,
lifecycle_subscription: Option<Vec<LifecycleEventType>>,
parent_conversation_id: AIConversationId,
parent_run_id: Option<String>,
ctx: &mut ModelContext<Self>,
) -> async_channel::Receiver<StartAgentOutcome> {
let (prompt, execution_mode) =
normalize_legacy_local_child_harness_command(prompt, execution_mode);
let (sender, receiver) = async_channel::bounded(1);
let request_id = self.next_request_id();
self.pending.insert(
request_id,
PendingStartAgent {
parent_conversation_id,
child_conversation_id: None,
sender,
},
);
ctx.emit(StartAgentExecutorEvent::CreateAgent(Box::new(
StartAgentRequest {
id: request_id,
name,
prompt,
execution_mode,
lifecycle_subscription,
parent_conversation_id,
parent_run_id,
},
)));
receiver
}
pub(super) fn preprocess_action(
&mut self,
_action: PreprocessActionInput,
@@ -494,22 +576,19 @@ impl StartAgentExecutor {
}
}
/// Extracts the text output from a child agent's conversation.
/// Collects text from all exchanges in the conversation.
fn extract_child_output(conversation: &AIConversation) -> String {
let mut output_parts = Vec::new();
for exchange in conversation.all_exchanges() {
if let Some(output) = exchange.output_status.output() {
let text = output.get().format_for_copy(None);
if !text.is_empty() {
output_parts.push(text);
}
}
}
if output_parts.is_empty() {
"Agent completed but produced no text output.".to_string()
} else {
output_parts.join("\n\n")
/// Whether a child that failed before launch should have its hidden pane and
/// conversation cleaned up. Only terminal launch failures qualify; recoverable
/// `Blocked` startup states (e.g. awaiting GitHub auth) and non-terminal
/// `TransientError` (a recovery is in flight) keep their chip so the user can
/// resolve them or let the retry complete.
fn should_cleanup_failed_child_launch(status: &ConversationStatus) -> bool {
match status {
ConversationStatus::Error | ConversationStatus::Cancelled => true,
ConversationStatus::Blocked { .. }
| ConversationStatus::InProgress
| ConversationStatus::TransientError
| ConversationStatus::Success
| ConversationStatus::WaitingForEvents => false,
}
}
@@ -535,7 +614,16 @@ fn start_agent_error_message_for_status(
blocked_action.to_string()
})
}
ConversationStatus::InProgress | ConversationStatus::Success => None,
// `WaitingForEvents` is treated like `InProgress`/`Success` here:
// a child that's actively waiting for events has, by definition,
// already initialized successfully and is not an error case.
// TransientError is likewise non-terminal: a recovery is in flight,
// so keep waiting. The agent run is still in flight in all of these
// cases, so we don't surface an error message for the start path.
ConversationStatus::InProgress
| ConversationStatus::TransientError
| ConversationStatus::Success
| ConversationStatus::WaitingForEvents => None,
}
}
@@ -544,7 +632,13 @@ impl Entity for StartAgentExecutor {
}
pub enum StartAgentExecutorEvent {
CreateAgent(StartAgentRequest),
CreateAgent(Box<StartAgentRequest>),
/// A child agent failed at the launch stage (never started a server-side
/// run). The owning terminal view removes its hidden pane and conversation
/// so the orchestration pill bar does not retain a dead chip.
CleanupFailedChildLaunch {
conversation_id: AIConversationId,
},
}
#[cfg(test)]
@@ -1,24 +1,45 @@
use ai::agent::action_result::StartAgentVersion;
use galaxy_core::features::FeatureFlag;
use warpui::{App, Entity, EntityId, ModelHandle};
use super::*;
use crate::ai::agent::conversation::ConversationStatus;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType,
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, RenderableAIError,
StartAgentExecutionMode, StartAgentResult,
};
use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer;
use crate::ai::blocklist::BlocklistAIHistoryModel;
use ai::agent::action_result::StartAgentVersion;
use galaxy_core::features::FeatureFlag;
use galaxyui::{App, EntityId};
use crate::server::server_api::ServerApiProvider;
use crate::test_util::settings::initialize_history_persistence_for_tests;
const FIRST_REQUEST_ID: StartAgentRequestId = StartAgentRequestId::from_raw_for_test(0);
/// Stable placeholder run_id assigned to the parent conversation in tests
/// that dispatch an Oz local child. The Oz `Local` arm of
/// `StartAgentExecutor::execute` bails out synchronously if the parent has
/// no `run_id`, so every `local_with_defaults` test needs to assign one.
const PARENT_RUN_ID: &str = "00000000-0000-0000-0000-000000000001";
fn build_start_agent_action(
version: StartAgentVersion,
execution_mode: StartAgentExecutionMode,
) -> AIAgentAction {
build_start_agent_action_with_prompt(version, execution_mode, "Investigate the failure")
}
fn build_start_agent_action_with_prompt(
version: StartAgentVersion,
execution_mode: StartAgentExecutionMode,
prompt: &str,
) -> AIAgentAction {
AIAgentAction {
id: AIAgentActionId::from("start-agent-action".to_string()),
action: AIAgentActionType::StartAgent {
version,
name: "Agent 1".to_string(),
prompt: "Investigate the failure".to_string(),
prompt: prompt.to_string(),
execution_mode,
lifecycle_subscription: None,
},
@@ -28,14 +49,76 @@ fn build_start_agent_action(
}
#[test]
fn execute_returns_error_when_child_startup_is_blocked_before_initialization() {
fn legacy_local_codex_command_prompt_normalizes_to_local_harness() {
let (prompt, execution_mode) = normalize_legacy_local_child_harness_command(
"codex --dangerously-bypass-approvals-and-sandbox 'Investigate the failure'".to_string(),
StartAgentExecutionMode::local_with_defaults(),
);
assert_eq!(prompt, "Investigate the failure");
assert_eq!(
execution_mode,
StartAgentExecutionMode::local_harness("codex".to_string())
);
}
#[test]
fn execute_normalizes_legacy_local_codex_command_before_validation() {
App::test((), |mut app| async move {
let _orchestration_v2 = FeatureFlag::OrchestrationV2.override_enabled(true);
let _local_codex = FeatureFlag::LocalClaudeCodexChildHarnesses.override_enabled(true);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, ctx)
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let action = build_start_agent_action_with_prompt(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
"codex --dangerously-bypass-approvals-and-sandbox 'Investigate the failure'",
);
let execution = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id: parent_conversation_id,
};
let result: AnyActionExecution = executor.execute(input, ctx).into();
result
});
let AnyActionExecution::Sync(result) = execution else {
panic!("expected sync execution");
};
assert!(matches!(
result,
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
if error
== "Local harness child agents require the parent run_id to be available."
&& version == StartAgentVersion::V1
));
});
}
#[test]
fn execute_returns_error_when_child_startup_is_blocked_before_initialization() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
history_model.update(&mut app, |model, ctx| {
model.assign_run_id_for_conversation(
parent_conversation_id,
PARENT_RUN_ID.to_string(),
None,
terminal_view_id,
ctx,
);
});
let action = build_start_agent_action(
StartAgentVersion::V1,
@@ -64,16 +147,25 @@ fn execute_returns_error_when_child_startup_is_blocked_before_initialization() {
terminal_view_id,
"Agent 1".to_string(),
parent_conversation_id,
None,
ctx,
)
});
history_model.update(&mut app, |model, ctx| {
model.record_new_conversation_request_complete(
FIRST_REQUEST_ID,
child_conversation_id,
ctx,
);
});
executor.read(&app, |executor, _| {
assert_eq!(
executor
.pending
.first()
.and_then(|pending| pending.child_conversation_id),
.values()
.find_map(|pending| pending.child_conversation_id),
Some(child_conversation_id)
);
});
@@ -108,14 +200,23 @@ fn execute_returns_error_when_child_startup_is_blocked_before_initialization() {
}
#[test]
fn execute_returns_detailed_error_when_child_startup_fails_before_initialization() {
fn execute_resolves_error_when_request_linkage_happens_after_child_already_failed() {
App::test((), |mut app| async move {
let _orchestration_v2 = FeatureFlag::OrchestrationV2.override_enabled(true);
initialize_history_persistence_for_tests(&mut app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, ctx)
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
history_model.update(&mut app, |model, ctx| {
model.assign_run_id_for_conversation(
parent_conversation_id,
PARENT_RUN_ID.to_string(),
None,
terminal_view_id,
ctx,
);
});
let action = build_start_agent_action(
StartAgentVersion::V1,
@@ -144,16 +245,203 @@ fn execute_returns_detailed_error_when_child_startup_fails_before_initialization
terminal_view_id,
"Agent 1".to_string(),
parent_conversation_id,
None,
ctx,
)
});
history_model.update(&mut app, |history_model, ctx| {
history_model.update_conversation_status_with_error_message(
history_model.update_conversation_status_with_error(
terminal_view_id,
child_conversation_id,
ConversationStatus::Error,
Some("Failed to resolve child agent skills: review-comments".to_string()),
Some(RenderableAIError::other(
"'codex' CLI not found on your machine.",
false,
)),
ctx,
);
});
history_model.update(&mut app, |model, ctx| {
model.record_new_conversation_request_complete(
FIRST_REQUEST_ID,
child_conversation_id,
ctx,
);
});
let async_result = execute_future.await;
let result = app.update(|ctx| on_complete(async_result, ctx));
assert!(matches!(
result,
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
if error == "'codex' CLI not found on your machine."
&& version == StartAgentVersion::V1
));
executor.read(&app, |executor, _| {
assert!(executor.pending.is_empty());
});
});
}
#[test]
fn execute_resolves_success_when_request_linkage_happens_after_child_already_started() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let terminal_view_id = EntityId::new();
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
app.add_singleton_model(OrchestrationEventStreamer::new);
let executor = app.add_model(StartAgentExecutor::new);
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
history_model.update(&mut app, |model, ctx| {
model.assign_run_id_for_conversation(
parent_conversation_id,
PARENT_RUN_ID.to_string(),
None,
terminal_view_id,
ctx,
);
});
let action = build_start_agent_action(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
);
let execution = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id: parent_conversation_id,
};
let result: AnyActionExecution = executor.execute(input, ctx).into();
result
});
let AnyActionExecution::Async {
execute_future,
on_complete,
} = execution
else {
panic!("expected async execution");
};
let child_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_child_conversation(
terminal_view_id,
"Agent 1".to_string(),
parent_conversation_id,
None,
ctx,
)
});
let run_id = uuid::Uuid::new_v4().to_string();
history_model.update(&mut app, |history_model, ctx| {
history_model.assign_run_id_for_conversation(
child_conversation_id,
run_id.clone(),
None,
terminal_view_id,
ctx,
);
});
history_model.update(&mut app, |model, ctx| {
model.record_new_conversation_request_complete(
FIRST_REQUEST_ID,
child_conversation_id,
ctx,
);
});
let async_result = execute_future.await;
let result = app.update(|ctx| on_complete(async_result, ctx));
assert!(matches!(
result,
AIAgentActionResultType::StartAgent(StartAgentResult::Success {
agent_id,
version,
}) if agent_id == run_id && version == StartAgentVersion::V1
));
executor.read(&app, |executor, _| {
assert!(executor.pending.is_empty());
});
});
}
#[test]
fn execute_returns_detailed_error_when_child_startup_fails_before_initialization() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
history_model.update(&mut app, |model, ctx| {
model.assign_run_id_for_conversation(
parent_conversation_id,
PARENT_RUN_ID.to_string(),
None,
terminal_view_id,
ctx,
);
});
let action = build_start_agent_action(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
);
let execution = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id: parent_conversation_id,
};
let result: AnyActionExecution = executor.execute(input, ctx).into();
result
});
let AnyActionExecution::Async {
execute_future,
on_complete,
} = execution
else {
panic!("expected async execution");
};
let child_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_child_conversation(
terminal_view_id,
"Agent 1".to_string(),
parent_conversation_id,
None,
ctx,
)
});
history_model.update(&mut app, |model, ctx| {
model.record_new_conversation_request_complete(
FIRST_REQUEST_ID,
child_conversation_id,
ctx,
);
});
history_model.update(&mut app, |history_model, ctx| {
history_model.update_conversation_status_with_error(
terminal_view_id,
child_conversation_id,
ConversationStatus::Error,
Some(RenderableAIError::other(
"Failed to resolve child agent skills: review-comments",
false,
)),
ctx,
);
});
@@ -170,13 +458,23 @@ fn execute_returns_detailed_error_when_child_startup_fails_before_initialization
}
#[test]
fn execute_returns_error_when_local_harness_child_requires_orchestration_v2() {
fn execute_accepts_local_harness_child_when_parent_run_id_is_available() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, ctx)
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
history_model.update(&mut app, |model, ctx| {
model.assign_run_id_for_conversation(
parent_conversation_id,
PARENT_RUN_ID.to_string(),
None,
terminal_view_id,
ctx,
);
});
let action = build_start_agent_action(
StartAgentVersion::V2,
@@ -191,66 +489,31 @@ fn execute_returns_error_when_local_harness_child_requires_orchestration_v2() {
let result: AnyActionExecution = executor.execute(input, ctx).into();
result
});
let AnyActionExecution::Sync(result) = execution else {
panic!("expected sync execution");
let AnyActionExecution::Async {
execute_future,
on_complete,
} = execution
else {
panic!("expected async execution");
};
assert!(matches!(
result,
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
if error == "Local harness child agents require orchestration v2."
&& version == StartAgentVersion::V2
));
});
}
#[test]
fn execute_rejects_invalid_local_harness_names_before_pane_creation() {
App::test((), |mut app| async move {
let _orchestration_v2 = FeatureFlag::OrchestrationV2.override_enabled(true);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, ctx)
});
let action = build_start_agent_action(
StartAgentVersion::V2,
StartAgentExecutionMode::local_harness("codex".to_string()),
);
let execution = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id: parent_conversation_id,
};
let result: AnyActionExecution = executor.execute(input, ctx).into();
result
executor.read(&app, |executor, _| {
assert!(executor.pending.contains_key(&FIRST_REQUEST_ID));
});
let AnyActionExecution::Sync(result) = execution else {
panic!("expected sync execution");
};
assert!(matches!(
result,
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
if error == "Unsupported local child harness 'codex'."
&& version == StartAgentVersion::V2
));
drop(execute_future);
drop(on_complete);
});
}
#[test]
fn execute_returns_error_when_local_harness_child_missing_parent_run_id() {
App::test((), |mut app| async move {
let _orchestration_v2 = FeatureFlag::OrchestrationV2.override_enabled(true);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, ctx)
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let action = build_start_agent_action(
StartAgentVersion::V2,
@@ -281,14 +544,489 @@ fn execute_returns_error_when_local_harness_child_missing_parent_run_id() {
}
#[test]
fn execute_returns_error_when_remote_opencode_harness_is_requested() {
fn execute_rejects_invalid_local_harness_names_before_pane_creation() {
App::test((), |mut app| async move {
let _orchestration_v2 = FeatureFlag::OrchestrationV2.override_enabled(true);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, ctx)
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let action = build_start_agent_action(
StartAgentVersion::V2,
StartAgentExecutionMode::local_harness("gemini".to_string()),
);
let execution = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id: parent_conversation_id,
};
let result: AnyActionExecution = executor.execute(input, ctx).into();
result
});
let AnyActionExecution::Sync(result) = execution else {
panic!("expected sync execution");
};
assert!(matches!(
result,
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
if error == "Unsupported local child harness 'gemini'."
&& version == StartAgentVersion::V2
));
});
}
#[test]
fn execute_rejects_disabled_local_codex_before_other_local_harness_validation() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let action = build_start_agent_action(
StartAgentVersion::V2,
StartAgentExecutionMode::local_harness("codex".to_string()),
);
let execution = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id: parent_conversation_id,
};
let result: AnyActionExecution = executor.execute(input, ctx).into();
result
});
let AnyActionExecution::Sync(result) = execution else {
panic!("expected sync execution");
};
assert!(matches!(
result,
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
if error == "Local Codex child agents are temporarily disabled."
&& version == StartAgentVersion::V2
));
});
}
#[test]
fn execute_allows_local_codex_when_flag_is_enabled() {
App::test((), |mut app| async move {
let _local_codex = FeatureFlag::LocalClaudeCodexChildHarnesses.override_enabled(true);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let action = build_start_agent_action(
StartAgentVersion::V2,
StartAgentExecutionMode::local_harness("codex".to_string()),
);
let execution = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id: parent_conversation_id,
};
let result: AnyActionExecution = executor.execute(input, ctx).into();
result
});
let AnyActionExecution::Sync(result) = execution else {
panic!("expected sync execution");
};
assert!(matches!(
result,
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
if error
== "Local harness child agents require the parent run_id to be available."
&& version == StartAgentVersion::V2
));
});
}
#[test]
fn parallel_dispatch_keeps_two_pendings_distinguishable_by_request_id() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
history_model.update(&mut app, |model, ctx| {
model.assign_run_id_for_conversation(
parent_conversation_id,
PARENT_RUN_ID.to_string(),
None,
terminal_view_id,
ctx,
);
});
let action_a = build_start_agent_action(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
);
let action_b = build_start_agent_action(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
);
executor.update(&mut app, |executor, ctx| {
let _: AnyActionExecution = executor
.execute(
ExecuteActionInput {
action: &action_a,
conversation_id: parent_conversation_id,
},
ctx,
)
.into();
let _: AnyActionExecution = executor
.execute(
ExecuteActionInput {
action: &action_b,
conversation_id: parent_conversation_id,
},
ctx,
)
.into();
});
executor.read(&app, |executor, _| {
assert_eq!(executor.pending.len(), 2, "both pendings should be live");
assert!(executor.pending.contains_key(&FIRST_REQUEST_ID));
assert!(executor
.pending
.contains_key(&StartAgentRequestId::from_raw_for_test(1)));
});
});
}
#[test]
fn parallel_pendings_each_resolve_independently_via_recorded_child_id() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
history_model.update(&mut app, |model, ctx| {
model.assign_run_id_for_conversation(
parent_conversation_id,
PARENT_RUN_ID.to_string(),
None,
terminal_view_id,
ctx,
);
});
let action_a = build_start_agent_action(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
);
let action_b = build_start_agent_action(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
);
let exec_a = executor.update(&mut app, |executor, ctx| {
let result: AnyActionExecution = executor
.execute(
ExecuteActionInput {
action: &action_a,
conversation_id: parent_conversation_id,
},
ctx,
)
.into();
result
});
let exec_b = executor.update(&mut app, |executor, ctx| {
let result: AnyActionExecution = executor
.execute(
ExecuteActionInput {
action: &action_b,
conversation_id: parent_conversation_id,
},
ctx,
)
.into();
result
});
let (
AnyActionExecution::Async {
execute_future: future_a,
on_complete: complete_a,
},
AnyActionExecution::Async {
execute_future: future_b,
on_complete: complete_b,
},
) = (exec_a, exec_b)
else {
panic!("expected async executions");
};
let child_a = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_child_conversation(
terminal_view_id,
"Agent A".to_string(),
parent_conversation_id,
None,
ctx,
)
});
let child_b = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_child_conversation(
terminal_view_id,
"Agent B".to_string(),
parent_conversation_id,
None,
ctx,
)
});
history_model.update(&mut app, |model, ctx| {
model.record_new_conversation_request_complete(FIRST_REQUEST_ID, child_a, ctx);
model.record_new_conversation_request_complete(
StartAgentRequestId::from_raw_for_test(1),
child_b,
ctx,
);
});
history_model.update(&mut app, |history_model, ctx| {
history_model.update_conversation_status_with_error(
terminal_view_id,
child_b,
ConversationStatus::Error,
Some(RenderableAIError::other("Agent B init failed", false)),
ctx,
);
});
let async_b = future_b.await;
let result_b = app.update(|ctx| complete_b(async_b, ctx));
assert!(matches!(
result_b,
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, .. })
if error == "Agent B init failed"
));
executor.read(&app, |executor, _| {
assert_eq!(
executor.pending.len(),
1,
"only child_b's pending should have been removed"
);
assert!(executor.pending.contains_key(&FIRST_REQUEST_ID));
});
drop(future_a);
drop(complete_a);
});
}
#[derive(Default)]
struct CapturedCleanupEvents(Vec<AIConversationId>);
impl Entity for CapturedCleanupEvents {
type Event = ();
}
/// Per-test handles for the launch-cleanup tests, returned by
/// [`dispatch_pending_child_launch`].
struct PendingChildLaunch {
history_model: ModelHandle<BlocklistAIHistoryModel>,
captured: ModelHandle<CapturedCleanupEvents>,
terminal_view_id: EntityId,
child_conversation_id: AIConversationId,
}
/// Dispatches a local child launch and creates (but does not yet link) its
/// child conversation, leaving one in-flight pending in the executor with a
/// model subscribed to capture `CleanupFailedChildLaunch` events. Tests link
/// the child and drive it to a terminal state, then assert on cleanup. The
/// returned executor handle must be kept alive for the duration of the test so
/// the executor (and its history subscription) is not dropped before the child
/// status change is processed.
fn dispatch_pending_child_launch(
app: &mut App,
) -> (PendingChildLaunch, ModelHandle<StartAgentExecutor>) {
initialize_history_persistence_for_tests(app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let captured = app.add_model(|_| CapturedCleanupEvents::default());
captured.update(app, |_, ctx| {
ctx.subscribe_to_model(&executor, |captured, _, event, _ctx| {
if let StartAgentExecutorEvent::CleanupFailedChildLaunch { conversation_id } = event {
captured.0.push(*conversation_id);
}
});
});
let parent_conversation_id = history_model.update(app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
history_model.update(app, |model, ctx| {
model.assign_run_id_for_conversation(
parent_conversation_id,
PARENT_RUN_ID.to_string(),
None,
terminal_view_id,
ctx,
);
});
let action = build_start_agent_action(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
);
// The pending lives in the executor regardless of the returned execution,
// and cleanup is emitted synchronously from the child status update, so the
// action-result plumbing is discarded.
executor.update(app, |executor, ctx| {
let _: AnyActionExecution = executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: parent_conversation_id,
},
ctx,
)
.into();
});
let child_conversation_id = history_model.update(app, |history_model, ctx| {
history_model.start_new_child_conversation(
terminal_view_id,
"Agent 1".to_string(),
parent_conversation_id,
None,
ctx,
)
});
(
PendingChildLaunch {
history_model,
captured,
terminal_view_id,
child_conversation_id,
},
executor,
)
}
/// Links the dispatched child conversation back to its pending request,
/// which lets subsequent child status changes resolve the pending.
fn link_pending_child(state: &PendingChildLaunch, app: &mut App) {
state.history_model.update(app, |model, ctx| {
model.record_new_conversation_request_complete(
FIRST_REQUEST_ID,
state.child_conversation_id,
ctx,
);
});
}
#[test]
fn errored_child_launch_emits_cleanup_event() {
App::test((), |mut app| async move {
let (state, _executor) = dispatch_pending_child_launch(&mut app);
link_pending_child(&state, &mut app);
state.history_model.update(&mut app, |model, ctx| {
model.update_conversation_status_with_error(
state.terminal_view_id,
state.child_conversation_id,
ConversationStatus::Error,
Some(RenderableAIError::other(
"Child agent failed to spawn",
false,
)),
ctx,
);
});
state.captured.read(&app, |captured, _| {
assert_eq!(captured.0, vec![state.child_conversation_id]);
});
});
}
#[test]
fn blocked_child_launch_does_not_emit_cleanup_event() {
App::test((), |mut app| async move {
let (state, _executor) = dispatch_pending_child_launch(&mut app);
link_pending_child(&state, &mut app);
// A recoverable startup block (e.g. awaiting GitHub auth) keeps its
// chip so the user can resolve it.
state.history_model.update(&mut app, |model, ctx| {
model.update_conversation_status(
state.terminal_view_id,
state.child_conversation_id,
ConversationStatus::Blocked {
blocked_action: "GitHub authentication required.".to_string(),
},
ctx,
);
});
state.captured.read(&app, |captured, _| {
assert!(
captured.0.is_empty(),
"blocked startup should not schedule chip cleanup"
);
});
});
}
#[test]
fn successfully_started_child_does_not_emit_cleanup_event() {
App::test((), |mut app| async move {
let (state, _executor) = dispatch_pending_child_launch(&mut app);
// `OrchestrationEventStreamer::new` reads the history singleton, so it
// must be registered after the helper registers it.
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
app.add_singleton_model(OrchestrationEventStreamer::new);
// Assigning a run id before linkage marks the child as started, so the
// executor resolves the pending as a success, not a launch failure.
state.history_model.update(&mut app, |model, ctx| {
model.assign_run_id_for_conversation(
state.child_conversation_id,
uuid::Uuid::new_v4().to_string(),
None,
state.terminal_view_id,
ctx,
);
});
link_pending_child(&state, &mut app);
state.captured.read(&app, |captured, _| {
assert!(
captured.0.is_empty(),
"a child that started successfully should not be cleaned up"
);
});
});
}
#[test]
fn execute_returns_error_when_remote_opencode_harness_is_requested() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let action = build_start_agent_action(
StartAgentVersion::V2,
@@ -300,6 +1038,7 @@ fn execute_returns_error_when_remote_opencode_harness_is_requested() {
worker_host: String::new(),
harness_type: "opencode".to_string(),
title: String::new(),
auth_secret_name: None,
},
);
@@ -1,12 +1,12 @@
use futures::{future::BoxFuture, FutureExt};
use futures::future::BoxFuture;
use futures::FutureExt;
use galaxyui::{Entity, ModelContext};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use crate::ai::agent::{
AIAgentAction, AIAgentActionResultType, AIAgentActionType, SuggestNewConversationResult,
};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
/// Whether the client accepted or rejected the new conversation. We make this a separate type from
/// `SuggestNewConversationResult` for more ergonomic threading of the message_id through
/// the various layers of action handling.
@@ -1,19 +1,17 @@
use futures::{channel::oneshot, future::BoxFuture, FutureExt};
use futures::channel::oneshot;
use futures::future::BoxFuture;
use futures::FutureExt;
use galaxy_core::features::FeatureFlag;
use galaxyui::{Entity, ModelContext};
use crate::{
ai::{
agent::{
conversation::AIConversationId, AIAgentAction, AIAgentActionId, AIAgentActionType,
SuggestPromptRequest, SuggestPromptResult,
},
blocklist::action_model::execute::{
ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput,
},
},
AIAgentActionResultType,
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{
AIAgentAction, AIAgentActionId, AIAgentActionType, SuggestPromptRequest, SuggestPromptResult,
};
use crate::ai::blocklist::action_model::execute::{
ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput,
};
use crate::AIAgentActionResultType;
pub struct PromptSuggestionExecutor {
suggest_prompt_result_tx: Option<oneshot::Sender<SuggestPromptResult>>,
@@ -5,9 +5,13 @@ use std::path::PathBuf;
#[path = "upload_artifact_tests.rs"]
mod tests;
use futures::{future::BoxFuture, FutureExt};
use futures::future::BoxFuture;
use futures::FutureExt;
#[cfg(not(target_family = "wasm"))]
use galaxyui::SingletonEntity;
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use crate::terminal::model::session::active_session::ActiveSession;
#[cfg(not(target_family = "wasm"))]
use crate::{
@@ -19,10 +23,17 @@ use crate::{
},
server::server_api::ServerApiProvider,
};
#[cfg(not(target_family = "wasm"))]
use galaxyui::SingletonEntity;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
#[cfg(not(target_family = "wasm"))]
fn format_upload_artifact_error(err: &anyhow::Error) -> String {
let error_chain = format!("{err:#}");
if error_chain != err.to_string() {
format!("Artifact upload failed: {error_chain}")
} else {
error_chain
}
}
pub struct UploadArtifactExecutor {
#[cfg_attr(target_family = "wasm", allow(dead_code))]
@@ -148,7 +159,7 @@ impl UploadArtifactExecutor {
})
}
Err(err) => AIAgentActionResultType::UploadArtifact(
UploadArtifactResult::Error(err.to_string()),
UploadArtifactResult::Error(format_upload_artifact_error(&err)),
),
},
)
@@ -4,18 +4,21 @@ use std::path::{Path, PathBuf};
use async_channel::unbounded;
use galaxyui::{App, EntityId, ModelHandle};
use super::*;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType,
UploadArtifactRequest, UploadArtifactResult,
};
use crate::ai::blocklist::{BlocklistAIHistoryModel, BlocklistAIPermissions};
use crate::ai::execution_profiles::{profiles::AIExecutionProfilesModel, ActionPermission};
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
use crate::ai::execution_profiles::ActionPermission;
use crate::ai::mcp::templatable_manager::TemplatableMCPServerManager;
use crate::auth::AuthStateProvider;
use crate::cloud_object::model::persistence::CloudModel;
use crate::network::NetworkStatus;
use crate::server::{cloud_objects::update_manager::UpdateManager, sync_queue::SyncQueue};
use crate::server::cloud_objects::update_manager::UpdateManager;
use crate::server::sync_queue::SyncQueue;
use crate::terminal::event::BlockMetadataReceivedEvent;
use crate::terminal::model::block::BlockMetadata;
use crate::terminal::model::session::active_session::ActiveSession;
@@ -25,11 +28,10 @@ use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::terminal::shell::ShellType;
use crate::terminal::ShellLaunchData;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::workspaces::{team_tester::TeamTesterStatus, user_workspaces::UserWorkspaces};
use crate::workspaces::team_tester::TeamTesterStatus;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::LaunchMode;
use super::*;
fn build_upload_artifact_action(file_path: &str) -> AIAgentAction {
AIAgentAction {
id: AIAgentActionId::from("upload-artifact-action".to_string()),
@@ -124,6 +126,29 @@ fn test_shell_launch_data() -> ShellLaunchData {
}
}
#[test]
fn format_upload_artifact_error_preserves_full_error_chain() {
let err = anyhow::anyhow!(
"Artifact upload limit reached: this conversation already has the maximum allowed number of file artifacts (10). Remove an existing artifact or upload fewer files."
)
.context("Failed to create file artifact upload target");
assert_eq!(
format_upload_artifact_error(&err),
"Artifact upload failed: Failed to create file artifact upload target: Artifact upload limit reached: this conversation already has the maximum allowed number of file artifacts (10). Remove an existing artifact or upload fewer files."
);
}
#[test]
fn format_upload_artifact_error_keeps_single_layer_errors() {
let err = anyhow::anyhow!("Failed to open artifact file '/tmp/missing.txt'");
assert_eq!(
format_upload_artifact_error(&err),
"Failed to open artifact file '/tmp/missing.txt'"
);
}
#[test]
fn should_autoexecute_honors_file_read_permissions_for_resolved_path() {
let temp_dir = tempfile::tempdir().unwrap();
@@ -140,7 +165,7 @@ fn should_autoexecute_honors_file_read_permissions_for_resolved_path() {
let executor =
app.add_model(|_| UploadArtifactExecutor::new(active_session, terminal_view_id));
let conversation_id = history.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_view_id, false, false, ctx)
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let action = build_upload_artifact_action("reports/report.txt");
@@ -190,7 +215,7 @@ fn execute_returns_error_when_conversation_has_not_synced_to_server() {
let executor =
app.add_model(|_| UploadArtifactExecutor::new(active_session, terminal_view_id));
let conversation_id = history.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_view_id, false, false, ctx)
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let action = build_upload_artifact_action(&artifact_path.display().to_string());
@@ -1,10 +1,11 @@
use ai::agent::action_result::AIAgentActionResultType;
use futures::{future::BoxFuture, FutureExt};
use futures::future::BoxFuture;
use futures::FutureExt;
use galaxyui::{Entity, ModelContext};
use crate::ai::agent::{AIAgentActionType, UseComputerResult};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use crate::ai::agent::{AIAgentActionType, UseComputerResult};
use crate::features::FeatureFlag;
pub struct UseComputerExecutor;
@@ -42,11 +43,20 @@ impl UseComputerExecutor {
let actions = request.actions.clone();
let screenshot_params = request.screenshot_params;
// Gate per-window targeting behind the client feature flag. When off, the actor forces the
// legacy full-screen path so results are identical to the pre-existing implementation.
let background_enabled = FeatureFlag::BackgroundComputerUse.is_enabled();
ActionExecution::new_async(
async move {
let mut actor = computer_use::create_actor();
match actor
.perform_actions(&actions, computer_use::Options { screenshot_params })
.perform_actions(
&actions,
computer_use::Options {
screenshot_params,
background_enabled,
},
)
.await
{
Ok(result) => UseComputerResult::Success(result),
@@ -0,0 +1,263 @@
//! Executor for `AIAgentActionType::WaitForEvents`.
//!
//! Schedules a watchdog timer so that the wait completes with `Completed`
//! if no events arrive within the idle window.
use std::collections::HashMap;
use std::time::Duration;
use futures::future::BoxFuture;
use futures::FutureExt;
use warpui::r#async::SpawnedFutureHandle;
use warpui::{Entity, EntityId, ModelContext, SingletonEntity};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
use crate::ai::agent::{AIAgentActionResultType, AIAgentActionType, WaitForEventsResult};
use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer;
use crate::ai::blocklist::BlocklistAIHistoryModel;
/// Fallback when `idle_timeout_seconds` is unset (0). Matches the worker
/// VM idle ceiling.
pub(crate) const DEFAULT_ORCHESTRATED_IDLE_TIMEOUT_SECONDS: i32 = 30 * 60;
/// Subtracted from the server-supplied timeout so the client closes the
/// wait before the worker idle-shutdown.
pub(crate) const CLIENT_WATCHDOG_SAFETY_MARGIN: Duration = Duration::from_secs(30);
/// Lower bound after the safety-margin subtraction; keeps tiny stamped
/// values from clamping to 0.
pub(crate) const HARD_FLOOR: Duration = Duration::from_secs(5);
/// Apply the safety margin and hard floor. Non-positive input falls back
/// to the default.
pub(crate) fn watchdog_timeout_for_stamped_seconds(stamped_seconds: i32) -> Duration {
let seconds = if stamped_seconds <= 0 {
DEFAULT_ORCHESTRATED_IDLE_TIMEOUT_SECONDS
} else {
stamped_seconds
};
let stamped = Duration::from_secs(seconds as u64);
stamped
.checked_sub(CLIENT_WATCHDOG_SAFETY_MARGIN)
.filter(|d| *d >= HARD_FLOOR)
.unwrap_or(HARD_FLOOR)
}
/// Currently-in-flight WaitForEvents action state.
struct PendingWait {
tool_call_id: String,
sender: async_channel::Sender<WaitForEventsResult>,
/// Handle to the watchdog timer. Aborted on cancel so the future
/// doesn't survive up to ~30 minutes past supersede.
watchdog_handle: SpawnedFutureHandle,
}
pub struct WaitForEventsExecutor {
terminal_view_id: EntityId,
/// Bumped on each fresh execute(); stale watchdog closures no-op when
/// they fire.
conversation_generation: HashMap<AIConversationId, usize>,
pending: HashMap<AIConversationId, PendingWait>,
}
impl WaitForEventsExecutor {
pub fn new(terminal_view_id: EntityId, _ctx: &mut ModelContext<Self>) -> Self {
Self {
terminal_view_id,
conversation_generation: HashMap::new(),
pending: HashMap::new(),
}
}
pub(super) fn should_autoexecute(
&self,
_input: ExecuteActionInput,
_ctx: &mut ModelContext<Self>,
) -> bool {
// Synthesized from a server-emitted tool call; no confirmation.
true
}
pub(super) fn preprocess_action(
&mut self,
_action: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let AIAgentActionType::WaitForEvents {
tool_call_id,
idle_timeout_seconds,
} = &input.action.action
else {
return ActionExecution::InvalidAction;
};
let tool_call_id = tool_call_id.clone();
let conversation_id = input.conversation_id;
let timeout = watchdog_timeout_for_stamped_seconds(*idle_timeout_seconds);
// Blocking on descendants is the trigger to confirm parent status
// against the server and register for the owner-side ancestor stream,
// so children created out-of-band (Oz CLI / web API) are delivered.
OrchestrationEventStreamer::handle(ctx).update(ctx, |streamer, ctx| {
streamer.register_parent_on_wait(conversation_id, ctx);
});
// Bump the counter so any prior watchdog closure observes a
// stale generation.
let generation = self
.conversation_generation
.entry(conversation_id)
.or_insert(0);
*generation += 1;
let expected_generation = *generation;
// Schedule the watchdog first so we can store its handle in the
// pending entry. The closure no-ops on a stale generation.
let watchdog_tool_call_id = tool_call_id.clone();
let watchdog_handle = ctx.spawn(
async move {
warpui::r#async::Timer::after(timeout).await;
},
move |me, (), ctx| {
me.fire_watchdog_if_current(
conversation_id,
&watchdog_tool_call_id,
expected_generation,
ctx,
);
},
);
// Replace any prior pending entry. Dropping the prior sender wakes
// its receiver with Err and the prior watchdog is aborted so it
// can't fire after the new wait starts.
let (sender, receiver) = async_channel::bounded(1);
if let Some(prev) = self.pending.insert(
conversation_id,
PendingWait {
tool_call_id: tool_call_id.clone(),
sender,
watchdog_handle,
},
) {
prev.watchdog_handle.abort();
drop(prev.sender);
}
// Flip the conversation into WaitingForEvents before returning so
// downstream subscribers see the yield immediately.
let terminal_view_id = self.terminal_view_id;
BlocklistAIHistoryModel::handle(ctx).update(ctx, move |history_model, ctx| {
history_model.update_conversation_status(
terminal_view_id,
conversation_id,
ConversationStatus::WaitingForEvents,
ctx,
);
});
ActionExecution::new_async(async move { receiver.recv().await }, move |result, _ctx| {
let wait_result = match result {
Ok(result) => result,
// Sender dropped via external cancellation (e.g. resume
// injection, user query). The action_model removed this
// action from `async_executing_actions` before dropping
// the sender, so the spawn callback that wraps this
// closure will suppress the result. The value here is
// unobservable in that path; pick `Completed` defensively.
Err(_) => WaitForEventsResult::Completed,
};
AIAgentActionResultType::WaitForEvents(wait_result)
})
}
/// Drop the in-flight wait so a later watchdog fire is a no-op.
/// Caller (`BlocklistAIActionExecutor::cancel_running_async_action`)
/// has already removed the action from `async_executing_actions`, so
/// the spawn callback will silently discard the result that surfaces
/// from the dropped sender.
pub(crate) fn cancel_execution(&mut self, tool_call_id: &str) {
let Some(conversation_id) = self
.pending
.iter()
.find(|(_, pending)| pending.tool_call_id == tool_call_id)
.map(|(id, _)| *id)
else {
return;
};
let Some(pending) = self.pending.remove(&conversation_id) else {
return;
};
if let Some(gen) = self.conversation_generation.get_mut(&conversation_id) {
*gen += 1;
}
pending.watchdog_handle.abort();
drop(pending.sender);
}
fn fire_watchdog_if_current(
&mut self,
conversation_id: AIConversationId,
tool_call_id: &str,
expected_generation: usize,
ctx: &mut ModelContext<Self>,
) {
if self
.conversation_generation
.get(&conversation_id)
.copied()
.unwrap_or(0)
!= expected_generation
{
return;
}
let Some(pending) = self.pending.get(&conversation_id) else {
return;
};
if pending.tool_call_id != tool_call_id {
return;
}
// Defensive: only fire if the conversation is still waiting. If
// some other path transitioned the conversation out of
// `WaitingForEvents` without going through `cancel_execution`,
// sending `Completed` now would inject a stale tool-call result
// into a conversation the server has long since moved past.
let still_waiting = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.is_some_and(|c| matches!(c.status(), ConversationStatus::WaitingForEvents));
if !still_waiting {
log::info!(
"WaitForEventsExecutor: watchdog stale (conversation no longer waiting); \
dropping pending entry conversation_id={conversation_id:?} \
tool_call_id={tool_call_id}"
);
self.pending.remove(&conversation_id);
return;
}
let Some(pending) = self.pending.remove(&conversation_id) else {
return;
};
log::info!(
"WaitForEventsExecutor: watchdog fired conversation_id={conversation_id:?} \
tool_call_id={tool_call_id}"
);
let _ = pending.sender.try_send(WaitForEventsResult::Completed);
}
}
impl Entity for WaitForEventsExecutor {
type Event = ();
}
#[cfg(test)]
#[path = "wait_for_events_tests.rs"]
mod tests;
@@ -0,0 +1,163 @@
//! Unit tests for the pure helpers in `wait_for_events`, plus an App-based
//! test of the executor's parent-registration wiring.
use std::sync::Arc;
use std::time::Duration;
use galaxy_core::features::FeatureFlag;
use warpui::{App, EntityId};
use super::{
watchdog_timeout_for_stamped_seconds, AnyActionExecution, ExecuteActionInput,
WaitForEventsExecutor, CLIENT_WATCHDOG_SAFETY_MARGIN,
DEFAULT_ORCHESTRATED_IDLE_TIMEOUT_SECONDS, HARD_FLOOR,
};
use crate::ai::agent::conversation::{AIConversation, ConversationStatus};
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{AIAgentAction, AIAgentActionId, AIAgentActionType};
use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer;
use crate::ai::blocklist::BlocklistAIHistoryModel;
use crate::server::server_api::ai::{AIClient, MockAIClient};
use crate::server::server_api::ServerApiProvider;
#[test]
fn watchdog_timeout_constants_match_documented_values() {
// The behavioural tests below assert the contract; this trips if
// someone moves a constant without updating the documented intent.
assert_eq!(DEFAULT_ORCHESTRATED_IDLE_TIMEOUT_SECONDS, 30 * 60);
assert_eq!(CLIENT_WATCHDOG_SAFETY_MARGIN, Duration::from_secs(30));
assert_eq!(HARD_FLOOR, Duration::from_secs(5));
}
#[test]
fn watchdog_timeout_subtracts_margin_for_stamped_minute() {
// A 60s stamped timeout has 30s of headroom after subtracting the
// safety margin — that's the canonical "happy path" the safety
// margin is designed for.
assert_eq!(
watchdog_timeout_for_stamped_seconds(60),
Duration::from_secs(30)
);
}
#[test]
fn watchdog_timeout_clamps_to_hard_floor_when_stamped_value_is_too_small() {
// A 10s stamped timeout would become negative after subtracting the
// 30s safety margin — the hard floor kicks in so the watchdog still
// fires after a finite delay.
assert_eq!(
watchdog_timeout_for_stamped_seconds(10),
HARD_FLOOR,
"stamped 10s should clamp to HARD_FLOOR after subtracting the safety margin"
);
}
#[test]
fn watchdog_timeout_falls_back_to_default_minus_margin_when_unset() {
// Prost flattens scalars, so the proto's "unset" looks like `0` on
// the Rust side; treat that as "use the default minus margin".
let expected = Duration::from_secs(DEFAULT_ORCHESTRATED_IDLE_TIMEOUT_SECONDS as u64)
- CLIENT_WATCHDOG_SAFETY_MARGIN;
assert_eq!(watchdog_timeout_for_stamped_seconds(0), expected);
}
#[test]
fn watchdog_timeout_clamps_negative_value_to_default_minus_margin() {
// Defense against a buggy or malicious payload. `Duration::from_secs`
// takes a `u64`; a negative value would underflow without the clamp.
let expected = Duration::from_secs(DEFAULT_ORCHESTRATED_IDLE_TIMEOUT_SECONDS as u64)
- CLIENT_WATCHDOG_SAFETY_MARGIN;
assert_eq!(watchdog_timeout_for_stamped_seconds(-42), expected);
}
#[test]
fn watchdog_timeout_preserves_large_stamped_value() {
// Server-supplied values well above the margin pass through as
// (stamped - margin). 15 minutes stays at 14m30s after the
// subtraction.
assert_eq!(
watchdog_timeout_for_stamped_seconds(900),
Duration::from_secs(900) - CLIENT_WATCHDOG_SAFETY_MARGIN
);
}
#[test]
fn execute_invokes_parent_registration_and_honors_child_short_circuit() {
// `execute()` must route into the orchestration streamer behind the flag.
// For a child conversation (has_parent_agent), the streamer short-circuits
// without a server fetch (asserted via the mock's times(0) expectation),
// and the wait still flips the conversation into WaitingForEvents.
App::test((), |mut app| async move {
let _flag_guard = FeatureFlag::WaitForEventsParentRegistration.override_enabled(true);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[]));
// A streamer whose server fetch must never be called: the child
// short-circuit precedes any `get_ambient_agent_task` call.
let mut mock = MockAIClient::new();
mock.expect_get_ambient_agent_task().times(0);
let ai_client: Arc<dyn AIClient> = Arc::new(mock);
let server_api = ServerApiProvider::new_for_test().get();
// Held for the lifetime of the test so the mock's times(0) expectation
// is verified on drop; resolved internally by `execute()` via
// `OrchestrationEventStreamer::handle`.
let _streamer = app.add_singleton_model(|ctx| {
OrchestrationEventStreamer::new_with_clients_for_test(ai_client, server_api, ctx)
});
let executor = app.add_model(|ctx| WaitForEventsExecutor::new(terminal_view_id, ctx));
// Child conversation: own run_id plus a parent_agent_id.
let mut conversation = AIConversation::new(false, false);
conversation.set_run_id("550e8400-e29b-41d4-a716-446655440530".to_string());
conversation.set_parent_agent_id("550e8400-e29b-41d4-a716-4466554405fc".to_string());
let conversation_id = conversation.id();
history_model.update(&mut app, |model, ctx| {
model.restore_conversations(terminal_view_id, vec![conversation], ctx);
model.update_conversation_status(
terminal_view_id,
conversation_id,
ConversationStatus::InProgress,
ctx,
);
});
let action = AIAgentAction {
id: AIAgentActionId::from("wait-action".to_string()),
action: AIAgentActionType::WaitForEvents {
tool_call_id: "tool-call-1".to_string(),
idle_timeout_seconds: 600,
},
task_id: TaskId::new("wait-task".to_string()),
requires_result: false,
};
let execution = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id,
};
let result: AnyActionExecution = executor.execute(input, ctx).into();
result
});
assert!(
matches!(execution, AnyActionExecution::Async { .. }),
"WaitForEvents should yield an async execution"
);
history_model.read(&app, |model, _| {
assert!(
matches!(
model.conversation(&conversation_id).map(|c| c.status()),
Some(ConversationStatus::WaitingForEvents)
),
"execute() must flip the conversation into WaitingForEvents"
);
});
// The child short-circuit is asserted by the mock's times(0)
// expectation, verified when `_streamer` drops at test teardown:
// a child conversation must never trigger a `get_ambient_agent_task`
// fetch.
});
}
@@ -97,3 +97,100 @@ mod binary_detection {
assert!(block_on(is_file_content_binary_async(&missing)));
}
}
mod path_shell_quoting {
use super::super::{build_is_file_path_command, build_is_git_repository_command};
use crate::terminal::shell::ShellType;
#[test]
fn is_file_path_quotes_posix_path_as_single_argument() {
let command = build_is_file_path_command("/tmp/repo path/file.rs", ShellType::Bash);
assert_eq!(command, "test -f '/tmp/repo path/file.rs'");
}
#[test]
fn is_file_path_neutralizes_posix_substitutions() {
let command =
build_is_file_path_command("/tmp/x$(touch /tmp/warp-poc)`id`", ShellType::Bash);
assert_eq!(command, "test -f '/tmp/x$(touch /tmp/warp-poc)`id`'");
}
#[test]
fn is_file_path_neutralizes_embedded_quote_posix() {
let command = build_is_file_path_command("/tmp/foo'; rm -rf ~; echo '", ShellType::Bash);
assert_eq!(command, r#"test -f '/tmp/foo'"'"'; rm -rf ~; echo '"'"''"#);
}
#[test]
fn is_file_path_quotes_powershell_path_as_single_argument() {
let command =
build_is_file_path_command(r#"C:\Users\me\file path.rs"#, ShellType::PowerShell);
assert_eq!(
command,
r#"if (Test-Path -PathType Leaf 'C:\Users\me\file path.rs') { exit 0 } else { exit 1 }"#
);
}
#[test]
fn is_file_path_neutralizes_powershell_substitutions() {
let command = build_is_file_path_command(
r#"C:\tmp\x$(New-Item C:\poc)$env:USERPROFILE"#,
ShellType::PowerShell,
);
assert_eq!(
command,
r#"if (Test-Path -PathType Leaf 'C:\tmp\x$(New-Item C:\poc)$env:USERPROFILE') { exit 0 } else { exit 1 }"#
);
}
#[test]
fn is_file_path_neutralizes_fish_embedded_quote() {
let command = build_is_file_path_command("/tmp/owner's file", ShellType::Fish);
assert_eq!(command, r"test -f '/tmp/owner\'s file'");
}
#[test]
fn is_git_repository_quotes_posix_path_as_single_argument() {
let command = build_is_git_repository_command("/tmp/repo path", ShellType::Zsh);
assert_eq!(command, "git -C '/tmp/repo path' rev-parse");
}
#[test]
fn is_git_repository_neutralizes_posix_substitutions() {
let command =
build_is_git_repository_command("/tmp/x$(curl evil.example)`id`", ShellType::Bash);
assert_eq!(command, "git -C '/tmp/x$(curl evil.example)`id`' rev-parse");
}
#[test]
fn is_git_repository_neutralizes_embedded_quote_posix() {
let command =
build_is_git_repository_command("/tmp/foo'; rm -rf ~; echo '", ShellType::Bash);
assert_eq!(
command,
r#"git -C '/tmp/foo'"'"'; rm -rf ~; echo '"'"'' rev-parse"#
);
}
#[test]
fn is_git_repository_neutralizes_powershell_substitutions() {
let command = build_is_git_repository_command(
r#"C:\repo$(New-Item C:\poc)$env:USERPROFILE"#,
ShellType::PowerShell,
);
assert_eq!(
command,
r#"git -C 'C:\repo$(New-Item C:\poc)$env:USERPROFILE' rev-parse"#
);
}
}
@@ -1,7 +1,9 @@
use super::*;
use crate::ai::agent::{task::TaskId, AIAgentAction, AIAgentActionId, AIAgentActionType};
use std::collections::HashSet;
use super::*;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{AIAgentAction, AIAgentActionId, AIAgentActionType};
fn create_test_action(id: AIAgentActionId) -> AIAgentAction {
AIAgentAction {
id,
+4 -2
View File
@@ -1,7 +1,9 @@
use std::{collections::HashMap, sync::Arc};
use std::collections::HashMap;
use std::sync::Arc;
use super::*;
use crate::ai::agent::{task::TaskId, AIAgentActionResultType};
use crate::ai::agent::task::TaskId;
use crate::ai::agent::AIAgentActionResultType;
fn make_action_result(id: &str) -> Arc<AIAgentActionResult> {
Arc::new(AIAgentActionResult {
@@ -1,12 +1,9 @@
use crate::context_chips::{
display_chip::{DisplayChip, GitLineChanges, PromptDisplayChipEvent},
git_line_changes_from_chips,
prompt_type::PromptType,
ChipResult,
};
use galaxyui::{ModelHandle, ViewContext, ViewHandle};
use super::{AgentInputFooter, AgentInputFooterEvent};
use crate::context_chips::display_chip::{DisplayChip, GitLineChanges, PromptDisplayChipEvent};
use crate::context_chips::prompt_type::PromptType;
use crate::context_chips::{git_line_changes_from_chips, ChipResult};
impl AgentInputFooter {
/// Returns `true` if `DisplayChip`s should be recreated based on updated metadata values.
@@ -22,14 +19,9 @@ impl AgentInputFooter {
|| new_chips.iter().enumerate().any(|(i, chip_result)| {
let existing_chip = &existing_chips[i];
existing_chip.read(ctx, |chip, _| {
chip.text()
!= chip_result
.value()
.map(|v| v.to_string())
.unwrap_or_default()
chip.value() != chip_result.value()
|| chip.chip_kind() != chip_result.kind()
// For parity with PromptDisplay: compare the first on-click value only.
|| chip.first_on_click_value() != chip_result.on_click_values().first()
|| chip.on_click_values() != chip_result.on_click_values()
})
})
}
@@ -3,25 +3,22 @@
//! Uses the shared [`ChipConfigurator`] with `LeftRightZones` layout to let users
//! drag/drop chips between left, right, and unused banks.
use settings::Setting as _;
use galaxyui::keymap::FixedBinding;
use galaxyui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
use super::toolbar_item::AgentToolbarItemKind;
use crate::appearance::AppearanceEvent;
use crate::chip_configurator::{
render_chip_editor_modal, render_chip_editor_sections, ChipConfigurator,
ChipConfiguratorAction, ChipConfiguratorLayout, ChipEditorModalConfig, ChipEditorMouseHandles,
ChipEditorSectionsConfig,
};
use crate::report_if_error;
use crate::terminal::session_settings::{
AgentToolbarChipSelection, CLIAgentToolbarChipSelection, SessionSettings,
SessionSettingsChangedEvent, ToolbarChipSelection,
};
use crate::Appearance;
use settings::Setting as _;
use super::toolbar_item::AgentToolbarItemKind;
use crate::{report_if_error, Appearance};
const AGENT_MODAL_TITLE: &str = "Edit agent toolbelt";
const CLI_MODAL_TITLE: &str = "Edit CLI agent toolbelt";
@@ -93,6 +90,25 @@ fn open_toolbar_items_from_settings<V: View>(
)
}
};
// Filter out items that are unavailable due to runtime state (user settings,
// workspace config, etc.) on top of the feature-flag checks in all_available().
let available: Vec<AgentToolbarItemKind> = available
.into_iter()
.filter(|item| item.is_available(ctx))
.collect();
// Drop saved items that are no longer available (e.g. their feature flag was disabled
// or a setting was turned off).
let filter_unavailable = |items: Vec<AgentToolbarItemKind>| -> Vec<AgentToolbarItemKind> {
items
.into_iter()
.filter(|item| available.contains(item))
.collect()
};
let current_left = filter_unavailable(current_left);
let current_right = filter_unavailable(current_right);
chip_configurator.open_left_right_zones_with_items(
current_left,
current_right,
@@ -108,6 +124,15 @@ fn open_default_toolbar_items<V: View>(
) {
let appearance = Appearance::as_ref(ctx);
let (left, right, available) = AgentToolbarItemKind::defaults_for_mode(mode);
let filter_runtime = |items: Vec<AgentToolbarItemKind>| -> Vec<AgentToolbarItemKind> {
items
.into_iter()
.filter(|item| item.is_available(ctx))
.collect()
};
let left = filter_runtime(left);
let right = filter_runtime(right);
let available = filter_runtime(available);
chip_configurator.open_left_right_zones_with_items(left, right, available, appearance);
}
@@ -156,6 +181,19 @@ impl AgentToolbarInlineEditor {
}
});
// Chip colors are derived from the theme, so rebuild the chips from
// settings when the theme changes to keep an open editor readable after a
// theme switch (this inline editor persists its arrangement on every
// edit, so reloading preserves the user's layout).
ctx.subscribe_to_model(&Appearance::handle(ctx), |me, _, event, ctx| {
if matches!(event, AppearanceEvent::ThemeChanged)
&& me.chip_configurator.current_dragging_state.is_none()
{
me.reset_from_settings(ctx);
ctx.notify();
}
});
editor
}
@@ -270,7 +308,21 @@ fn save_toolbar_selection<V: View>(
}
impl AgentToolbarEditorModal {
pub fn new(_ctx: &mut ViewContext<Self>) -> Self {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
// Chip colors are derived from the theme, so rebuild the chips when the
// theme changes to keep an open editor readable after a theme switch.
// Only rebuild while the modal is actually open (has chips) and not
// mid-drag.
ctx.subscribe_to_model(&Appearance::handle(ctx), |me, _, event, ctx| {
if matches!(event, AppearanceEvent::ThemeChanged)
&& me.chip_configurator.current_dragging_state.is_none()
&& me.chip_configurator.has_items()
{
let mode = me.mode;
open_toolbar_items_from_settings(&mut me.chip_configurator, mode, ctx);
ctx.notify();
}
});
Self {
mouse_handles: Default::default(),
chip_configurator: ChipConfigurator::new(ChipConfiguratorLayout::LeftRightZones),
@@ -1,38 +1,101 @@
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::ui::color::blend::Blend;
use galaxy_core::ui::theme::Fill;
use galaxyui::{
elements::{
ChildAnchor, ChildView, ConstrainedBox, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Stack,
},
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
use std::sync::Arc;
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use settings::Setting;
use std::sync::Arc;
use crate::{
ai::ambient_agents::telemetry::CloudAgentTelemetryEvent,
ai::{
cloud_agent_settings::CloudAgentSettings, cloud_environments::CloudAmbientAgentEnvironment,
},
appearance::Appearance,
cloud_object::model::{generic_string_model::StringModel, persistence::CloudModel},
context_chips::display_menu::{
ChipMenuType, DisplayChipMenu, FixedFooter, GenericMenuItem, PromptDisplayMenuEvent,
},
report_if_error,
server::ids::SyncId,
terminal::input::{MenuPositioning, MenuPositioningProvider},
ui_components::icons::Icon,
view_components::action_button::{ActionButton, ActionButtonTheme, ButtonSize},
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::ui::color::blend::Blend;
use galaxy_core::ui::theme::Fill;
use galaxyui::elements::{
ChildAnchor, ChildView, ConstrainedBox, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Stack,
};
use galaxyui::{
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
use super::{AgentInputButtonTheme, AmbientAgentViewModel};
use crate::ai::ambient_agents::telemetry::CloudAgentTelemetryEvent;
use crate::ai::cloud_agent_settings::CloudAgentSettings;
use crate::ai::cloud_environments::CloudAmbientAgentEnvironment;
use crate::appearance::Appearance;
use crate::cloud_object::model::generic_string_model::StringModel;
use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::CloudObjectLookup as _;
use crate::context_chips::display_menu::{
ChipMenuType, DisplayChipMenu, FixedFooter, GenericMenuItem, PromptDisplayMenuEvent,
};
use crate::report_if_error;
use crate::server::ids::SyncId;
use crate::terminal::input::{
HandoffComposeState, HandoffComposeStateEvent, MenuPositioning, MenuPositioningProvider,
};
use crate::terminal::view::ambient_agent::AmbientAgentViewModelEvent;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{ActionButton, ActionButtonTheme, ButtonSize};
/// Normalizes ambient-agent and handoff environment selection state behind one API.
#[derive(Clone)]
pub(crate) enum EnvironmentSelectorTarget {
CloudPane(ModelHandle<AmbientAgentViewModel>),
Handoff(ModelHandle<HandoffComposeState>),
}
impl EnvironmentSelectorTarget {
fn selected_environment_id(&self, ctx: &AppContext) -> Option<SyncId> {
match self {
Self::CloudPane(model) => model.as_ref(ctx).selected_environment_id().cloned(),
Self::Handoff(state) => state.as_ref(ctx).selected_environment_id().cloned(),
}
}
fn set_environment_id(
&self,
environment_id: Option<SyncId>,
is_explicit: bool,
ctx: &mut ViewContext<EnvironmentSelector>,
) {
match self {
Self::CloudPane(model) => {
model.update(ctx, |model, ctx| {
model.set_environment_id(environment_id, ctx);
});
}
Self::Handoff(state) => {
state.update(ctx, |state, ctx| {
state.set_environment_id(environment_id, is_explicit, ctx);
});
}
}
}
fn ensure_default_environment_id(
&self,
environment_id: SyncId,
ctx: &mut ViewContext<EnvironmentSelector>,
) {
match self {
Self::CloudPane(model) => {
model.update(ctx, |model, ctx| {
model.set_environment_id(Some(environment_id), ctx);
});
}
Self::Handoff(state) => {
state.update(ctx, |state, ctx| {
state.ensure_default_environment_id(environment_id, ctx);
});
}
}
}
fn is_configuring(&self, ctx: &AppContext) -> bool {
match self {
Self::CloudPane(model) => model.as_ref(ctx).is_configuring_ambient_agent(),
Self::Handoff(state) => state.as_ref(ctx).is_active(),
}
}
}
/// A selector component for choosing an ambient agent environment.
pub struct EnvironmentSelector {
@@ -40,7 +103,7 @@ pub struct EnvironmentSelector {
dropdown: ViewHandle<DisplayChipMenu>,
is_menu_open: bool,
menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
ambient_agent_model: ModelHandle<AmbientAgentViewModel>,
target: EnvironmentSelectorTarget,
}
pub enum EnvironmentSelectorEvent {
@@ -117,7 +180,7 @@ impl GenericMenuItem for NewEnvironmentMenuItem {
}
}
fn sort_environments_by_recency(environments: &mut [CloudAmbientAgentEnvironment]) {
pub(crate) fn sort_environments_by_recency(environments: &mut [CloudAmbientAgentEnvironment]) {
environments.sort_by(|a, b| {
// Sort by last-used timestamp descending (most recent first), then by display name ascending
b.metadata
@@ -136,7 +199,7 @@ fn sort_environments_by_recency(environments: &mut [CloudAmbientAgentEnvironment
impl EnvironmentSelector {
pub fn new(
menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
ambient_agent_model: ModelHandle<AmbientAgentViewModel>,
target: EnvironmentSelectorTarget,
ctx: &mut ViewContext<Self>,
) -> Self {
let button = ctx.add_typed_action_view(|_ctx| {
@@ -190,9 +253,7 @@ impl EnvironmentSelector {
ctx
);
if me.is_configuring(ctx) {
me.ambient_agent_model.update(ctx, |model, ctx| {
model.set_environment_id(Some(env_item.id), ctx);
});
me.target.set_environment_id(Some(env_item.id), true, ctx);
// Persist the selection to settings for next time.
me.save_selected_environment_to_settings(env_item.id, ctx);
}
@@ -212,19 +273,39 @@ impl EnvironmentSelector {
ctx.notify();
});
ctx.subscribe_to_model(&ambient_agent_model, |me, _, event, ctx| {
use crate::terminal::view::ambient_agent::AmbientAgentViewModelEvent;
if let AmbientAgentViewModelEvent::EnvironmentSelected = event {
me.refresh_menu(ctx);
match &target {
EnvironmentSelectorTarget::CloudPane(model) => {
ctx.subscribe_to_model(model, |me, _, event, ctx| {
if let AmbientAgentViewModelEvent::EnvironmentSelected = event {
me.refresh_menu(ctx);
}
me.refresh_button(ctx);
});
}
me.refresh_button(ctx);
});
EnvironmentSelectorTarget::Handoff(state) => {
ctx.subscribe_to_model(state, |me, _, event, ctx| {
match event {
HandoffComposeStateEvent::ActiveChanged => {
if !me.is_configuring(ctx) {
me.set_menu_visibility(false, ctx);
}
me.ensure_default_selection(ctx);
me.refresh_menu(ctx);
}
HandoffComposeStateEvent::EnvironmentSelected => {
me.refresh_menu(ctx);
}
}
me.refresh_button(ctx);
});
}
}
let mut me = Self {
button,
dropdown,
is_menu_open: false,
menu_positioning_provider,
ambient_agent_model,
target,
};
me.refresh_menu(ctx);
me.refresh_button(ctx);
@@ -236,13 +317,34 @@ impl EnvironmentSelector {
self.is_menu_open
}
fn is_configuring(&self, ctx: &AppContext) -> bool {
self.ambient_agent_model
.as_ref(ctx)
.is_configuring_ambient_agent()
pub fn open_menu(&mut self, ctx: &mut ViewContext<Self>) {
if !self.is_configuring(ctx) {
return;
}
self.set_menu_visibility(true, ctx);
}
fn set_menu_visibility(&mut self, is_open: bool, ctx: &mut ViewContext<Self>) {
fn is_configuring(&self, ctx: &AppContext) -> bool {
self.target.is_configuring(ctx)
}
fn highlight_selected_environment(&mut self, ctx: &mut ViewContext<Self>) {
let Some(selected_id) = self.target.selected_environment_id(ctx) else {
return;
};
let mut environments = CloudAmbientAgentEnvironment::get_all(ctx);
sort_environments_by_recency(&mut environments);
let Some(index) = environments.iter().position(|env| env.id == selected_id) else {
return;
};
self.dropdown.update(ctx, |menu, ctx| {
menu.select_index(index, ctx);
});
}
pub(super) fn set_menu_visibility(&mut self, is_open: bool, ctx: &mut ViewContext<Self>) {
if self.is_menu_open == is_open {
return;
}
@@ -251,6 +353,7 @@ impl EnvironmentSelector {
if is_open {
send_telemetry_from_ctx!(CloudAgentTelemetryEvent::EnvironmentSelectorOpened, ctx);
ctx.focus(&self.dropdown);
self.highlight_selected_environment(ctx);
}
ctx.emit(EnvironmentSelectorEvent::MenuVisibilityChanged { open: is_open });
ctx.notify();
@@ -258,10 +361,7 @@ impl EnvironmentSelector {
/// Ensures a default environment is selected if none is currently selected.
fn ensure_default_selection(&mut self, ctx: &mut ViewContext<Self>) {
let current_selection = self
.ambient_agent_model
.as_ref(ctx)
.selected_environment_id();
let current_selection = self.target.selected_environment_id(ctx);
if current_selection.is_some() {
return;
}
@@ -270,9 +370,7 @@ impl EnvironmentSelector {
if let Some(env_id) = self.get_saved_environment_from_settings(ctx) {
// Verify the environment still exists.
if CloudAmbientAgentEnvironment::get_by_id(&env_id, ctx).is_some() {
self.ambient_agent_model.update(ctx, |model, ctx| {
model.set_environment_id(Some(env_id), ctx);
});
self.target.ensure_default_environment_id(env_id, ctx);
return;
}
}
@@ -281,9 +379,7 @@ impl EnvironmentSelector {
let mut environments = CloudAmbientAgentEnvironment::get_all(ctx);
sort_environments_by_recency(&mut environments);
if let Some(first_env) = environments.first() {
self.ambient_agent_model.update(ctx, |model, ctx| {
model.set_environment_id(Some(first_env.id), ctx);
});
self.target.ensure_default_environment_id(first_env.id, ctx);
}
}
@@ -307,11 +403,7 @@ impl EnvironmentSelector {
let mut environments = CloudAmbientAgentEnvironment::get_all(ctx);
sort_environments_by_recency(&mut environments);
let selected_id = self
.ambient_agent_model
.as_ref(ctx)
.selected_environment_id()
.cloned();
let selected_id = self.target.selected_environment_id(ctx);
let menu_items: Vec<EnvironmentMenuItem> = environments
.iter()
@@ -328,16 +420,20 @@ impl EnvironmentSelector {
self.dropdown.update(ctx, |menu, ctx| {
menu.update_menu_items(menu_items, ctx);
});
if self.is_menu_open {
self.highlight_selected_environment(ctx);
}
}
fn refresh_button(&mut self, ctx: &mut ViewContext<Self>) {
let label = self
.ambient_agent_model
.as_ref(ctx)
.selected_environment_id()
.and_then(|id| CloudAmbientAgentEnvironment::get_by_id(id, ctx))
.map(|env| env.model().string_model.display_name())
.unwrap_or_else(|| "New environment".to_string());
let label = if let Some(id) = self.target.selected_environment_id(ctx) {
CloudAmbientAgentEnvironment::get_by_id(&id, ctx)
.map(|env| env.model().string_model.display_name())
.unwrap_or_else(|| "New environment".to_string())
} else {
"New environment".to_string()
};
let is_configuring = self.is_configuring(ctx);
File diff suppressed because it is too large Load Diff
@@ -1,11 +1,12 @@
use serde::{Deserialize, Serialize};
use crate::context_chips::{agent_footer_available_chips, available_chips, ContextChipKind};
use crate::features::FeatureFlag;
use crate::terminal::shared_session::SharedSessionStatus;
use crate::ui_components::icons::Icon;
use warpui::SingletonEntity;
use super::editor::AgentToolbarEditorMode;
use crate::context_chips::{agent_footer_available_chips, available_chips, ContextChipKind};
use crate::features::FeatureFlag;
use crate::settings::AISettings;
use crate::terminal::shared_session::SharedSessionStatus;
use crate::ui_components::icons::Icon;
/// Declares which footer(s) a toolbar item is available in.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -67,6 +68,9 @@ pub enum AgentToolbarItemKind {
// Agent view only – shows fast-forward (auto-approve) toggle in the footer
FastForwardToggle,
// Agent view only – "Hand off to cloud" chip.
HandoffToCloud,
}
impl AgentToolbarItemKind {
@@ -76,7 +80,8 @@ impl AgentToolbarItemKind {
Self::ModelSelector
| Self::NLDToggle
| Self::ContextWindowUsage
| Self::FastForwardToggle => ToolbarAvailability::AgentViewOnly,
| Self::FastForwardToggle
| Self::HandoffToCloud => ToolbarAvailability::AgentViewOnly,
Self::FileExplorer | Self::RichInput | Self::Settings => {
ToolbarAvailability::CLIAgentOnly
}
@@ -95,6 +100,8 @@ impl AgentToolbarItemKind {
Self::Settings | Self::FileExplorer => !status.is_viewer(),
Self::FileAttach => !status.is_viewer() || is_cloud_mode,
Self::FastForwardToggle => !status.is_viewer() || status.is_executor(),
// Handoff is host-initiated; viewers cannot hand off another user's conversation.
Self::HandoffToCloud => !status.is_viewer(),
Self::ContextChip(_)
| Self::ModelSelector
| Self::NLDToggle
@@ -116,6 +123,7 @@ impl AgentToolbarItemKind {
Self::RichInput => "Rich Input",
Self::Settings => "Settings",
Self::FastForwardToggle => "Fast Forward",
Self::HandoffToCloud => "Hand off to cloud",
}
}
@@ -126,11 +134,44 @@ impl AgentToolbarItemKind {
Self::NLDToggle => Some(Icon::NLD),
Self::VoiceInput => Some(Icon::Microphone),
Self::FileAttach => Some(Icon::Plus),
Self::ContextWindowUsage => Some(Icon::ConversationContext0),
Self::ContextWindowUsage => Some(Icon::ContextRemaining100),
Self::FileExplorer => Some(Icon::FileCopy),
Self::RichInput => Some(Icon::TextInput),
Self::Settings => Some(Icon::Settings),
Self::FastForwardToggle => Some(Icon::FastForward),
// The bundled `upload-cloud-01.svg` (cloud-with-upward-arrow) is the
// closest fit among the existing icons for V0; design may swap it later.
Self::HandoffToCloud => Some(Icon::UploadCloud),
}
}
/// Whether this item should remain visible during `&` handoff-compose mode.
/// Only items relevant to composing a cloud run are shown.
pub(super) fn is_available_during_handoff_compose(&self) -> bool {
match self {
Self::ContextChip(
ContextChipKind::ShellGitBranch | ContextChipKind::GitBranchStatus,
) => true,
Self::ModelSelector | Self::VoiceInput | Self::FileAttach => true,
Self::ContextChip(_)
| Self::NLDToggle
| Self::ContextWindowUsage
| Self::FastForwardToggle
| Self::HandoffToCloud
| Self::ShareSession
| Self::FileExplorer
| Self::RichInput
| Self::Settings => false,
}
}
/// Whether this item should be included in the toolbar given the current app state.
/// Feature-flag checks live in `all_available()` / `default_*()`. This method
/// handles runtime conditions that depend on user settings or workspace state.
pub fn is_available(&self, app: &warpui::AppContext) -> bool {
match self {
Self::HandoffToCloud => AISettings::as_ref(app).is_cloud_handoff_enabled(app),
_ => true,
}
}
@@ -167,6 +208,17 @@ impl AgentToolbarItemKind {
Self::ContextWindowUsage,
Self::ModelSelector,
];
if FeatureFlag::CreatingSharedSessions.is_enabled()
&& FeatureFlag::HOARemoteControl.is_enabled()
{
items.push(Self::ShareSession);
}
if FeatureFlag::OzHandoff.is_enabled()
&& FeatureFlag::HandoffLocalCloud.is_enabled()
&& cfg!(all(feature = "local_fs", not(target_family = "wasm")))
{
items.push(Self::HandoffToCloud);
}
items.push(Self::VoiceInput);
items.push(Self::FileAttach);
items
@@ -188,6 +240,17 @@ impl AgentToolbarItemKind {
if FeatureFlag::FastForwardAutoexecuteButton.is_enabled() {
items.push(Self::FastForwardToggle);
}
if FeatureFlag::CreatingSharedSessions.is_enabled()
&& FeatureFlag::HOARemoteControl.is_enabled()
{
items.push(Self::ShareSession);
}
if FeatureFlag::OzHandoff.is_enabled()
&& FeatureFlag::HandoffLocalCloud.is_enabled()
&& cfg!(all(feature = "local_fs", not(target_family = "wasm")))
{
items.push(Self::HandoffToCloud);
}
items
}
@@ -9,6 +9,9 @@ use galaxyui::keymap::Keystroke;
use galaxyui::platform::OperatingSystem;
use galaxyui::{AppContext, Entity, ModelHandle, SingletonEntity, View, ViewContext};
use parking_lot::FairMutex;
use galaxyui::{
AppContext, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
};
use super::{AgentViewState, EphemeralMessageModel, EphemeralMessageModelEvent};
use crate::ai::agent::conversation::AIConversation;
@@ -18,19 +21,20 @@ use crate::ai::agent::{
use crate::ai::blocklist::agent_view::shortcuts::AgentShortcutViewModel;
use crate::ai::blocklist::agent_view::zero_state_block::render_ambient_credits_banner;
use crate::ai::blocklist::agent_view::{
agent_view_bg_fill, AgentViewController, AgentViewControllerEvent,
agent_view_bg_fill, is_in_cloud_context, AgentViewController, AgentViewControllerEvent,
};
use crate::ai::blocklist::{
BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIHistoryEvent,
ai_brand_color, BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIHistoryEvent,
BlocklistAIInputEvent, BlocklistAIInputModel,
};
use crate::ai::document::ai_document_model::{AIDocumentModel, AIDocumentModelEvent};
use crate::ai::mcp::{
templatable_manager::{FigmaMcpStatus, TemplatableMCPServerManagerEvent},
TemplatableMCPServerManager,
use crate::ai::mcp::templatable_manager::{FigmaMcpStatus, TemplatableMCPServerManagerEvent};
use crate::ai::mcp::TemplatableMCPServerManager;
use crate::ai::request_usage_model::{
AIRequestUsageModel, AIRequestUsageModelEvent, AMBIENT_AGENT_TRIAL_CREDIT_THRESHOLD,
};
use crate::ai::request_usage_model::{AIRequestUsageModel, AIRequestUsageModelEvent};
use crate::search::slash_command_menu::static_commands::commands;
use crate::settings::AISettings;
use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent};
use crate::terminal::input::message_bar::attached_context::{
AttachedBlocksMessageProducer, AttachedContextArgs, AttachedTextSelectionMessageProducer,
@@ -45,7 +49,7 @@ use crate::terminal::input::slash_command_model::{SlashCommandEntryState, SlashC
use crate::terminal::input::suggestions_mode_model::{
InputSuggestionsModeEvent, InputSuggestionsModeModel,
};
use crate::terminal::input::{InputAction, SET_INPUT_MODE_AGENT_ACTION_NAME};
use crate::terminal::input::{HandoffComposeState, InputAction, SET_INPUT_MODE_AGENT_ACTION_NAME};
use crate::terminal::model::TerminalModel;
use crate::terminal::view::TerminalAction;
use crate::ui_components::blended_colors;
@@ -66,11 +70,14 @@ pub struct AgentMessageBarMouseStates {
pub toggle_plan: MouseStateHandle,
pub toggle_conversation_menu: MouseStateHandle,
pub toggle_code_review: MouseStateHandle,
pub handoff_to_cloud: MouseStateHandle,
pub clear_attached_context: MouseStateHandle,
/// Mouse state handle for the "Get Figma MCP" contextual button.
pub figma_install_button: MouseStateHandle,
/// Mouse state handle for the "Enable Figma MCP" contextual button.
pub figma_enable_button: MouseStateHandle,
/// Mouse state handle for dismissing the ambient credits banner.
pub ambient_credits_banner_close: MouseStateHandle,
}
/// Renders contextual hint text at the bottom of the agent view status bar.
@@ -83,6 +90,7 @@ pub struct AgentMessageBar {
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
slash_command_model: ModelHandle<SlashCommandModel>,
context_model: ModelHandle<BlocklistAIContextModel>,
handoff_compose_state: ModelHandle<HandoffComposeState>,
terminal_model: Arc<FairMutex<TerminalModel>>,
mouse_states: AgentMessageBarMouseStates,
/// Whether the word "figma" has been detected in the current input buffer or attached images.
@@ -93,6 +101,10 @@ pub struct AgentMessageBar {
impl Entity for AgentMessageBar {
type Event = ();
}
#[derive(Clone, Debug)]
pub enum AgentMessageBarAction {
DismissAmbientCreditsBanner,
}
impl AgentMessageBar {
#[allow(clippy::too_many_arguments)]
@@ -105,6 +117,7 @@ impl AgentMessageBar {
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
slash_command_model: ModelHandle<SlashCommandModel>,
context_model: ModelHandle<BlocklistAIContextModel>,
handoff_compose_state: ModelHandle<HandoffComposeState>,
terminal_model: Arc<FairMutex<TerminalModel>>,
ctx: &mut ViewContext<Self>,
) -> Self {
@@ -166,6 +179,9 @@ impl AgentMessageBar {
ctx.subscribe_to_model(&slash_command_model, |_, _, _, ctx| {
ctx.notify();
});
ctx.subscribe_to_model(&handoff_compose_state, |_, _, _, ctx| {
ctx.notify();
});
let history_model = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_model, |_, _, event, ctx| {
@@ -215,7 +231,11 @@ impl AgentMessageBar {
}
ctx.subscribe_to_model(&AIRequestUsageModel::handle(ctx), |_, _, event, ctx| {
if matches!(event, AIRequestUsageModelEvent::RequestUsageUpdated) {
if matches!(
event,
AIRequestUsageModelEvent::RequestUsageUpdated
| AIRequestUsageModelEvent::AmbientCreditsBannerDismissed
) {
ctx.notify();
}
});
@@ -229,6 +249,7 @@ impl AgentMessageBar {
input_suggestions_model,
slash_command_model,
context_model,
handoff_compose_state,
terminal_model,
mouse_states: AgentMessageBarMouseStates::default(),
figma_detected: false,
@@ -293,6 +314,7 @@ impl View for AgentMessageBar {
let agent_view_controller = self.agent_view_controller.as_ref(app);
let context_model = self.context_model.as_ref(app);
let slash_command_model = self.slash_command_model.as_ref(app);
let handoff_compose_state = self.handoff_compose_state.as_ref(app);
let terminal_model = self.terminal_model.lock();
let appearance = Appearance::as_ref(app);
@@ -317,6 +339,7 @@ impl View for AgentMessageBar {
input_model,
slash_command_model,
context_model,
handoff_compose_state,
terminal_model: &terminal_model,
appearance,
app,
@@ -331,6 +354,7 @@ impl View for AgentMessageBar {
.or_else(|| AttachedBlocksMessageProducer.produce_message(args))
.or_else(|| AttachedTextSelectionMessageProducer.produce_message(args))
.or_else(|| AutodetectedBashModeMessageProducer.produce_message(args))
.or_else(|| ExitCloudHandoffModeMessageProducer.produce_message(args))
.or_else(|| ExitBashModeMessageProducer.produce_message(args))
.or_else(|| HideShortcutsMessageProducer.produce_message(args))
.or_else(|| ZeroStateMessageProducer.produce_message(args))
@@ -340,19 +364,26 @@ impl View for AgentMessageBar {
};
// Show credits banner when user has ambient credits remaining.
use crate::ai::request_usage_model::AMBIENT_AGENT_TRIAL_CREDIT_THRESHOLD;
let right_element = if cfg!(target_family = "wasm") {
None
} else if let Some(credits) =
AIRequestUsageModel::as_ref(app).ambient_only_credits_remaining()
{
if credits >= AMBIENT_AGENT_TRIAL_CREDIT_THRESHOLD {
Some(render_ambient_credits_banner(credits, app))
} else {
let request_usage_model = AIRequestUsageModel::as_ref(app);
if let Some(credits) = request_usage_model.ambient_only_credits_remaining() {
if credits >= AMBIENT_AGENT_TRIAL_CREDIT_THRESHOLD
&& !request_usage_model.is_ambient_credits_banner_dismissed()
{
Some(render_ambient_credits_banner(
credits,
self.mouse_states.ambient_credits_banner_close.clone(),
AgentMessageBarAction::DismissAmbientCreditsBanner,
app,
))
} else {
None
}
} else {
None
}
} else {
None
};
// Append a Figma MCP chip to the message if applicable.
@@ -395,6 +426,19 @@ impl View for AgentMessageBar {
}
}
impl TypedActionView for AgentMessageBar {
type Action = AgentMessageBarAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
AgentMessageBarAction::DismissAmbientCreditsBanner => {
AIRequestUsageModel::handle(ctx).update(ctx, |model, ctx| {
model.dismiss_ambient_credits_banner(ctx);
});
}
}
}
}
/// Arguments for agent message producers.
#[derive(Copy, Clone)]
pub struct AgentMessageArgs<'a> {
@@ -406,6 +450,7 @@ pub struct AgentMessageArgs<'a> {
pub input_model: &'a BlocklistAIInputModel,
pub slash_command_model: &'a SlashCommandModel,
pub context_model: &'a BlocklistAIContextModel,
pub handoff_compose_state: &'a HandoffComposeState,
pub terminal_model: &'a TerminalModel,
pub appearance: &'a Appearance,
pub app: &'a AppContext,
@@ -565,10 +610,36 @@ impl MessageProvider<AgentMessageArgs<'_>> for ZeroStateMessageProducer {
.with_is_disabled(!is_buffer_empty),
);
let is_cloud_agent = matches!(
agent_view_controller.agent_view_state(),
AgentViewState::Active { origin, .. } if origin.is_cloud_agent()
);
let is_cloud_agent =
is_in_cloud_context(agent_view_controller.agent_view_state(), terminal_model);
let ai_settings = AISettings::as_ref(app);
// Handoff to cloud only available for local agents.
if !is_cloud_agent && ai_settings.is_ampersand_handoff_enabled(app) {
items.push(
MessageItem::clickable(
vec![
MessageItem::Keystroke {
keystroke: Keystroke {
key: "&".to_owned(),
..Default::default()
},
color: color_override_for_shortcuts_and_commands,
background_color: bg_color_override_for_shortcuts_and_commands,
},
MessageItem::Text {
content: "send task to the cloud".into(),
color: color_override_for_shortcuts_and_commands,
},
],
|ctx| {
ctx.dispatch_typed_action(InputAction::ActivateCloudHandoff);
},
mouse_states.handoff_to_cloud.clone(),
)
.with_is_disabled(!is_buffer_empty),
);
}
let plan_count = AIDocumentModel::as_ref(app)
.get_all_documents_for_conversation(active_conversation.id())
@@ -596,7 +667,10 @@ impl MessageProvider<AgentMessageArgs<'_>> for ZeroStateMessageProducer {
// Code review only works locally.
#[cfg(not(target_family = "wasm"))]
if !is_cloud_agent && *TabSettings::as_ref(app).show_code_review_button {
if !is_cloud_agent
&& !ai_settings.is_cloud_handoff_enabled(app)
&& *TabSettings::as_ref(app).show_code_review_button
{
let code_review_keystroke = if OperatingSystem::get().is_mac() {
Keystroke::parse("cmd-shift-+").expect("keystroke should parse")
} else {
@@ -711,15 +785,21 @@ fn should_fork_from_last_known_good_state(
};
match error {
RenderableAIError::QuotaLimit
RenderableAIError::QuotaLimit { .. }
| RenderableAIError::ServerOverloaded
| RenderableAIError::ContextWindowExceeded(_)
| RenderableAIError::InvalidApiKey { .. }
| RenderableAIError::AwsBedrockCredentialsExpiredOrInvalid { .. } => false,
RenderableAIError::InternalWarpError => true,
// A shell-exit failure can't resume in this (now-dead) pane, but the user
// can fork from the last known good state to continue in a fresh one.
RenderableAIError::InternalWarpError | RenderableAIError::AgentExitedShell => true,
RenderableAIError::Other {
will_attempt_resume,
..
}
| RenderableAIError::TransientNetworkError {
will_attempt_resume,
..
} => !will_attempt_resume,
}
}
@@ -734,10 +814,14 @@ impl MessageProvider<AgentMessageArgs<'_>> for ForkSlashCommandMessageProducer {
return None;
};
let command_name = detected_command.command.name;
if command_name != commands::FORK.name
&& command_name != commands::FORK_FROM.name
&& command_name != commands::FORK_AND_COMPACT.name
{
let is_fork_family = command_name == commands::FORK.name
|| command_name == commands::FORK_FROM.name
|| command_name == commands::FORK_AND_COMPACT.name;
#[cfg(not(target_family = "wasm"))]
let is_continue_locally = command_name == commands::CONTINUE_LOCALLY.name;
#[cfg(target_family = "wasm")]
let is_continue_locally = false;
if !is_fork_family && !is_continue_locally {
return None;
}
let modifier_keystroke = if cfg!(target_os = "macos") {
@@ -755,23 +839,15 @@ impl MessageProvider<AgentMessageArgs<'_>> for ForkSlashCommandMessageProducer {
}
};
// `/fork` opens in a new pane with Enter and a new tab with Cmd/Ctrl+Enter.
// Other fork-like commands open in the current pane with Enter and a new pane
// with Cmd/Ctrl+Enter.
let (primary_label, secondary_label) = if command_name == commands::FORK.name {
(" new pane", " new tab")
} else {
(" current pane", " new pane")
};
// All fork-style commands open a new pane on Enter and a new tab on Cmd/Ctrl+Enter.
Some(Message::new(vec![
MessageItem::keystroke(Keystroke {
key: "enter".to_owned(),
..Default::default()
}),
MessageItem::text(primary_label),
MessageItem::text(" new pane"),
MessageItem::keystroke(modifier_keystroke),
MessageItem::text(secondary_label),
MessageItem::text(" new tab"),
]))
}
}
@@ -808,8 +884,8 @@ impl MessageProvider<AgentMessageArgs<'_>> for AutodetectedBashModeMessageProduc
input_buffer_model,
input_model,
appearance,
slash_command_model,
app,
slash_command_model,
..
} = args;
if input_model.is_ai_input_enabled()
@@ -834,47 +910,90 @@ impl MessageProvider<AgentMessageArgs<'_>> for AutodetectedBashModeMessageProduc
}
}
struct ExitCloudHandoffModeMessageProducer;
impl MessageProvider<AgentMessageArgs<'_>> for ExitCloudHandoffModeMessageProducer {
fn produce_message(&self, args: AgentMessageArgs<'_>) -> Option<Message> {
let AgentMessageArgs {
input_buffer_model,
handoff_compose_state,
appearance,
..
} = args;
if !handoff_compose_state.is_active() {
return None;
}
let active_color = ai_brand_color(appearance.theme());
let is_buffer_empty = input_buffer_model.current_value().is_empty();
let (dismiss_text_color, dismiss_key_color, dismiss_key_bg) = if is_buffer_empty {
(active_color, None, None)
} else {
(
Fill::from(active_color).with_opacity(60).into_solid(),
Some(
appearance
.theme()
.sub_text_color(appearance.theme().background())
.into_solid(),
),
Some(blended_colors::neutral_1(appearance.theme())),
)
};
Some(Message::new(vec![
MessageItem::Keystroke {
keystroke: Keystroke {
key: "enter".to_owned(),
..Default::default()
},
color: None,
background_color: None,
},
MessageItem::Text {
content: "to hand off to cloud".into(),
color: Some(active_color),
},
MessageItem::Keystroke {
keystroke: Keystroke {
key: "backspace".to_owned(),
..Default::default()
},
color: dismiss_key_color,
background_color: dismiss_key_bg,
},
MessageItem::Text {
content: "to dismiss".into(),
color: Some(dismiss_text_color),
},
]))
}
}
struct ExitBashModeMessageProducer;
impl MessageProvider<AgentMessageArgs<'_>> for ExitBashModeMessageProducer {
fn produce_message(&self, args: AgentMessageArgs<'_>) -> Option<Message> {
let AgentMessageArgs {
input_buffer_model,
input_model,
appearance,
app,
..
} = args;
if input_model.is_ai_input_enabled() || !input_model.is_input_type_locked() {
return None;
}
let set_input_mode_agent_keystroke =
keybinding_name_to_keystroke(SET_INPUT_MODE_AGENT_ACTION_NAME, app)?;
let (text_color, keystroke_color_override, keystroke_bg_color_override) =
if input_buffer_model.current_value().is_empty() {
(appearance.theme().ansi_fg_blue(), None, None)
} else {
(
Fill::from(appearance.theme().ansi_fg_blue())
.with_opacity(60)
.into_solid(),
Some(
appearance
.theme()
.sub_text_color(appearance.theme().background())
.into_solid(),
),
Some(blended_colors::neutral_1(appearance.theme())),
)
};
let text_color = appearance.theme().ansi_fg_blue();
Some(
Message::new(vec![
MessageItem::Keystroke {
keystroke: Keystroke {
key: "backspace".to_owned(),
..Default::default()
},
color: keystroke_color_override,
background_color: keystroke_bg_color_override,
keystroke: set_input_mode_agent_keystroke,
color: None,
background_color: None,
},
MessageItem::text("to exit shell mode"),
])
@@ -1,36 +1,38 @@
use galaxy_core::ui::{appearance::Appearance, Icon};
use galaxyui::{
elements::{
ConstrainedBox, Container, CrossAxisAlignment, Empty, Flex, Hoverable, MainAxisSize,
MouseStateHandle, ParentElement, Shrinkable, Text,
},
fonts::{Properties, Style, Weight::Bold},
platform::Cursor,
prelude::{Border, CornerRadius, Radius},
text_layout::ClipConfig,
Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::Vector2F;
use settings::Setting;
use crate::{
ai::{
active_agent_views_model::ActiveAgentViewsModel,
agent::conversation::{AIConversationId, ConversationStatus},
blocklist::BlocklistAIHistoryEvent,
},
terminal::BlockListSettings,
ui_components::blended_colors,
view_components::DismissibleToast,
workspace::{ToastStack, WorkspaceAction},
BlocklistAIHistoryModel,
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::Icon;
use galaxyui::elements::{
ConstrainedBox, Container, CrossAxisAlignment, Empty, Expanded, Flex, Hoverable, MainAxisSize,
MouseStateHandle, ParentElement, SavePosition, Shrinkable, Text,
};
use galaxyui::fonts::Weight::Bold;
use galaxyui::fonts::{Properties, Style};
use galaxyui::platform::Cursor;
use galaxyui::prelude::{Border, CornerRadius, Radius};
use galaxyui::text_layout::ClipConfig;
use galaxyui::{
AppContext, Element, Entity, EntityId, EventContext, ModelHandle, SingletonEntity,
TypedActionView, View, ViewContext,
};
use super::{AgentViewController, AgentViewEntryOrigin};
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::blocklist::BlocklistAIHistoryEvent;
use crate::terminal::BlockListSettings;
use crate::ui_components::blended_colors;
use crate::ui_components::icon_with_status::{render_icon_with_status, IconWithStatusVariant};
use crate::view_components::DismissibleToast;
use crate::workspace::{ToastStack, WorkspaceAction};
use crate::BlocklistAIHistoryModel;
#[derive(Default)]
struct StateHandles {
block: MouseStateHandle,
fork_button: MouseStateHandle,
open_conversation_button: MouseStateHandle,
}
pub struct AgentViewEntryBlockParams {
@@ -52,6 +54,8 @@ pub struct AgentViewEntryBlock {
/// Cached title for rendering when conversation no longer exists (i.e. after deletion).
cached_title: Option<String>,
state_handles: StateHandles,
/// UI instance anchor for positioning the entry context menu.
view_id: EntityId,
}
impl AgentViewEntryBlock {
@@ -84,7 +88,6 @@ impl AgentViewEntryBlock {
_ => (),
});
ctx.subscribe_to_model(&agent_view_controller, |_, _, _, ctx| ctx.notify());
let active_agent_views_model = ActiveAgentViewsModel::handle(ctx);
ctx.subscribe_to_model(&active_agent_views_model, |_, _, _, ctx| ctx.notify());
@@ -96,10 +99,15 @@ impl AgentViewEntryBlock {
origin,
cached_title: Default::default(),
state_handles: Default::default(),
view_id: ctx.view_id(),
}
}
}
pub fn get_agent_view_entry_block_position_id(view_id: EntityId) -> String {
format!("agent_view_entry_block_{view_id}")
}
pub fn render_block_container(
origin: AgentViewEntryOrigin,
content: Box<dyn Element>,
@@ -155,6 +163,45 @@ fn render_subtext(text: String, appearance: &Appearance) -> Box<dyn Element> {
.finish()
}
/// Renders a custom button with icon and hover/click states for the Agent View Block.
///
/// This button component is currently used for the "fork" and "open agent conversation" actions
/// within the Agent View. Future actions re-using this pattern should use this component for
/// visually consistent icon-based interaction.
fn render_agent_view_block_button<F>(
icon: Icon,
icon_color: ColorU,
mouse_state: MouseStateHandle,
appearance: &Appearance,
on_click: F,
) -> Box<dyn Element>
where
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F),
{
Hoverable::new(mouse_state, move |state| {
let container = Container::new(
ConstrainedBox::new(icon.to_warpui_icon(icon_color.into()).finish())
.with_height(20.)
.with_width(20.)
.finish(),
)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(5.)));
let container = if state.is_clicked() {
container.with_background(blended_colors::fg_overlay_4(appearance.theme()))
} else if state.is_hovered() {
container.with_background(blended_colors::fg_overlay_3(appearance.theme()))
} else {
container
};
container.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(on_click)
.finish()
}
fn render_deleted_state(
origin: AgentViewEntryOrigin,
cached_title: Option<String>,
@@ -203,6 +250,7 @@ impl View for AgentViewEntryBlock {
}
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let are_block_dividers_enabled =
*BlockListSettings::as_ref(app).show_block_dividers.value();
@@ -211,7 +259,7 @@ impl View for AgentViewEntryBlock {
// If the agent_view_block's conversation no longer exists,
// we assume that it has been deleted.
return render_deleted_state(
self.origin,
self.origin.clone(),
self.cached_title.clone(),
appearance,
are_block_dividers_enabled,
@@ -222,42 +270,46 @@ impl View for AgentViewEntryBlock {
return Empty::new().finish();
}
fn with_opacity(mut color: ColorU, opacity: u8) -> ColorU {
color.a = opacity;
color
}
let agent_icon = render_icon_with_status(
IconWithStatusVariant::OzAgent {
status: Some(conversation.status().clone()),
is_ambient: false,
},
24.,
0.,
theme,
theme.background(),
);
let status_icon = conversation.status().render_icon(appearance);
let status_icon_bg = match conversation.status() {
ConversationStatus::InProgress => {
with_opacity(appearance.theme().ansi_fg_magenta(), 25)
}
ConversationStatus::Success => with_opacity(appearance.theme().ansi_fg_green(), 25),
ConversationStatus::Error => with_opacity(appearance.theme().ansi_fg_red(), 25),
ConversationStatus::Cancelled => {
with_opacity(blended_colors::neutral_5(appearance.theme()), 25)
}
ConversationStatus::Blocked { .. } => {
with_opacity(appearance.theme().ansi_fg_yellow(), 25)
}
let is_active =
ActiveAgentViewsModel::as_ref(app).is_conversation_open(self.conversation_id, app);
let is_active_in_this_pane = self
.agent_view_controller
.as_ref(app)
.agent_view_state()
.active_conversation_id()
== Some(self.conversation_id);
let is_open_elsewhere = is_active && !is_active_in_this_pane;
let subtext = if is_open_elsewhere {
Some("Open in different pane")
} else if self.is_restored {
Some("Restored")
} else if !self.is_new
&& !matches!(
&self.origin,
AgentViewEntryOrigin::LongRunningCommand
| AgentViewEntryOrigin::AgentRequestedNewConversation
)
{
Some("Continued")
} else {
None
};
let mut row = Flex::row()
// Build the title+subtext section.
let mut title_section = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max)
.with_child(
Container::new(
ConstrainedBox::new(status_icon.finish())
.with_height(16.)
.with_width(16.)
.finish(),
)
.with_uniform_padding(2.)
.with_background_color(status_icon_bg)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_margin_right(8.)
.finish(),
)
.with_child(
Shrinkable::new(
1.,
@@ -282,86 +334,114 @@ impl View for AgentViewEntryBlock {
)
.finish(),
);
let is_active =
ActiveAgentViewsModel::as_ref(app).is_conversation_open(self.conversation_id, app);
let is_active_in_this_pane = self
.agent_view_controller
.as_ref(app)
.agent_view_state()
.active_conversation_id()
== Some(self.conversation_id);
let is_open_elsewhere = is_active && !is_active_in_this_pane;
let subtext = if is_open_elsewhere {
Some("Open in different pane")
} else if self.is_restored {
Some("Restored")
} else if !self.is_new
&& !matches!(
self.origin,
AgentViewEntryOrigin::LongRunningCommand
| AgentViewEntryOrigin::AgentRequestedNewConversation
)
{
Some("Continued")
} else {
None
};
if let Some(subtext) = subtext {
row.add_child(render_subtext(subtext.to_string(), appearance));
title_section.add_child(render_subtext(subtext.to_string(), appearance));
}
row.add_child(
Container::new(Empty::new().finish())
.with_margin_right(8.)
.finish(),
);
row.add_child(
ConstrainedBox::new(
Icon::ChevronRight
.to_galaxyui_icon(
blended_colors::text_sub(
appearance.theme(),
appearance.theme().background(),
)
.into(),
)
.finish(),
)
.with_height(20.)
.with_width(20.)
.finish(),
);
let conversation_id = self.conversation_id;
let origin = self.origin;
Hoverable::new(self.state_handles.block.clone(), move |hoverable_state| {
let background = if hoverable_state.is_hovered() {
blended_colors::fg_overlay_2(appearance.theme())
} else {
blended_colors::fg_overlay_1(appearance.theme())
};
render_block_container(
origin,
row.finish(),
background.into(),
appearance,
are_block_dividers_enabled,
)
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(EnterAgentBlockAction::EnterAgentMode { conversation_id });
})
let icon_color = blended_colors::text_sub(theme, theme.background());
let fork_button = ConstrainedBox::new(render_agent_view_block_button(
Icon::ArrowSplit,
icon_color,
self.state_handles.fork_button.clone(),
appearance,
move |ctx, _, _| {
ctx.dispatch_typed_action(EnterAgentBlockAction::ForkConversation {
conversation_id,
});
},
))
.with_height(20.)
.with_width(20.)
.finish();
let open_conversation_button = ConstrainedBox::new(render_agent_view_block_button(
Icon::ChevronRight,
icon_color,
self.state_handles.open_conversation_button.clone(),
appearance,
move |ctx, _, _| {
ctx.dispatch_typed_action(EnterAgentBlockAction::EnterAgentMode {
conversation_id,
});
},
))
.with_height(20.)
.with_width(20.)
.finish();
let row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max)
.with_child(Container::new(agent_icon).with_margin_right(8.).finish())
// Expanded fills all remaining space, pushing buttons to the right.
.with_child(Expanded::new(1., title_section.finish()).finish())
.with_child(Container::new(fork_button).with_margin_left(8.).finish())
.with_child(open_conversation_button);
let origin = self.origin.clone();
let entry_block_id = self.view_id;
let entry_block_position_id = get_agent_view_entry_block_position_id(entry_block_id);
SavePosition::new(
Hoverable::new(self.state_handles.block.clone(), move |hoverable_state| {
let background = if hoverable_state.is_hovered() {
blended_colors::fg_overlay_2(appearance.theme())
} else {
blended_colors::fg_overlay_1(appearance.theme())
};
render_block_container(
origin.clone(),
row.finish(),
background.into(),
appearance,
are_block_dividers_enabled,
)
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(EnterAgentBlockAction::EnterAgentMode {
conversation_id,
});
})
.on_right_click(move |ctx, _, position| {
let Some(entry_bounds) = ctx.element_position_by_id(&entry_block_position_id)
else {
log::warn!(
"Could not retrieve the position of the agent view entry block for context menu display."
);
return;
};
// Subtract the block's origin so the position is expressed relative
// to the block element. OffsetPositioning::offset_from_save_position_element
// then offsets the menu from the saved block position, placing it at
// the exact mouse cursor location.
ctx.dispatch_typed_action(EnterAgentBlockAction::OpenConversationContextMenu {
conversation_id,
agent_view_entry_block_id: entry_block_id,
position: position - entry_bounds.origin(),
});
})
.with_defer_events_to_children()
.finish(),
&get_agent_view_entry_block_position_id(entry_block_id),
)
.finish()
}
}
#[derive(Debug, Clone)]
pub enum AgentViewEntryBlockEvent {
EnterAgentView { conversation_id: AIConversationId },
EnterAgentView {
conversation_id: AIConversationId,
},
OpenConversationContextMenu {
conversation_id: AIConversationId,
agent_view_entry_block_id: EntityId,
position: Vector2F,
},
ForkConversation {
conversation_id: AIConversationId,
},
}
impl Entity for AgentViewEntryBlock {
@@ -370,7 +450,17 @@ impl Entity for AgentViewEntryBlock {
#[derive(Debug, Clone)]
pub enum EnterAgentBlockAction {
EnterAgentMode { conversation_id: AIConversationId },
EnterAgentMode {
conversation_id: AIConversationId,
},
OpenConversationContextMenu {
conversation_id: AIConversationId,
agent_view_entry_block_id: EntityId,
position: Vector2F,
},
ForkConversation {
conversation_id: AIConversationId,
},
}
impl TypedActionView for AgentViewEntryBlock {
@@ -416,6 +506,22 @@ impl TypedActionView for AgentViewEntryBlock {
});
}
}
EnterAgentBlockAction::OpenConversationContextMenu {
conversation_id,
agent_view_entry_block_id,
position,
} => {
ctx.emit(AgentViewEntryBlockEvent::OpenConversationContextMenu {
conversation_id: *conversation_id,
agent_view_entry_block_id: *agent_view_entry_block_id,
position: *position,
});
}
EnterAgentBlockAction::ForkConversation { conversation_id } => {
ctx.emit(AgentViewEntryBlockEvent::ForkConversation {
conversation_id: *conversation_id,
});
}
}
}
}
+87 -79
View File
@@ -1,25 +1,23 @@
use std::{sync::Arc, time::Duration};
use std::sync::Arc;
use std::time::Duration;
use instant::Instant;
use parking_lot::FairMutex;
use galaxy_core::ui::appearance::Appearance;
use galaxyui::keymap::Keystroke;
use galaxyui::AppContext;
use galaxyui::{
r#async::SpawnedFutureHandle, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity,
};
use parking_lot::FairMutex;
use crate::terminal::input::message_bar::{Message, MessageItem};
use crate::terminal::input::slash_commands::SlashCommandTrigger;
use crate::util::bindings::keybinding_name_to_keystroke;
use crate::{
ai::agent::conversation::AIConversationId,
terminal::{view::ambient_agent::AmbientAgentViewModel, TerminalModel},
BlocklistAIHistoryModel,
};
use galaxyui::r#async::SpawnedFutureHandle;
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use super::{DismissalStrategy, EphemeralMessage, EphemeralMessageModel};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::blocklist::orchestration_topology::{
adjacent_orchestration_child_conversation_id, OrchestrationNavigationDirection,
};
use crate::terminal::input::message_bar::{Message, MessageItem};
use crate::terminal::input::slash_commands::SlashCommandTrigger;
use crate::terminal::TerminalModel;
use crate::util::bindings::keybinding_name_to_keystroke;
use crate::BlocklistAIHistoryModel;
/// Error returned when entering the agent view fails.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
@@ -100,7 +98,7 @@ impl PendingConfirmation {
///
/// Depending on the entrypoint, an `AgentView` block representing the entry may be inserted into
/// the terminal blocklist.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AgentViewEntryOrigin {
/// Entered agent view from user input (e.g. /agent or cmd-enter keypress).
Input {
@@ -138,6 +136,8 @@ pub enum AgentViewEntryOrigin {
ThirdPartyCloudAgent,
/// Entered agent view via the CLI (e.g. `warp agent run`).
Cli,
/// Entered agent view via the headless TUI frontend.
Tui,
/// Entered agent view by adding an image (drag-and-drop or paste).
ImageAdded,
/// Entered agent view by executing a slash command that requires agent mode.
@@ -146,8 +146,8 @@ pub enum AgentViewEntryOrigin {
},
SlashInit,
CreateEnvironment,
/// Entered agent view by executing a slash command that requires agent mode.
Keybinding,
/// Entered agent view from the new-conversation keybinding.
Keybinding(Keystroke),
/// Entered agent view by attaching context from the code review panel.
CodeReviewContext,
/// Entered agent view from codex integration modal.
@@ -169,6 +169,11 @@ pub enum AgentViewEntryOrigin {
/// Entered agent view because a parent agent started this child agent via StartAgent.
ChildAgent,
/// Entered agent view by clicking a pill / breadcrumb in the orchestration
/// pill bar (or breadcrumb row) to navigate the current pane to a sibling
/// or parent conversation in the same orchestration tree.
OrchestrationPillBar,
/// Entered agent view after opening project from OS directory picker.
ProjectEntry,
@@ -178,6 +183,10 @@ pub enum AgentViewEntryOrigin {
/// Entered agent view by clearing the buffer (Cmd+K) while already in agent view.
ClearBuffer,
/// Entered agent view via the "Jump to Latest Agent Message" command, which
/// returns to the most recent conversation from the terminal.
JumpToLatestAgentMessage,
// The variants below actually correspond to callsites where the selected conversation is
// updated, but don't actually correspond to entering the agent view. They exist so we can
// continue to call `set_pending_query_state_for_(new|existing)_conversation`, but you'll find
@@ -257,6 +266,13 @@ impl AgentViewState {
}
}
pub fn origin(&self) -> Option<AgentViewEntryOrigin> {
match self {
AgentViewState::Active { origin, .. } => Some(origin.clone()),
AgentViewState::Inactive => None,
}
}
/// Returns `true` if in an active agent view state.
pub fn is_active(&self) -> bool {
matches!(self, AgentViewState::Active { .. })
@@ -334,7 +350,7 @@ const NEW_CONVERSATION_KEYBINDING_CONFIRMATION_MESSAGE_ID: &str =
/// Controller responsible for managing and updating agent view state for a given terminal pane.
///
/// `AgentViewState` is stored on the terminal model but should only be updated via the APIs on
/// this constroller, which ensures the correct events are emitted and downstream effects take
/// this controller, which ensures the correct events are emitted and downstream effects take
/// place.
pub struct AgentViewController {
terminal_model: Arc<FairMutex<TerminalModel>>,
@@ -343,7 +359,6 @@ pub struct AgentViewController {
/// Set during terminal pane attach; used for pane-group-scoped visibility checks.
pane_group_id: Option<EntityId>,
agent_view_state: AgentViewState,
ambient_agent_view_model: ModelHandle<AmbientAgentViewModel>,
ephemeral_message_model: ModelHandle<EphemeralMessageModel>,
pending_confirmation: Option<PendingConfirmation>,
pending_confirmation_abort_handle: Option<SpawnedFutureHandle>,
@@ -368,16 +383,13 @@ impl AgentViewController {
pub fn new(
terminal_model: Arc<FairMutex<TerminalModel>>,
terminal_view_id: EntityId,
ambient_agent_view_model: ModelHandle<AmbientAgentViewModel>,
ephemeral_message_model: ModelHandle<EphemeralMessageModel>,
_ctx: &mut ModelContext<Self>,
) -> Self {
Self {
terminal_model,
terminal_view_id,
pane_group_id: None,
agent_view_state: AgentViewState::Inactive,
ambient_agent_view_model,
ephemeral_message_model,
pending_confirmation: None,
pending_confirmation_abort_handle: None,
@@ -388,6 +400,11 @@ impl AgentViewController {
self.pane_group_id
}
/// Returns the [`EntityId`] of the [`TerminalView`] that owns this controller.
pub fn terminal_view_id(&self) -> EntityId {
self.terminal_view_id
}
pub fn set_pane_group_id(&mut self, pane_group_id: EntityId) {
self.pane_group_id = Some(pane_group_id);
}
@@ -408,35 +425,37 @@ impl AgentViewController {
&self.agent_view_state
}
/// Resolves the conversation adjacent to the active agent-view conversation
/// in the canonical orchestration pill order.
pub fn adjacent_orchestration_conversation_id(
&self,
direction: OrchestrationNavigationDirection,
app: &AppContext,
) -> Option<AIConversationId> {
let active_conversation_id = self.agent_view_state.active_conversation_id()?;
adjacent_orchestration_child_conversation_id(
BlocklistAIHistoryModel::as_ref(app),
active_conversation_id,
direction,
)
}
/// Returns whether the user is allowed to exit agent view.
/// This is used to determine both whether the escape key should work
/// and whether the escape keybinding should be displayed.
pub fn can_exit_agent_view(
&self,
ctx: &impl galaxyui::ModelAsRef,
) -> Result<(), ExitAgentViewError> {
pub fn can_exit_agent_view(&self) -> Result<(), ExitAgentViewError> {
let model = self.terminal_model.lock();
let ambient_agent_model = self.ambient_agent_view_model.as_ref(ctx);
let is_fullscreen_with_long_running = self.agent_view_state.is_fullscreen()
&& model
.block_list()
.active_block()
.is_active_and_long_running();
// Ambient agent sessions should not allow exiting agent view if it's not backed by a terminal session because there's nothing to escape to.
if ambient_agent_model.is_ambient_agent() {
if !ambient_agent_model.has_parent_terminal() {
return Err(ExitAgentViewError::AmbientAgent);
}
// But if there is a terminal backing and we're in a LRC, we should be able to escape
else if is_fullscreen_with_long_running {
return Ok(());
}
}
// In a non-ambient agent case, users cannot exit the fullscreen agent view with an active long running command.
if is_fullscreen_with_long_running {
// Cloud agent panes do not have the same underlying terminal ownership
// constraint (no local shell process), so long-running third party agent
// commands should not trap the user in agent view.
if is_fullscreen_with_long_running && !model.is_dummy_cloud_mode_session() {
return Err(ExitAgentViewError::LongRunningCommand);
}
@@ -569,6 +588,26 @@ impl AgentViewController {
&mut self,
keybinding_name: &str,
ctx: &mut ModelContext<Self>,
) -> bool {
let Some(keystroke) = keybinding_name_to_keystroke(keybinding_name, ctx) else {
log::warn!(
"Expected keybinding for slash command {keybinding_name}, but none was found"
);
return true;
};
self.should_start_new_conversation_for_keystroke(keystroke, ctx)
}
/// Decides whether a keybinding-triggered new conversation should proceed immediately.
///
/// We only require a second press when the user is already in an active, non-empty
/// conversation. This protects against accidental conversation resets from muscle-memory
/// keypresses, while preserving single-step behavior for explicit typed/slash-menu execution.
pub fn should_start_new_conversation_for_keystroke(
&mut self,
keystroke: Keystroke,
ctx: &mut ModelContext<Self>,
) -> bool {
enum Decision {
StartNewConversation,
@@ -596,13 +635,6 @@ impl AgentViewController {
break 'decision Decision::StartNewConversation;
}
let Some(keystroke) = keybinding_name_to_keystroke(keybinding_name, ctx) else {
log::warn!(
"Expected keybinding for slash command {keybinding_name}, but none was found"
);
break 'decision Decision::StartNewConversation;
};
let normalized_keystroke = keystroke.normalized();
if self.is_new_conversation_keybinding_confirmation_active_for(
conversation_id,
@@ -711,7 +743,7 @@ impl AgentViewController {
.active_block()
.is_active_and_long_running()
&& !terminal_model.is_conversation_transcript_viewer()
&& !matches!(origin, AgentViewEntryOrigin::ThirdPartyCloudAgent)
&& !matches!(&origin, AgentViewEntryOrigin::ThirdPartyCloudAgent)
};
if is_long_running {
@@ -781,7 +813,8 @@ impl AgentViewController {
history_model.start_new_conversation(
self.terminal_view_id,
false,
matches!(origin, AgentViewEntryOrigin::CloudAgent),
matches!(&origin, AgentViewEntryOrigin::CloudAgent),
matches!(&origin, AgentViewEntryOrigin::ThirdPartyCloudAgent),
ctx,
)
});
@@ -793,7 +826,7 @@ impl AgentViewController {
self.agent_view_state = AgentViewState::Active {
conversation_id,
origin,
origin: origin.clone(),
display_mode,
original_conversation_length: exchange_count,
};
@@ -802,21 +835,6 @@ impl AgentViewController {
.block_list_mut()
.set_agent_view_state(self.agent_view_state.clone());
if origin == AgentViewEntryOrigin::CloudAgent {
// Only enter setup state if there are no existing environments.
// If environments exist, the user should go directly to composing.
let has_environments =
!crate::ai::cloud_environments::CloudAmbientAgentEnvironment::get_all(ctx)
.is_empty();
self.ambient_agent_view_model.update(ctx, |model, ctx| {
if has_environments {
model.enter_composing_from_setup(ctx);
} else {
model.enter_setup(ctx);
}
});
}
ctx.emit(AgentViewControllerEvent::EnteredAgentView {
conversation_id,
is_new: exchange_count == 0,
@@ -882,7 +900,7 @@ impl AgentViewController {
) {
self.clear_new_conversation_keybinding_confirmation(ctx);
// Check if exiting agent view is allowed.
if self.can_exit_agent_view(ctx).is_err() {
if self.can_exit_agent_view().is_err() {
return;
}
@@ -942,16 +960,6 @@ impl AgentViewController {
.block_list_mut()
.set_agent_view_state(self.agent_view_state.clone());
// Capture ambient agent status before resetting it
let was_ambient_agent = self.ambient_agent_view_model.as_ref(ctx).is_ambient_agent();
// Reset ambient agent status when exiting agent view
if was_ambient_agent {
self.ambient_agent_view_model.update(ctx, |model, ctx| {
model.reset_status(ctx);
});
}
let history_model = BlocklistAIHistoryModel::handle(ctx);
let final_exchange_count = history_model
.as_ref(ctx)
@@ -959,6 +967,7 @@ impl AgentViewController {
.map(|conversation| conversation.exchange_count())
.unwrap_or(0);
let was_ambient_agent = origin == AgentViewEntryOrigin::CloudAgent;
ctx.emit(AgentViewControllerEvent::ExitedAgentView {
conversation_id,
origin,
@@ -1012,7 +1021,6 @@ fn exit_confirmation_message(
) -> Message {
use galaxyui::SingletonEntity;
use crate::terminal::input::message_bar::{Message, MessageItem};
let appearance = Appearance::handle(app).as_ref(app);
@@ -0,0 +1,176 @@
use warpui::{AppContext, EntityId, ModelContext, ModelHandle, SingletonEntity};
use super::{
AgentViewController, AgentViewControllerEvent, AgentViewEntryOrigin, EnterAgentViewError,
};
use crate::ai::agent::conversation::{AIConversationAutoexecuteMode, AIConversationId};
use crate::ai::blocklist::conversation_selection::{
ConversationSelection, ConversationSelectionEvent,
};
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
/// GUI conversation selection backed unconditionally by Agent View.
pub(crate) struct AgentViewConversationSelection {
terminal_surface_id: EntityId,
agent_view_controller: ModelHandle<AgentViewController>,
}
impl AgentViewConversationSelection {
/// Creates GUI conversation selection for a terminal view.
pub(crate) fn new(
terminal_surface_id: warpui::EntityId,
agent_view_controller: ModelHandle<AgentViewController>,
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
) -> Self {
ctx.subscribe_to_model(&agent_view_controller, |_, _, event, ctx| match event {
AgentViewControllerEvent::EnteredAgentView {
display_mode,
origin,
..
} => {
ctx.emit(ConversationSelectionEvent::Changed);
ctx.emit(ConversationSelectionEvent::Activated {
is_fullscreen: display_mode.is_fullscreen(),
origin: origin.clone(),
});
}
AgentViewControllerEvent::ExitedAgentView {
conversation_id,
final_exchange_count,
is_exit_before_new_entrance,
..
} => {
ctx.emit(ConversationSelectionEvent::Changed);
ctx.emit(ConversationSelectionEvent::Deactivated {
conversation_id: *conversation_id,
final_exchange_count: *final_exchange_count,
is_exit_before_new_entrance: *is_exit_before_new_entrance,
});
}
AgentViewControllerEvent::ExitConfirmed { .. } => {}
});
ctx.subscribe_to_model(
&BlocklistAIHistoryModel::handle(ctx),
|selection, _, event, ctx| selection.handle_history_event(event, ctx),
);
Self {
terminal_surface_id,
agent_view_controller,
}
}
}
impl ConversationSelection for AgentViewConversationSelection {
fn selected_conversation_id(&self, app: &AppContext) -> Option<AIConversationId> {
self.agent_view_controller
.as_ref(app)
.agent_view_state()
.active_conversation_id()
}
fn is_conversation_active(&self, app: &AppContext) -> bool {
self.agent_view_controller.as_ref(app).is_active()
}
fn is_conversation_fullscreen(&self, app: &AppContext) -> bool {
self.agent_view_controller.as_ref(app).is_fullscreen()
}
fn select_existing_conversation(
&mut self,
conversation_id: AIConversationId,
origin: AgentViewEntryOrigin,
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
) {
if let Err(error) = 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: {error}");
}
}
fn select_new_conversation(
&mut self,
origin: AgentViewEntryOrigin,
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
) {
if let Err(error) = 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: {error}");
}
}
fn try_start_new_conversation(
&mut self,
origin: AgentViewEntryOrigin,
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
) -> Result<AIConversationId, EnterAgentViewError> {
self.agent_view_controller.update(ctx, |controller, ctx| {
controller.try_enter_agent_view(None, origin, ctx)
})
}
fn pending_query_autoexecute_override(
&self,
app: &AppContext,
) -> AIConversationAutoexecuteMode {
self.selected_conversation_id(app)
.as_ref()
.and_then(|conversation_id| {
BlocklistAIHistoryModel::as_ref(app).conversation(conversation_id)
})
.map(|conversation| conversation.autoexecute_override())
.unwrap_or_default()
}
fn toggle_pending_query_autoexecute(
&mut self,
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
) {
if let Some(conversation_id) = self.selected_conversation_id(ctx) {
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.toggle_autoexecute_override(
&conversation_id,
self.terminal_surface_id,
ctx,
);
});
}
}
fn handle_history_event(
&mut self,
event: &BlocklistAIHistoryEvent,
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
) {
if event
.terminal_surface_id()
.is_some_and(|id| id != self.terminal_surface_id)
{
return;
}
match event {
BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface { .. } => {
self.agent_view_controller
.update(ctx, |controller, ctx| controller.exit_agent_view(ctx));
}
BlocklistAIHistoryEvent::SplitConversation {
old_conversation_id,
new_conversation_id,
..
} if self.selected_conversation_id(ctx) == Some(*old_conversation_id) => {
self.select_existing_conversation(
*new_conversation_id,
AgentViewEntryOrigin::AgentRequestedNewConversation,
ctx,
);
}
_ => {}
}
}
}
#[cfg(test)]
#[path = "conversation_selection_tests.rs"]
mod tests;
@@ -0,0 +1,68 @@
use std::sync::Arc;
use parking_lot::FairMutex;
use warpui::r#async::executor::Background;
use warpui::{App, EntityId};
use super::AgentViewConversationSelection;
use crate::ai::blocklist::agent_view::{
AgentViewController, AgentViewEntryOrigin, EphemeralMessageModel,
};
use crate::ai::blocklist::{BlocklistAIHistoryModel, ConversationSelection};
use crate::terminal::color::{self, Colors};
use crate::terminal::event_listener::ChannelEventListener;
use crate::terminal::model::test_utils::block_size;
use crate::terminal::TerminalModel;
use crate::test_util::settings::initialize_settings_for_tests;
#[test]
fn gui_selection_delegates_to_agent_view() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let terminal_surface_id = EntityId::new();
let terminal_model = Arc::new(FairMutex::new(TerminalModel::new_for_test(
block_size(),
color::List::from(&Colors::default()),
ChannelEventListener::new_for_test(),
Arc::new(Background::default()),
false,
None,
false,
false,
None,
)));
let ephemeral_message_model = app.add_model(|_| EphemeralMessageModel::new());
let agent_view_controller = app.add_model(|_| {
AgentViewController::new(terminal_model, terminal_surface_id, ephemeral_message_model)
});
let selection = app.add_model(|ctx| {
Box::new(AgentViewConversationSelection::new(
terminal_surface_id,
agent_view_controller.clone(),
ctx,
)) as Box<dyn ConversationSelection>
});
let conversation_id = history.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_surface_id, false, false, false, ctx)
});
selection.update(&mut app, |selection, ctx| {
selection.select_existing_conversation(
conversation_id,
AgentViewEntryOrigin::ConversationSelector,
ctx,
);
});
selection.read(&app, |selection, ctx| {
assert_eq!(
selection.selected_conversation_id(ctx),
Some(conversation_id)
);
});
agent_view_controller.read(&app, |controller, _| {
assert!(controller.is_active());
});
});
}
@@ -4,9 +4,8 @@ use std::time::Duration;
use galaxyui::r#async::SpawnedFutureHandle;
use galaxyui::{Entity, ModelContext};
use crate::terminal::input::message_bar::{Message, MessageProvider};
use super::agent_message_bar::AgentMessageArgs;
use crate::terminal::input::message_bar::{Message, MessageProvider};
const DEFAULT_MESSAGE_DURATION: Duration = Duration::from_millis(1500);
@@ -1,25 +1,23 @@
use std::sync::Arc;
use ai::agent::action::{AIAgentActionType, ShellCommandDelay};
use parking_lot::FairMutex;
use galaxy_core::ui::appearance::Appearance;
use galaxyui::elements::{CornerRadius, Radius};
use galaxyui::{
elements::{CornerRadius, Radius},
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity, View, ViewContext,
};
use parking_lot::FairMutex;
use crate::{
ai::{
agent::icons,
blocklist::{
block::cli_controller::LongRunningCommandControlState,
inline_action::inline_action_header::HeaderConfig, BlocklistAIActionModel,
BlocklistAIHistoryEvent, BlocklistAIHistoryModel,
},
},
terminal::{model::session::Sessions, TerminalModel},
ui_components::{blended_colors, icons::Icon},
use crate::ai::agent::icons;
use crate::ai::blocklist::block::cli_controller::LongRunningCommandControlState;
use crate::ai::blocklist::inline_action::inline_action_header::HeaderConfig;
use crate::ai::blocklist::{
BlocklistAIActionModel, BlocklistAIHistoryEvent, BlocklistAIHistoryModel,
};
use crate::terminal::model::session::Sessions;
use crate::terminal::TerminalModel;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
const AGENT_PROMPT_TO_INTERACT_MESSAGE: &str = "Prompt agent to interact with";
const AGENT_WAITING_ON_INSTRUCTIONS_MESSAGE: &str = "Agent is waiting on instructions";
@@ -47,7 +45,7 @@ impl InlineAgentViewHeader {
let history_model = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_model, move |me, _, event, ctx| {
if event
.terminal_view_id()
.terminal_surface_id()
.is_some_and(|id| id != me.terminal_view_id)
{
return;
+52 -7
View File
@@ -1,33 +1,39 @@
pub(crate) mod agent_input_footer;
mod agent_message_bar;
mod agent_view_block;
pub mod child_agent_status_card;
mod controller;
mod conversation_selection;
mod ephemeral_message_model;
mod inline_agent_view_header;
// TODO: Move orchestration_conversation_links module import elsewhere.
pub(crate) mod orchestration_avatar;
pub(crate) mod orchestration_conversation_links;
pub mod orchestration_pill_bar;
pub mod orchestration_pill_bar_model;
pub mod shortcuts;
pub(crate) mod subagent_inline_panel;
mod zero_state_block;
use std::sync::LazyLock;
pub use agent_input_footer::*;
pub use agent_message_bar::*;
pub use agent_view_block::*;
pub use controller::*;
pub(crate) use conversation_selection::AgentViewConversationSelection;
pub use ephemeral_message_model::*;
use galaxyui::fonts::Properties;
pub use inline_agent_view_header::*;
pub use zero_state_block::*;
use std::sync::LazyLock;
pub use orchestration_pill_bar::{render_orchestration_breadcrumbs, OrchestrationPillBar};
use pathfinder_color::ColorU;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::color::blend::Blend;
use galaxy_core::ui::theme::Fill;
use galaxy_core::ui::{appearance::Appearance, color::blend::Blend};
use galaxyui::keymap::Keystroke;
use galaxyui::{AppContext, SingletonEntity};
use pathfinder_color::ColorU;
pub use zero_state_block::*;
use crate::terminal::model::TerminalModel;
use crate::view_components::action_button::ActionButtonTheme;
pub static ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE: LazyLock<Keystroke> = LazyLock::new(|| {
@@ -49,6 +55,45 @@ pub static ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE: LazyLock<Keystroke> = La
}
});
pub static ENTER_CLOUD_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE: LazyLock<Keystroke> =
LazyLock::new(|| {
cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
Keystroke {
cmd: true,
alt: true,
key: "enter".to_owned(),
..Default::default()
}
} else {
Keystroke {
ctrl: true,
alt: true,
key: "enter".to_owned(),
..Default::default()
}
}
}
});
/// Returns `true` when the current pane is in a cloud or remote context.
pub fn is_in_cloud_context(
agent_view_state: &AgentViewState,
terminal_model: &TerminalModel,
) -> bool {
let origin_is_cloud = matches!(
agent_view_state,
AgentViewState::Active { origin, .. }
if matches!(
origin,
AgentViewEntryOrigin::CloudAgent | AgentViewEntryOrigin::ThirdPartyCloudAgent
)
);
origin_is_cloud
|| terminal_model.is_conversation_transcript_viewer()
|| terminal_model.is_dummy_cloud_mode_session()
}
pub fn agent_view_bg_fill(app: &AppContext) -> Fill {
let appearance = Appearance::as_ref(app);
appearance.theme().surface_overlay_1()
@@ -0,0 +1,41 @@
use warpui::elements::Element;
use warpui::{AppContext, SingletonEntity};
use crate::ai::blocklist::agent_view::orchestration_pill_bar::{
render_agent_avatar_disc, render_orchestrator_avatar_disc,
};
use crate::appearance::Appearance;
const TRANSCRIPT_AVATAR_SCALE: f32 = 1.25;
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum OrchestrationAvatar {
Orchestrator,
Agent { display_name: String },
}
impl OrchestrationAvatar {
pub(crate) fn agent(display_name: String) -> Self {
Self::Agent { display_name }
}
pub(crate) fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let size = app.font_cache().line_height(
appearance.monospace_font_size(),
appearance.line_height_ratio(),
) * TRANSCRIPT_AVATAR_SCALE;
match self {
Self::Orchestrator => render_orchestrator_avatar_disc(size, theme, appearance),
Self::Agent { display_name } => {
render_agent_avatar_disc(display_name, size, theme, appearance)
}
}
}
}
#[cfg(test)]
#[path = "orchestration_avatar_tests.rs"]
mod tests;
@@ -0,0 +1,11 @@
use super::OrchestrationAvatar;
#[test]
fn agent_avatar_identity_is_display_name_based_for_pill_consistency() {
assert_eq!(
OrchestrationAvatar::agent("Agent 1".to_string()),
OrchestrationAvatar::Agent {
display_name: "Agent 1".to_string(),
}
);
}
@@ -1,30 +1,28 @@
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::Fill;
use galaxyui::{
elements::{
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Expanded, Flex,
Hoverable, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
},
fonts::{Properties, Weight::Bold},
platform::Cursor,
text_layout::ClipConfig,
AppContext, Element, EventContext, SingletonEntity,
};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::Vector2F;
use crate::{
ai::{
agent::{
api::ServerConversationToken,
conversation::{AIConversation, AIConversationId},
},
agent_conversations_model::AgentConversationsModel,
blocklist::BlocklistAIHistoryModel,
},
ui_components::{blended_colors, icons::Icon},
workspace::{RestoreConversationLayout, WorkspaceAction},
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::Fill;
use galaxyui::elements::{
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Expanded, Flex, Hoverable,
MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
};
use galaxyui::fonts::Properties;
use galaxyui::fonts::Weight::Bold;
use galaxyui::platform::Cursor;
use galaxyui::text_layout::ClipConfig;
use galaxyui::{AppContext, Element, EntityId, EventContext, SingletonEntity};
use crate::ai::agent::api::ServerConversationToken;
use crate::ai::agent::conversation::{AIConversation, AIConversationId};
use crate::ai::agent_conversations_model::entry::AgentConversationEntryId;
use crate::ai::agent_conversations_model::{
AgentConversationNavigationSubject, AgentConversationsModel,
};
use crate::ai::blocklist::BlocklistAIHistoryModel;
use crate::terminal::view::TerminalAction;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::workspace::{RestoreConversationLayout, WorkspaceAction, WorkspaceRegistry};
pub(crate) fn conversation_id_for_agent_id(
agent_id: &str,
@@ -40,6 +38,79 @@ pub(crate) fn conversation_id_for_agent_id(
})
}
/// True if the conversation is open in some other visible pane. Hidden
/// child-agent panes are excluded so unopened children don't look
/// "already open".
pub(crate) fn is_conversation_open_in_other_visible_view(
conversation_id: AIConversationId,
self_terminal_view_id: EntityId,
app: &AppContext,
) -> bool {
let Some(owner) =
BlocklistAIHistoryModel::as_ref(app).terminal_surface_id_for_conversation(&conversation_id)
else {
return false;
};
if owner == self_terminal_view_id {
return false;
}
pane_group_id_containing_terminal_view(owner, app).is_some()
}
/// Finds the pane group containing the given terminal view across all
/// visible panes/tabs. Used to distinguish same-tab vs cross-tab focus.
pub(crate) fn pane_group_id_containing_terminal_view(
terminal_view_id: EntityId,
app: &AppContext,
) -> Option<EntityId> {
let registry = WorkspaceRegistry::as_ref(app);
for (_, workspace_handle) in registry.all_workspaces(app) {
let workspace = workspace_handle.as_ref(app);
for pane_group_handle in workspace.tab_views() {
let pane_group = pane_group_handle.as_ref(app);
for pane_id in pane_group.visible_pane_ids() {
if let Some(terminal_view) = pane_group.terminal_view_from_pane_id(pane_id, app) {
if terminal_view.id() == terminal_view_id {
return Some(pane_group_handle.id());
}
}
}
}
}
None
}
/// Navigates to a child agent's pane: focuses an existing sibling pane,
/// activates the owning tab, or splits off a new pane.
pub(crate) fn dispatch_focus_or_open_child_agent_pane(
conversation_id: AIConversationId,
self_terminal_view_id: EntityId,
ctx: &mut EventContext,
app: &AppContext,
) {
if let Some(owner_view_id) =
BlocklistAIHistoryModel::as_ref(app).terminal_surface_id_for_conversation(&conversation_id)
{
if owner_view_id != self_terminal_view_id {
if let Some(owner_pane_group_id) =
pane_group_id_containing_terminal_view(owner_view_id, app)
{
let self_pane_group_id =
pane_group_id_containing_terminal_view(self_terminal_view_id, app);
if Some(owner_pane_group_id) == self_pane_group_id {
ctx.dispatch_typed_action(TerminalAction::RevealChildAgent { conversation_id });
} else {
ctx.dispatch_typed_action(WorkspaceAction::FocusTerminalViewInWorkspace {
terminal_view_id: owner_view_id,
});
}
return;
}
}
}
ctx.dispatch_typed_action(TerminalAction::OpenChildAgentInNewPane { conversation_id });
}
pub(crate) fn parent_conversation_id(
active_conversation: &AIConversation,
app: &AppContext,
@@ -54,19 +125,14 @@ pub(crate) fn parent_conversation_id(
pub(crate) fn conversation_navigation_action(
conversation_id: AIConversationId,
app: &AppContext,
) -> WorkspaceAction {
AgentConversationsModel::as_ref(app)
.get_conversation(&conversation_id)
.and_then(|conversation| {
conversation.get_open_action(Some(RestoreConversationLayout::SplitPane), app)
})
.unwrap_or(WorkspaceAction::RestoreOrNavigateToConversation {
pane_view_locator: None,
window_id: None,
) -> Option<WorkspaceAction> {
AgentConversationsModel::resolve_open_action(
AgentConversationNavigationSubject::Entry(AgentConversationEntryId::Conversation(
conversation_id,
terminal_view_id: None,
restore_layout: Some(RestoreConversationLayout::SplitPane),
})
)),
Some(RestoreConversationLayout::SplitPane),
app,
)
}
pub(crate) fn parent_conversation_navigation_card(
@@ -79,7 +145,7 @@ pub(crate) fn parent_conversation_navigation_card(
.conversation(&parent_conversation_id)
.and_then(|conversation| conversation.title())
.unwrap_or_else(|| "Parent conversation".to_string());
let action = conversation_navigation_action(parent_conversation_id, app);
let action = conversation_navigation_action(parent_conversation_id, app)?;
Some(conversation_navigation_card(
parent_title,
Some("Back to parent conversation".to_string()),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,133 @@
//! Shared singleton holding cross-pane UI state for the orchestration pill
//! bar: the set of pinned child-agent conversations (persisted so pins
//! survive restarts) and a per-orchestrator horizontal scroll handle so
//! the pill row's scroll offset survives switching between sibling panes.
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use warpui::elements::ClippedScrollStateHandle;
use warpui::{Entity, ModelContext, SingletonEntity};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
/// Toggle `id` in `pinned` — insert when absent, remove when present.
/// Returns the new membership state (`true` = now pinned).
/// Extracted so toggle semantics can be unit tested without a singleton.
pub(super) fn toggle_pin_in_set(
pinned: &mut HashSet<AIConversationId>,
id: AIConversationId,
) -> bool {
if pinned.remove(&id) {
false
} else {
pinned.insert(id);
true
}
}
/// Singleton owning cross-pane UI state for the orchestration pill bar.
pub struct OrchestrationPillBarModel {
/// In-memory mirror of which child conversations are pinned. The
/// persisted source of truth lives on each conversation; this set
/// exists for fast lookups and cross-pane event notification.
pinned: HashSet<AIConversationId>,
/// One scroll handle per orchestrator conversation id. Every pill
/// bar rendering the same orchestration tree clones the same handle
/// so panning in one pane is reflected in sibling panes. `RefCell`
/// so handles can be lazily created from `&AppContext` paths.
horizontal_scroll_states: RefCell<HashMap<AIConversationId, ClippedScrollStateHandle>>,
}
impl OrchestrationPillBarModel {
/// Construct the singleton seeded with the set of conversation ids that
/// were already pinned in persisted storage at startup.
pub fn new(initial_pinned: HashSet<AIConversationId>, ctx: &mut ModelContext<Self>) -> Self {
// Prune deleted conversations globally so per-pane code never
// has to (and can't accidentally clobber sibling panes' pins).
let history_handle = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_handle, |this, _, event, ctx| match event {
BlocklistAIHistoryEvent::RemoveConversation {
conversation_id, ..
}
| BlocklistAIHistoryEvent::DeletedConversation {
conversation_id, ..
} => {
if this.pinned.remove(conversation_id) {
ctx.emit(OrchestrationPillBarEvent::PinSetChanged);
}
// Drop the matching scroll handle so deleted orchestrators
// don't leak entries. No event needed — nothing subscribes
// to scroll-state changes.
this.horizontal_scroll_states
.borrow_mut()
.remove(conversation_id);
}
_ => {}
});
Self {
pinned: initial_pinned,
horizontal_scroll_states: RefCell::new(HashMap::new()),
}
}
/// Returns `true` if the given conversation id is currently pinned.
pub fn is_pinned(&self, conversation_id: &AIConversationId) -> bool {
self.pinned.contains(conversation_id)
}
/// Returns the shared horizontal scroll handle for the given
/// orchestration tree, lazily creating one on first access. Every
/// pill bar in that tree clones the same handle, so panning in one
/// pane stays in sync with sibling panes.
pub fn horizontal_scroll_state_for(
&self,
orchestrator_id: AIConversationId,
) -> ClippedScrollStateHandle {
self.horizontal_scroll_states
.borrow_mut()
.entry(orchestrator_id)
.or_default()
.clone()
}
/// Toggle whether `conversation_id` is pinned, persist the new state to
/// the underlying conversation, and notify subscribers.
pub fn toggle_pin(&mut self, conversation_id: AIConversationId, ctx: &mut ModelContext<Self>) {
let now_pinned = toggle_pin_in_set(&mut self.pinned, conversation_id);
// Push the change down to the conversation so the next session
// restore reflects it. Doing this even if the conversation isn't
// yet loaded is fine — the in-memory set is the active source
// until that conversation rehydrates.
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.set_conversation_pinned(conversation_id, now_pinned, ctx);
});
ctx.emit(OrchestrationPillBarEvent::PinSetChanged);
}
/// Clears the in-memory pinned set and the scroll handle cache.
/// Invoked on logout so the next user does not inherit the previous
/// account's UI state; persisted pins are wiped by the sqlite reset
/// that runs alongside logout.
pub fn reset(&mut self) {
self.pinned.clear();
self.horizontal_scroll_states.borrow_mut().clear();
}
}
impl Entity for OrchestrationPillBarModel {
type Event = OrchestrationPillBarEvent;
}
impl SingletonEntity for OrchestrationPillBarModel {}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum OrchestrationPillBarEvent {
/// The pinned set changed (toggle or history-driven prune).
PinSetChanged,
}
#[cfg(test)]
#[path = "orchestration_pill_bar_model_tests.rs"]
mod tests;
@@ -0,0 +1,146 @@
use std::collections::HashSet;
use warpui::App;
use super::*;
use crate::ai::agent::conversation::AIConversation;
use crate::persistence::ModelEvent;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::{GlobalResourceHandles, GlobalResourceHandlesProvider};
#[test]
fn toggle_pin_in_set_flips_membership_for_each_call() {
let mut pinned: HashSet<AIConversationId> = HashSet::new();
let id = AIConversationId::new();
// Initially empty.
assert!(!pinned.contains(&id));
// First toggle pins the id and reports the new state.
assert!(toggle_pin_in_set(&mut pinned, id));
assert!(pinned.contains(&id));
assert_eq!(pinned.len(), 1);
// Second toggle unpins it.
assert!(!toggle_pin_in_set(&mut pinned, id));
assert!(!pinned.contains(&id));
assert!(pinned.is_empty());
// Third toggle re-pins, confirming the function is idempotent under
// pairs and doesn't accumulate state.
assert!(toggle_pin_in_set(&mut pinned, id));
assert!(pinned.contains(&id));
}
#[test]
fn toggle_pin_in_set_only_affects_target_id() {
let mut pinned: HashSet<AIConversationId> = HashSet::new();
let a = AIConversationId::new();
let b = AIConversationId::new();
toggle_pin_in_set(&mut pinned, a);
toggle_pin_in_set(&mut pinned, b);
assert!(pinned.contains(&a));
assert!(pinned.contains(&b));
// Unpinning `a` leaves `b` alone.
toggle_pin_in_set(&mut pinned, a);
assert!(!pinned.contains(&a));
assert!(pinned.contains(&b));
}
/// End-to-end check that toggling a pin (a) updates the in-memory
/// `AIConversation.pinned` flag on the history model and (b) emits an
/// `UpdateMultiAgentConversation` SQLite event carrying `pinned: true`,
/// which is what makes the pin survive an app restart.
#[test]
fn toggle_pin_persists_pinned_state_to_sqlite_event() {
App::test((), |mut app| async move {
// `write_updated_conversation_state` reads `GeneralSettings`,
// `AppExecutionMode`, and the global model_event_sender; wire all
// three up so the SQLite write path actually runs.
initialize_settings_for_tests(&mut app);
let (sender, receiver) = std::sync::mpsc::sync_channel::<ModelEvent>(4);
let mut global_resource_handles = GlobalResourceHandles::mock(&mut app);
global_resource_handles.model_event_sender = Some(sender);
app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resource_handles));
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[]));
// Restore a conversation that starts unpinned. The history model is
// the source of truth for the persisted flag.
let conversation = AIConversation::new(false, false);
let conversation_id = conversation.id();
let terminal_view_id = warpui::EntityId::new();
history_model.update(&mut app, |model, ctx| {
model.restore_conversations(terminal_view_id, vec![conversation], ctx);
});
// Pin model must be registered after the history model because it
// subscribes to history events on construction.
let pin_model =
app.add_singleton_model(|ctx| OrchestrationPillBarModel::new(HashSet::new(), ctx));
pin_model.update(&mut app, |model, ctx| {
model.toggle_pin(conversation_id, ctx);
});
// In-memory pin set reflects the toggle.
pin_model.read(&app, |model, _| {
assert!(
model.is_pinned(&conversation_id),
"pin set should contain the conversation after toggle"
);
});
// The conversation's persisted `pinned` flag is now true.
history_model.read(&app, |model, _| {
let pinned = model.conversation(&conversation_id).map(|c| c.is_pinned());
assert_eq!(
pinned,
Some(true),
"AIConversation.is_pinned() should reflect the toggle"
);
});
// The SQLite writer should have received an UpdateMultiAgentConversation
// event with pinned=true. The fire-and-forget send happens on the
// executor, so allow a short timeout.
let event = receiver
.recv_timeout(std::time::Duration::from_secs(1))
.expect("expected an UpdateMultiAgentConversation event after toggle_pin");
match event {
ModelEvent::UpdateMultiAgentConversation {
conversation_id: persisted_id,
conversation_data,
..
} => {
assert_eq!(persisted_id, conversation_id.to_string());
assert!(
conversation_data.pinned,
"persisted AgentConversationData.pinned should be true"
);
}
other => panic!("expected UpdateMultiAgentConversation, got {other:?}"),
}
// Toggling again should write pinned=false through the same path.
pin_model.update(&mut app, |model, ctx| {
model.toggle_pin(conversation_id, ctx);
});
let event = receiver
.recv_timeout(std::time::Duration::from_secs(1))
.expect("expected a second UpdateMultiAgentConversation event after unpin");
match event {
ModelEvent::UpdateMultiAgentConversation {
conversation_data, ..
} => {
assert!(
!conversation_data.pinned,
"persisted AgentConversationData.pinned should be false after unpin"
);
}
other => panic!("expected UpdateMultiAgentConversation, got {other:?}"),
}
});
}
@@ -0,0 +1,199 @@
use super::*;
// Traversal and canonical pill-order correctness are exercised in
// `app/src/ai/blocklist/orchestration_topology_tests.rs`. These tests stay
// focused on the pill bar's own dispatch behavior.
/// The data layer that `OrchestrationPillBar::pill_specs` reads must
/// surface restored orchestration children before any pane has been created.
///
/// `pill_specs` (defined privately on `OrchestrationPillBar`) walks
/// `descendant_conversation_ids_in_spawn_order(history, orchestrator_id)` and
/// then `filter_map(|id| history.conversation(&id))`. The
/// `history.conversation(&id)` lookup must return `Some` for restored
/// children even before the parent's hidden pane materializes, or the pill
/// bar renders nothing. This test asserts both layers work after
/// `BlocklistAIHistoryModel::new` runs, before any `restore_conversations` /
/// pane materialization.
#[test]
fn pill_bar_data_layer_finds_restored_children_before_pane_creation() {
use chrono::Utc;
use uuid::Uuid;
use warpui::App;
use crate::ai::blocklist::orchestration_topology::descendant_conversation_ids_in_spawn_order;
use crate::ai::blocklist::BlocklistAIHistoryModel;
use crate::persistence::model::{
AgentConversation, AgentConversationData, AgentConversationRecord,
};
App::test((), |app| async move {
let parent_id = AIConversationId::new();
let child_id = AIConversationId::new();
let parent_run_id = Uuid::new_v4().to_string();
let child_run_id = Uuid::new_v4().to_string();
let now = Utc::now().naive_utc();
let conversations = vec![
AgentConversation {
conversation: AgentConversationRecord {
id: 1,
conversation_id: child_id.to_string(),
conversation_data: serde_json::to_string(&AgentConversationData {
server_conversation_token: Some("child-token".to_string()),
conversation_usage_metadata: None,
reverted_action_ids: None,
forked_from_server_conversation_token: None,
artifacts_json: None,
parent_agent_id: Some(parent_run_id.clone()),
agent_name: Some("Agent 1".to_string()),
orchestration_harness_type: None,
parent_conversation_id: Some(parent_id.to_string()),
is_remote_child: false,
root_task_is_optimistic: None,
run_id: Some(child_run_id.clone()),
autoexecute_override: None,
last_event_sequence: None,
pinned: false,
})
.expect("child conversation data should serialize"),
last_modified_at: now,
},
tasks: vec![warp_multi_agent_api::Task {
id: format!("task-{child_id}"),
messages: vec![warp_multi_agent_api::Message {
fetched_memories: vec![],
id: "child-msg".to_string(),
task_id: format!("task-{child_id}"),
server_message_data: String::new(),
citations: vec![],
message: Some(warp_multi_agent_api::message::Message::UserQuery(
warp_multi_agent_api::message::UserQuery {
query: "Child query".to_string(),
context: None,
referenced_attachments: Default::default(),
mode: None,
intended_agent: Default::default(),
},
)),
request_id: "request-1".to_string(),
timestamp: None,
}],
dependencies: None,
description: "Child query".to_string(),
summary: String::new(),
server_data: String::new(),
}],
},
AgentConversation {
conversation: AgentConversationRecord {
id: 2,
conversation_id: parent_id.to_string(),
conversation_data: serde_json::to_string(&AgentConversationData {
server_conversation_token: Some("parent-token".to_string()),
conversation_usage_metadata: None,
reverted_action_ids: None,
forked_from_server_conversation_token: None,
artifacts_json: None,
parent_agent_id: None,
agent_name: None,
orchestration_harness_type: None,
parent_conversation_id: None,
is_remote_child: false,
root_task_is_optimistic: None,
run_id: Some(parent_run_id.clone()),
autoexecute_override: None,
last_event_sequence: None,
pinned: false,
})
.expect("parent conversation data should serialize"),
last_modified_at: now - chrono::Duration::seconds(1),
},
tasks: vec![warp_multi_agent_api::Task {
id: format!("task-{parent_id}"),
messages: vec![warp_multi_agent_api::Message {
fetched_memories: vec![],
id: "parent-msg".to_string(),
task_id: format!("task-{parent_id}"),
server_message_data: String::new(),
citations: vec![],
message: Some(warp_multi_agent_api::message::Message::UserQuery(
warp_multi_agent_api::message::UserQuery {
query: "Parent query".to_string(),
context: None,
referenced_attachments: Default::default(),
mode: None,
intended_agent: Default::default(),
},
)),
request_id: "request-2".to_string(),
timestamp: None,
}],
dependencies: None,
description: "Parent query".to_string(),
summary: String::new(),
server_data: String::new(),
}],
},
];
let history_model =
app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &conversations));
history_model.read(&app, |model, _| {
// pill_specs walks `descendant_conversation_ids_in_spawn_order`
// first. This index must be populated for restored children at
// app startup, before any pane materializes.
let descendants = descendant_conversation_ids_in_spawn_order(model, parent_id);
assert_eq!(
descendants,
vec![child_id],
"orchestration topology must surface restored children before any pane is created",
);
// pill_specs then collects pill specs via
// `descendants.into_iter().filter_map(|id| history.conversation(&id))`.
// The child must be hydrated eagerly so this lookup succeeds and
// the pill bar renders; otherwise the filter_map would drop the
// child (because `conversation(&child_id)` returned `None`) and
// `pill_specs` would return `None` from the
// `children.is_empty()` early-exit.
let resolved_children: Vec<&AIConversation> = descendants
.iter()
.filter_map(|id| model.conversation(id))
.collect();
assert_eq!(
resolved_children.len(),
1,
"restored child conversation must be available in conversations_by_id so \
OrchestrationPillBar::pill_specs renders a child pill",
);
assert_eq!(resolved_children[0].id(), child_id);
assert_eq!(resolved_children[0].agent_name(), Some("Agent 1"));
});
});
}
#[test]
fn navigation_action_for_child_pill_reveals_existing_child_pane() {
let conversation_id = AIConversationId::new();
assert!(matches!(
navigation_action_for_pill(PillKind::Child, conversation_id),
TerminalAction::RevealChildAgent {
conversation_id: actual_id,
} if actual_id == conversation_id
));
}
#[test]
fn navigation_action_for_orchestrator_pill_switches_in_place() {
let conversation_id = AIConversationId::new();
assert!(matches!(
navigation_action_for_pill(PillKind::Orchestrator, conversation_id),
TerminalAction::SwitchAgentViewToConversation {
conversation_id: actual_id,
} if actual_id == conversation_id
));
}
@@ -1,27 +1,27 @@
mod model;
pub use model::*;
use pathfinder_color::ColorU;
use std::borrow::Cow;
use galaxy_core::{features::FeatureFlag, ui::appearance::Appearance};
use galaxyui::{
elements::{Border, Container, CrossAxisAlignment, Expanded, Flex, ParentElement, Text},
keymap::Keystroke,
ui_components::components::{Coords, UiComponent, UiComponentStyles},
AppContext, Element, SingletonEntity,
pub use model::*;
use pathfinder_color::ColorU;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::appearance::Appearance;
use galaxyui::elements::{
Border, Container, CrossAxisAlignment, Expanded, Flex, ParentElement, Text,
};
use warpui::keymap::Keystroke;
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use warpui::{AppContext, Element, SingletonEntity};
use crate::{
ai::blocklist::agent_view::ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE,
cmd_or_ctrl_shift,
terminal::{self, TOGGLE_AUTOEXECUTE_MODE_KEYBINDING},
ui_components::blended_colors,
util::bindings::keybinding_name_to_keystroke,
workspace::view::{
TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME, TOGGLE_RIGHT_PANEL_BINDING_NAME,
},
use crate::ai::blocklist::agent_view::{
ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE, ENTER_CLOUD_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE,
};
use crate::cmd_or_ctrl_shift;
use crate::terminal::{self, TOGGLE_AUTOEXECUTE_MODE_KEYBINDING};
use crate::ui_components::blended_colors;
use crate::util::bindings::keybinding_name_to_keystroke;
use crate::workspace::view::{
TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME, TOGGLE_RIGHT_PANEL_BINDING_NAME,
};
#[derive(Copy, Clone, Debug, Default)]
@@ -259,7 +259,6 @@ pub fn render_agent_shortcuts_view(
}
pub mod styles {
use galaxy_core::ui::appearance::Appearance;
pub fn keystroke_size(appearance: &Appearance) -> f32 {
font_size(appearance) + 2.
@@ -1,12 +1,9 @@
use galaxy_core::send_telemetry_from_ctx;
use galaxyui::{Entity, ModelContext, ModelHandle};
use galaxy_core::send_telemetry_from_ctx;
use crate::{
ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent},
server::telemetry::TelemetryEvent,
terminal::input::buffer_model::InputBufferModel,
};
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent};
use crate::server::telemetry::TelemetryEvent;
use crate::terminal::input::buffer_model::InputBufferModel;
/// Model responsible for managing state required to conditionally render the shortcuts view.
pub struct AgentShortcutViewModel {
@@ -19,12 +16,12 @@ impl AgentShortcutViewModel {
agent_view_controller: ModelHandle<AgentViewController>,
ctx: &mut ModelContext<Self>,
) -> Self {
ctx.subscribe_to_model(&input_buffer_model, |me, event, ctx| {
ctx.subscribe_to_model(&input_buffer_model, |me, _, event, ctx| {
if me.is_shortcut_view_open && !event.new_content.is_empty() {
me.hide_shortcut_view(ctx);
}
});
ctx.subscribe_to_model(&agent_view_controller, |me, event, ctx| {
ctx.subscribe_to_model(&agent_view_controller, |me, _, event, ctx| {
if matches!(event, AgentViewControllerEvent::ExitedAgentView { .. }) {
me.hide_shortcut_view(ctx);
}
@@ -1,57 +1,53 @@
#![allow(dead_code, unused_imports, unused_variables)]
use galaxy_core::{features::FeatureFlag, report_if_error, ui::Icon};
use galaxyui::{
elements::{
Clipped, Container, CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement,
HighlightedHyperlink, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable,
Text,
},
fonts::{Properties, Weight},
keymap::Keystroke,
prelude::{Align, ConstrainedBox, Cursor, Empty, Hoverable, MainAxisAlignment, SavePosition},
scene::Border,
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
};
use std::borrow::Cow;
use std::cmp::Reverse;
use std::path::Path;
use std::sync::Arc;
use itertools::Itertools as _;
use markdown_parser::{parse_markdown, FormattedText, FormattedTextFragment, FormattedTextLine};
use parking_lot::FairMutex;
use settings::Setting;
use galaxy_core::features::FeatureFlag;
use galaxy_core::report_if_error;
use galaxy_core::ui::Icon;
use galaxyui::elements::{
Clipped, Container, CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement,
HighlightedHyperlink, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::keymap::Keystroke;
use galaxyui::prelude::{
Align, ConstrainedBox, Cursor, Empty, Hoverable, MainAxisAlignment, SavePosition,
};
use galaxyui::scene::Border;
use galaxyui::ui_components::components::{UiComponent as _, UiComponentStyles};
use galaxyui::{
Action, AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext,
};
use std::{borrow::Cow, cmp::Reverse, path::Path, sync::Arc};
use crate::{
ai::{
active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId},
agent::conversation::AIConversationId,
blocklist::{
agent_view::{
agent_view_bg_color, AgentViewController, AgentViewEntryOrigin,
ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE,
},
history_model::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel},
},
conversation_navigation::ConversationNavigationData,
},
appearance::Appearance,
changelog_model::{self, ChangelogModel},
settings::{AISettings, AISettingsChangedEvent},
terminal::{
self,
event::BlockType,
input::message_bar::{common::render_standard_message, Message, MessageItem},
model::{
blocks::BlockHeightItem,
session::{BootstrapSessionType, Session, SessionType, Sessions},
},
model_events::{AnsiHandlerEvent, ModelEvent, ModelEventDispatcher},
prompt,
view::{
ambient_agent::{AmbientAgentViewModel, AmbientAgentViewModelEvent},
TerminalAction,
},
TerminalModel,
},
util::time_format::format_approx_duration_from_now_utc,
use crate::ai::active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::blocklist::agent_view::{
agent_view_bg_color, AgentViewController, AgentViewEntryOrigin,
ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE, ENTER_CLOUD_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE,
};
use crate::ai::blocklist::history_model::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
use crate::ai::conversation_navigation::ConversationNavigationData;
use crate::appearance::Appearance;
use crate::changelog_model::{self, ChangelogModel};
use crate::settings::{AISettings, AISettingsChangedEvent};
use crate::terminal::event::BlockType;
use crate::terminal::input::message_bar::common::render_standard_message;
use crate::terminal::input::message_bar::{Message, MessageItem};
use crate::terminal::model::blocks::BlockHeightItem;
use crate::terminal::model::session::{BootstrapSessionType, Session, SessionType, Sessions};
use crate::terminal::model_events::{AnsiHandlerEvent, ModelEvent, ModelEventDispatcher};
use crate::terminal::view::ambient_agent::{AmbientAgentViewModel, AmbientAgentViewModelEvent};
use crate::terminal::view::TerminalAction;
use crate::terminal::{self, prompt, TerminalModel};
use crate::util::time_format::format_approx_duration_from_now_utc;
const CLOUD_AGENT_DOCS_URL: &str = "https://docs.warp.dev/agent-platform/cloud-agents/overview";
const OZ_UPDATES_SECTION_HEADER: &str = "What's new in Oz";
@@ -97,13 +93,14 @@ impl AgentViewZeroStateBlock {
origin: AgentViewEntryOrigin,
agent_view_controller: ModelHandle<AgentViewController>,
sessions: &ModelHandle<Sessions>,
cloud_agent_view_model: &ModelHandle<AmbientAgentViewModel>,
cloud_agent_view_model: Option<&ModelHandle<AmbientAgentViewModel>>,
terminal_model: Arc<FairMutex<TerminalModel>>,
model_events_dispatcher: &ModelHandle<ModelEventDispatcher>,
should_show_init_callout: bool,
ctx: &mut ViewContext<Self>,
) -> Self {
let cloud_agent_view_model_clone = cloud_agent_view_model.clone();
let cloud_agent_view_model_clone = cloud_agent_view_model.cloned();
let model_events_clone = model_events_dispatcher.clone();
ctx.subscribe_to_model(
&BlocklistAIHistoryModel::handle(ctx),
@@ -116,7 +113,10 @@ impl AgentViewZeroStateBlock {
me.should_hide = true;
ctx.unsubscribe_to_model(&model_events_clone);
ctx.unsubscribe_to_model(&history_model);
ctx.unsubscribe_to_model(&cloud_agent_view_model_clone);
if let Some(cloud_agent_view_model) = cloud_agent_view_model_clone.as_ref()
{
ctx.unsubscribe_to_model(cloud_agent_view_model);
}
ctx.notify();
return;
}
@@ -140,7 +140,7 @@ impl AgentViewZeroStateBlock {
ctx.notify();
});
let cloud_agent_view_model_clone = cloud_agent_view_model.clone();
let cloud_agent_view_model_clone = cloud_agent_view_model.cloned();
ctx.subscribe_to_model(
model_events_dispatcher,
move |me, model_events_dispatcher, event, ctx| {
@@ -152,7 +152,11 @@ impl AgentViewZeroStateBlock {
me.should_hide = true;
ctx.unsubscribe_to_model(&model_events_dispatcher);
ctx.unsubscribe_to_model(&BlocklistAIHistoryModel::handle(ctx));
ctx.unsubscribe_to_model(&cloud_agent_view_model_clone);
if let Some(cloud_agent_view_model) =
cloud_agent_view_model_clone.as_ref()
{
ctx.unsubscribe_to_model(cloud_agent_view_model);
}
ctx.notify();
}
}
@@ -166,34 +170,46 @@ impl AgentViewZeroStateBlock {
},
);
let model_events_clone = model_events_dispatcher.clone();
ctx.subscribe_to_model(cloud_agent_view_model, move |me, model, event, ctx| {
if FeatureFlag::CloudModeSetupV2.is_enabled() {
match event {
AmbientAgentViewModelEvent::DispatchedAgent
| AmbientAgentViewModelEvent::Cancelled
if !me.should_hide =>
{
me.should_hide = true;
ctx.unsubscribe_to_model(&model);
ctx.unsubscribe_to_model(&model_events_clone);
ctx.unsubscribe_to_model(&BlocklistAIHistoryModel::handle(ctx));
ctx.notify();
}
_ => (),
if let Some(cloud_agent_view_model) = cloud_agent_view_model {
let model_events_clone = model_events_dispatcher.clone();
ctx.subscribe_to_model(cloud_agent_view_model, move |me, model, event, ctx| {
if me.should_hide {
return;
}
} else if model.as_ref(ctx).should_show_status_footer() {
me.should_hide = true;
ctx.unsubscribe_to_model(&model);
ctx.unsubscribe_to_model(&model_events_clone);
ctx.unsubscribe_to_model(&BlocklistAIHistoryModel::handle(ctx));
ctx.notify();
}
});
let cloud_agent_view = cloud_agent_view_model.as_ref(ctx);
// Hide the zero state when this pane becomes a local-to-cloud handoff
// pane (REMOTE-1486). The fresh cloud-mode banner is suppressed because
// the pane is actually pre-loaded with a forked source conversation, not
// a brand-new one.
if matches!(event, AmbientAgentViewModelEvent::PendingHandoffChanged)
&& model.as_ref(ctx).is_local_to_cloud_handoff()
{
me.should_hide = true;
} else if FeatureFlag::CloudModeSetupV2.is_enabled() {
if matches!(
event,
AmbientAgentViewModelEvent::DispatchedAgent
| AmbientAgentViewModelEvent::Cancelled
) {
me.should_hide = true;
}
} else if model.as_ref(ctx).should_show_status_footer() {
me.should_hide = true;
}
if me.should_hide {
ctx.unsubscribe_to_model(&model);
ctx.unsubscribe_to_model(&model_events_clone);
ctx.unsubscribe_to_model(&BlocklistAIHistoryModel::handle(ctx));
ctx.notify();
}
});
}
let has_parent_terminal =
!cloud_agent_view.is_ambient_agent() || cloud_agent_view.has_parent_terminal();
cloud_agent_view_model.is_none_or(|model| !model.as_ref(ctx).is_ambient_agent());
let is_local_to_cloud_handoff = cloud_agent_view_model
.is_some_and(|model| model.as_ref(ctx).is_local_to_cloud_handoff());
let changelog_model = ChangelogModel::handle(ctx);
ctx.subscribe_to_model(&changelog_model, |me, changelog_model, event, ctx| {
if let changelog_model::Event::ChangelogRequestComplete { .. } = event {
@@ -230,6 +246,10 @@ impl AgentViewZeroStateBlock {
Self::recent_conversations_for_working_directory(current_working_directory, ctx)
})
.unwrap_or_default();
let should_hide = matches!(origin, AgentViewEntryOrigin::AcceptedPassiveCodeDiff)
|| is_local_to_cloud_handoff;
let is_oz_updates_expanded = !origin.is_cloud_agent()
&& *AISettings::handle(ctx).as_ref(ctx).should_expand_oz_updates;
Self {
conversation_id,
@@ -239,11 +259,11 @@ impl AgentViewZeroStateBlock {
terminal_model,
current_working_directory,
cached_recent_conversations,
should_hide: matches!(origin, AgentViewEntryOrigin::AcceptedPassiveCodeDiff),
should_hide,
should_show_init_callout,
has_parent_terminal,
state_handles,
is_oz_updates_expanded: false,
is_oz_updates_expanded,
}
}
@@ -402,7 +422,7 @@ impl View for AgentViewZeroStateBlock {
let active_session = self.active_session(app);
let body = render_body(
ZeroStateBodyProps {
origin: self.origin,
origin: self.origin.clone(),
has_parent_terminal: self.has_parent_terminal,
should_show_init_callout: self.should_show_init_callout,
recent_conversations: &self.cached_recent_conversations,
@@ -681,7 +701,11 @@ fn render_body(props: ZeroStateBodyProps<'_>, app: &AppContext) -> Vec<Box<dyn E
MessageItem::text("start a new agent conversation"),
],
|ctx| {
ctx.dispatch_typed_action(TerminalAction::StartNewAgentConversation);
ctx.dispatch_typed_action(TerminalAction::StartNewAgentConversation {
origin: AgentViewEntryOrigin::Input {
was_prompt_autodetected: false,
},
});
},
state_handles.start_new_conversation.clone(),
)]),
@@ -1145,14 +1169,19 @@ fn render_oz_updates(props: OzUpdatesProps<'_>, app: &AppContext) -> Option<Box<
}
/// Renders the ambient credits banner showing free cloud credits.
/// If `link_mouse_state` is provided, a "Launch cloud agent" link is shown.
pub fn render_ambient_credits_banner(credits: i32, app: &AppContext) -> Box<dyn Element> {
pub fn render_ambient_credits_banner<A>(
credits: i32,
close_button_mouse_state: MouseStateHandle,
dismiss_action: A,
app: &AppContext,
) -> Box<dyn Element>
where
A: Action + Clone + 'static,
{
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let font_family = appearance.ui_font_family();
let font_size = styles::CREDITS_BANNER_FONT_SIZE;
// Use ANSI terminal colors for the pill styling.
let text_color = theme.terminal_colors().normal.blue;
let credits_text = format!("{credits} free cloud agent credits");
@@ -1161,8 +1190,26 @@ pub fn render_ambient_credits_banner(credits: i32, app: &AppContext) -> Box<dyn
.with_style(Properties::default().weight(Weight::Semibold))
.soft_wrap(false)
.finish();
let close_button = appearance
.ui_builder()
.close_button(12., close_button_mouse_state)
.with_style(UiComponentStyles {
font_color: Some(text_color.into()),
..Default::default()
})
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(dismiss_action.clone());
})
.finish();
Container::new(text)
let content = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(text)
.with_child(Container::new(close_button).with_margin_left(4.).finish())
.finish();
Container::new(content)
.with_border(Border::all(1.).with_border_color(text_color.into()))
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.with_vertical_padding(2.)
@@ -1,14 +1,16 @@
use std::path::PathBuf;
use std::sync::Arc;
use warpui::r#async::executor::Background;
use super::{display_working_directory, format_session_location, should_render_oz_updates_section};
use crate::ai::blocklist::agent_view::zero_state_block::current_working_directory_for_zero_state;
use crate::terminal::model::ansi::{Handler, InitShellValue, PrecmdValue, SSHValue};
use crate::terminal::color::{self, Colors};
use crate::terminal::event_listener::ChannelEventListener;
use crate::terminal::model::ansi::{Handler, InitShellValue, PromptMetadata, SSHValue};
use crate::terminal::model::session::Session;
use crate::terminal::model::test_utils::block_size;
use crate::terminal::model::{session::Session, TerminalModel};
use crate::terminal::{
color::{self, Colors},
event_listener::ChannelEventListener,
};
use galaxyui::r#async::executor::Background;
use std::{path::PathBuf, sync::Arc};
use crate::terminal::model::TerminalModel;
fn terminal_with_startup_path(startup_path: Option<&str>) -> TerminalModel {
TerminalModel::new_for_test(
@@ -76,9 +78,9 @@ fn display_working_directory_abbreviates_subdirectory_under_home() {
#[test]
fn cwd_for_recent_conversations_prefers_active_block_pwd() {
let mut terminal = prebootstrap_terminal_with_startup_path("/startup/path");
terminal.precmd(PrecmdValue {
terminal.prompt_only_precmd(PromptMetadata {
pwd: Some("/active/path".to_owned()),
session_id: Some(0),
session_id: Some(123),
..Default::default()
});
@@ -96,7 +98,12 @@ fn cwd_for_recent_conversations_uses_startup_path_before_bootstrap_for_local_ses
#[test]
fn cwd_for_recent_conversations_does_not_use_startup_path_for_pending_ssh_bootstrap() {
let mut terminal = prebootstrap_terminal_with_startup_path("/startup/path");
terminal.ssh(SSHValue::default());
terminal.ssh(SSHValue {
session_id: Some(123),
remote_session_id: Some(456),
..Default::default()
});
let cwd = current_working_directory_for_zero_state(&terminal);
assert_eq!(cwd, None);
}
File diff suppressed because it is too large Load Diff
+170 -143
View File
@@ -1,48 +1,58 @@
use galaxy_core::features::FeatureFlag;
use galaxy_core::report_error;
use galaxy_core::ui::theme::color::internal_colors;
use galaxyui::elements::new_scrollable::SingleAxisConfig;
use galaxyui::elements::{
ClippedScrollStateHandle, ConstrainedBox, Empty, Fill, FormattedTextElement, Highlight,
HighlightedHyperlink, Hoverable, MainAxisAlignment, MainAxisSize, NewScrollable, SavePosition,
SelectableArea, SizeConstraintCondition, SizeConstraintSwitch,
};
use galaxyui::fonts::Weight;
use galaxyui::platform::{Cursor, OperatingSystem};
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use parking_lot::{FairMutex, RwLock};
use pathfinder_color::ColorU;
use settings::Setting as _;
use std::cmp::Ordering;
use std::path::Path;
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
use std::{cmp::Ordering, rc::Rc};
use lazy_static::lazy_static;
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use parking_lot::{FairMutex, RwLock};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use settings::Setting as _;
use galaxy_core::features::FeatureFlag;
use galaxy_core::report_error;
use galaxy_core::semantic_selection::SemanticSelection;
use galaxy_core::ui::appearance::Appearance;
use galaxy_editor::{
content::buffer::InitialBufferState, render::element::VerticalExpansionBehavior,
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_editor::content::buffer::InitialBufferState;
use galaxy_editor::render::element::VerticalExpansionBehavior;
use galaxyui::clipboard::ClipboardContent;
use galaxyui::elements::new_scrollable::SingleAxisConfig;
use galaxyui::elements::{
resizable_state_handle, Border, ChildAnchor, ChildView, ClippedScrollStateHandle,
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DragBarSide, DropShadow, Empty,
Expanded, Fill, Flex, FormattedTextElement, Highlight, HighlightedHyperlink, Hoverable,
MainAxisAlignment, MainAxisSize, MouseStateHandle, NewScrollable, OffsetPositioning,
ParentElement, PositionedElementAnchor, PositionedElementOffsetBounds, Radius, Resizable,
ResizableStateHandle, SavePosition, SelectableArea, SelectionHandle, Shrinkable,
SizeConstraintCondition, SizeConstraintSwitch, Stack, Text,
};
use galaxyui::r#async::Timer;
use galaxyui::fonts::{Properties, Style, Weight};
use galaxyui::keymap::{EditableBinding, Keystroke};
use galaxyui::platform::{Cursor, OperatingSystem};
use galaxyui::r#async::{SpawnedFutureHandle, Timer};
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{
clipboard::ClipboardContent,
elements::{
Border, ChildAnchor, ChildView, Container, CornerRadius, CrossAxisAlignment, DropShadow,
Expanded, Flex, MouseStateHandle, OffsetPositioning, ParentElement,
PositionedElementAnchor, PositionedElementOffsetBounds, Radius, SelectionHandle,
Shrinkable, Stack, Text,
},
fonts::{Properties, Style},
keymap::{EditableBinding, Keystroke},
r#async::SpawnedFutureHandle,
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle,
};
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use crate::ai::agent::{AIAgentPtyWriteMode, CancellationReason};
use super::cli_controller::{CLISubagentController, CLISubagentEvent, UserTakeOverReason};
use super::model::{AIBlockModel, AIBlockModelHelper, AIBlockModelImpl, AIBlockOutputStatus};
use super::view_impl::common::{
render_debug_footer, render_failed_output, render_informational_footer, render_text_sections,
DebugFooterProps, FailedOutputProps, TextSectionsProps,
};
use super::view_impl::output::are_all_text_sections_empty;
use super::{EmbeddedCodeEditorView, SecretRedactionState, TableSectionHandles};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::icons::yellow_stop_icon;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentActionType, AIAgentInput, AIAgentOutput, AIAgentOutputMessageType, AIAgentPtyWriteMode,
AIAgentText, AIAgentTextSection, CancellationReason, ProgrammingLanguage, WebSearchStatus,
};
use crate::ai::blocklist::block::view_impl::common::{
render_query_text, UserQueryProps, BLOCKED_ACTION_MESSAGE_FOR_GREP_OR_FILE_GLOB,
BLOCKED_ACTION_MESSAGE_FOR_READING_FILES, BLOCKED_ACTION_MESSAGE_FOR_SEARCHING_CODEBASE,
@@ -51,72 +61,47 @@ use crate::ai::blocklist::block::view_impl::common::{
LOAD_OUTPUT_MESSAGE_FOR_READING_FILES, LOAD_OUTPUT_MESSAGE_FOR_SEARCH_CODEBASE,
LOAD_OUTPUT_MESSAGE_FOR_WEB_SEARCH,
};
use crate::ai::blocklist::block::TextLocation;
use crate::ai::blocklist::code_block::CodeSnippetButtonHandles;
use crate::ai::blocklist::inline_action::inline_action_icons::icon_size;
use crate::ai::blocklist::permissions::is_agent_mode_autonomy_allowed;
use crate::ai::blocklist::{
BlocklistAIActionModel, BlocklistAIHistoryEvent, BlocklistAIPermissions,
};
use crate::ai::control_code_parser::{parse_control_codes_from_bytes, ParsedControlCodeOutput};
use crate::code::editor::view::{CodeEditorEvent, CodeEditorRenderOptions};
use crate::menu::MenuItemFields;
use crate::send_telemetry_from_ctx;
use crate::ai::execution_profiles::profiles::{
AIExecutionProfilesModel, AIExecutionProfilesModelEvent,
};
use crate::code::editor::view::{CodeEditorEvent, CodeEditorRenderOptions, CodeEditorView};
use crate::code::editor_management::CodeSource;
use crate::editor::InteractionState;
use crate::menu::{Event as MenuEvent, Menu, MenuItemFields, MenuVariant};
use crate::server::telemetry::TelemetryEvent;
use crate::settings::AISettings;
use crate::settings_view::SettingsSection;
use crate::terminal::input::SET_INPUT_MODE_TERMINAL_ACTION_NAME;
use crate::terminal::model::block::BlockId;
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
use crate::terminal::{ShellLaunchData, TerminalModel};
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::util::link_detection::{detect_links, DetectedLinksState};
use crate::view_components::action_button::{
ButtonSize, KeystrokeSource, NakedTheme, PrimaryTheme,
};
use crate::view_components::compactible_action_button::{
render_compact_and_regular_button_rows, CompactibleActionButton, RenderCompactibleActionButton,
};
use crate::view_components::compactible_split_action_button::CompactibleSplitActionButton;
use crate::view_components::DismissibleToast;
use crate::workspace::WorkspaceAction;
use crate::ToastStack;
use crate::{
ai::{
agent::{
conversation::AIConversationId, task::TaskId, AIAgentActionType, AIAgentOutput,
AIAgentOutputMessageType, AIAgentText, AIAgentTextSection, ProgrammingLanguage,
WebSearchStatus,
},
blocklist::{
code_block::CodeSnippetButtonHandles, BlocklistAIActionModel, BlocklistAIHistoryEvent,
BlocklistAIPermissions,
},
execution_profiles::profiles::{AIExecutionProfilesModel, AIExecutionProfilesModelEvent},
},
code::{editor::view::CodeEditorView, editor_management::CodeSource},
editor::InteractionState,
menu::{Event as MenuEvent, Menu, MenuVariant},
settings_view::SettingsSection,
terminal::safe_mode_settings::get_secret_obfuscation_mode,
ui_components::{blended_colors, icons::Icon},
view_components::{
action_button::{ButtonSize, KeystrokeSource, NakedTheme, PrimaryTheme},
compactible_action_button::{
render_compact_and_regular_button_rows, CompactibleActionButton,
RenderCompactibleActionButton,
},
compactible_split_action_button::CompactibleSplitActionButton,
},
BlocklistAIHistoryModel,
};
use crate::ai::agent::AIAgentInput;
use crate::ai::blocklist::block::TextLocation;
use crate::util::link_detection::{detect_links, DetectedLinksState};
use crate::ai::agent::icons::yellow_stop_icon;
use crate::ai::blocklist::inline_action::inline_action_icons::icon_size;
use super::cli_controller::{CLISubagentController, CLISubagentEvent, UserTakeOverReason};
use super::model::AIBlockModelHelper;
use super::TableSectionHandles;
use super::{
model::{AIBlockModel, AIBlockModelImpl, AIBlockOutputStatus},
view_impl::{
common::{
render_debug_footer, render_failed_output, render_informational_footer,
render_text_sections, DebugFooterProps, FailedOutputProps, TextSectionsProps,
},
output::are_all_text_sections_empty,
},
EmbeddedCodeEditorView, SecretRedactionState,
};
use crate::{send_telemetry_from_ctx, BlocklistAIHistoryModel, ToastStack};
const MENU_WIDTH: f32 = 200.0;
const MAX_HEIGHT: f32 = 320.0;
const MIN_RESIZABLE_WIDTH: f32 = 360.0;
const MIN_RESIZABLE_HEIGHT: f32 = 40.0;
const MIN_REMAINING_WINDOW_WIDTH: f32 = 200.0;
const MIN_REMAINING_WINDOW_HEIGHT: f32 = 100.0;
const AVATAR_RIGHT_MARGIN: f32 = 8.;
const CONTENT_PADDING: f32 = 12.;
const ALLOW_ACTION_POSITION_ID: &str = "allow-action-position-id";
@@ -145,7 +130,8 @@ const HAS_PENDING_NON_TRANSFER_CONTROL_ACTION_CONTEXT_KEY: &str =
const BLOCKED_ACTION_MESSAGE_FOR_TRANSFER_CONTROL: &str = "Agent is asking you to take control.";
pub fn init(app: &mut AppContext) {
use galaxyui::keymap::{macros::*, FixedBinding};
use galaxyui::keymap::macros::*;
use galaxyui::keymap::FixedBinding;
app.register_fixed_bindings([
FixedBinding::new(
@@ -234,6 +220,8 @@ pub struct CLISubagentView {
is_input_dismissed: bool,
input_dismiss_timer_handle: Option<SpawnedFutureHandle>,
resizable_width: ResizableStateHandle,
resizable_height: ResizableStateHandle,
current_working_directory: Option<String>,
shell_launch_data: Option<ShellLaunchData>,
@@ -488,6 +476,8 @@ impl CLISubagentView {
always_allow_read_files_checked,
is_input_dismissed: false,
input_dismiss_timer_handle: None,
resizable_width: resizable_state_handle(MIN_RESIZABLE_WIDTH),
resizable_height: resizable_state_handle(MAX_HEIGHT),
current_working_directory,
shell_launch_data,
selected_text: Arc::new(RwLock::new(None)),
@@ -734,9 +724,8 @@ impl CLISubagentView {
.and_then(|language| language.to_extension())
{
// Since this is a code snippet, construct a fake path name for looking up the language.
let fake_path_string = format!("snippet.{extension}");
let fake_path = std::path::Path::new(&fake_path_string);
view.set_language_with_path(fake_path, ctx);
let fake_path = format!("/snippet.{extension}");
view.set_language_with_local_path(Path::new(&fake_path), ctx);
}
}
let starting_line_number = source.as_ref().and_then(|s| {
@@ -977,6 +966,11 @@ impl View for CLISubagentView {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let semantic_selection = SemanticSelection::handle(app).as_ref(app);
let resizable_height = self
.resizable_height
.lock()
.map(|g| g.size())
.unwrap_or(MAX_HEIGHT);
let mut result = Flex::column()
.with_main_axis_size(MainAxisSize::Min)
@@ -1035,6 +1029,7 @@ impl View for CLISubagentView {
child: selectable_text.finish(),
background_color: internal_colors::accent_bg(theme).into(),
border: Some(Border::all(1.).with_border_fill(theme.accent())),
max_height: resizable_height,
},
app,
)
@@ -1148,6 +1143,7 @@ impl View for CLISubagentView {
border: Some(Border::all(1.).with_border_fill(
internal_colors::neutral_3(theme),
)),
max_height: resizable_height,
},
app,
)
@@ -1175,6 +1171,7 @@ impl View for CLISubagentView {
internal_colors::neutral_3(theme),
),
),
max_height: resizable_height,
},
app,
)
@@ -1190,57 +1187,64 @@ impl View for CLISubagentView {
let mut output_border = Border::all(1.).with_border_fill(internal_colors::neutral_3(theme));
if let AIBlockOutputStatus::Failed { error, .. } = &status {
output_border = Border::all(1.).with_border_color(theme.ui_error_color());
output_items.add_child(render_failed_output(
FailedOutputProps {
error,
is_ai_input_enabled: false,
invalid_api_key_button_handle: &self
.state_handles
.invalid_api_key_button_handle,
aws_bedrock_credentials_error_view: None,
icon_right_margin: AVATAR_RIGHT_MARGIN,
},
app,
));
// While an automatic resume is still in flight, keep the failed exchange
// quiet: don't switch to the error border, and skip the banner, the "won't
// count towards usage" notice, and the debug footer. The full failure UI is
// surfaced only once recovery has actually failed. Dogfood builds (Local/Dev)
// opt out so developers still see every transport failure aggressively.
if !error.should_suppress_during_recovery() {
output_border = Border::all(1.).with_border_color(theme.ui_error_color());
output_items.add_child(render_failed_output(
FailedOutputProps {
error,
is_ai_input_enabled: false,
invalid_api_key_button_handle: &self
.state_handles
.invalid_api_key_button_handle,
aws_bedrock_credentials_error_view: None,
icon_right_margin: AVATAR_RIGHT_MARGIN,
},
app,
));
if !self.model.is_restored() && !error.is_invalid_api_key() {
output_items.add_child(
Container::new(render_informational_footer(
app,
"This response won't count towards your usage. \"Take over\" to continue."
.to_string(),
))
.with_margin_top(8.)
.with_margin_left(icon_size(app) + AVATAR_RIGHT_MARGIN)
.finish(),
);
if !self.model.is_restored() && !error.is_invalid_api_key() {
output_items.add_child(
Container::new(render_informational_footer(
app,
"This response won't count towards your usage. \"Take over\" to continue."
.to_string(),
))
.with_margin_top(8.)
.with_margin_left(icon_size(app) + AVATAR_RIGHT_MARGIN)
.finish(),
);
output_items.add_child(
Container::new(render_debug_footer(
DebugFooterProps {
conversation: self.model.conversation(app),
model: self.model.as_ref(),
debug_copy_button_handle: self
.state_handles
.debug_copy_button_handle
.clone(),
submit_issue_button_handle: self
.state_handles
.submit_issue_button_handle
.clone(),
should_render_feedback_below: true,
},
|debug_id, ctx| {
ctx.dispatch_typed_action(CLISubagentAction::CopyDebugId(debug_id))
},
|ctx| ctx.dispatch_typed_action(CLISubagentAction::OpenFeedbackDocs),
app,
))
.with_margin_top(8.)
.with_margin_left(icon_size(app) + AVATAR_RIGHT_MARGIN)
.finish(),
);
output_items.add_child(
Container::new(render_debug_footer(
DebugFooterProps {
conversation: self.model.conversation(app),
model: self.model.as_ref(),
debug_copy_button_handle: self
.state_handles
.debug_copy_button_handle
.clone(),
submit_issue_button_handle: self
.state_handles
.submit_issue_button_handle
.clone(),
should_render_feedback_below: true,
},
|debug_id, ctx| {
ctx.dispatch_typed_action(CLISubagentAction::CopyDebugId(debug_id))
},
|ctx| ctx.dispatch_typed_action(CLISubagentAction::OpenFeedbackDocs),
app,
))
.with_margin_top(8.)
.with_margin_left(icon_size(app) + AVATAR_RIGHT_MARGIN)
.finish(),
);
}
}
}
@@ -1277,6 +1281,7 @@ impl View for CLISubagentView {
child: output.finish(),
background_color: internal_colors::neutral_2(appearance.theme()),
border: Some(output_border),
max_height: resizable_height,
},
app,
)
@@ -1296,6 +1301,7 @@ impl View for CLISubagentView {
input: input.clone(),
mode,
scroll_state: self.state_handles.input_scroll_state.clone(),
max_height: resizable_height,
},
app,
)),
@@ -1396,7 +1402,24 @@ impl View for CLISubagentView {
);
}
result.finish()
let content = result.finish();
let width_resizable = Resizable::new(self.resizable_width.clone(), content)
.with_dragbar_side(DragBarSide::Left)
.on_resize(|ctx, _| ctx.notify())
.with_bounds_callback(Box::new(|window_size| {
let max = (window_size.x() - MIN_REMAINING_WINDOW_WIDTH).max(MIN_RESIZABLE_WIDTH);
(MIN_RESIZABLE_WIDTH, max)
}))
.finish();
Resizable::new(self.resizable_height.clone(), width_resizable)
.with_dragbar_side(DragBarSide::Top)
.on_resize(|ctx, _| ctx.notify())
.with_bounds_callback(Box::new(|window_size| {
let max = (window_size.y() - MIN_REMAINING_WINDOW_HEIGHT).max(MIN_RESIZABLE_HEIGHT);
(MIN_RESIZABLE_HEIGHT, max)
}))
.finish()
}
fn keymap_context(&self, app: &AppContext) -> galaxyui::keymap::Context {
@@ -1708,6 +1731,7 @@ struct ScrollableContainerProps {
child: Box<dyn Element>,
background_color: ColorU,
border: Option<Border>,
max_height: f32,
}
fn render_scrollable_container(props: ScrollableContainerProps, _app: &AppContext) -> Container {
@@ -1716,6 +1740,7 @@ fn render_scrollable_container(props: ScrollableContainerProps, _app: &AppContex
child,
background_color,
border,
max_height,
} = props;
let scrollable = NewScrollable::vertical(
@@ -1731,7 +1756,7 @@ fn render_scrollable_container(props: ScrollableContainerProps, _app: &AppContex
.finish();
let clipped = ConstrainedBox::new(scrollable)
.with_max_height(MAX_HEIGHT)
.with_max_height(max_height)
.finish();
let mut container = Container::new(clipped)
@@ -1915,6 +1940,7 @@ struct WriteToPtyInputProps {
input: bytes::Bytes,
mode: AIAgentPtyWriteMode,
scroll_state: ClippedScrollStateHandle,
max_height: f32,
}
fn render_write_to_pty_input(props: WriteToPtyInputProps, app: &AppContext) -> Box<dyn Element> {
@@ -1922,6 +1948,7 @@ fn render_write_to_pty_input(props: WriteToPtyInputProps, app: &AppContext) -> B
input,
mode,
scroll_state,
max_height,
} = props;
let appearance = Appearance::as_ref(app);
@@ -1967,7 +1994,7 @@ fn render_write_to_pty_input(props: WriteToPtyInputProps, app: &AppContext) -> B
.finish();
let clipped = ConstrainedBox::new(scrollable)
.with_max_height(MAX_HEIGHT)
.with_max_height(max_height)
.finish();
Container::new(clipped)
+47 -34
View File
@@ -1,34 +1,27 @@
use std::{collections::HashMap, sync::Arc};
use std::collections::HashMap;
use std::sync::Arc;
use crate::server::telemetry::{CLISubagentControlState, TelemetryEvent};
use galaxy_core::send_telemetry_from_ctx;
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use instant::Instant;
use parking_lot::FairMutex;
use serde::{Deserialize, Serialize};
use crate::ai::blocklist::context_model::block_context_from_terminal_model;
use crate::{
ai::{
agent::{
conversation::AIConversationId, task::TaskId, AIAgentActionId, AIAgentActionResultType,
AIAgentContext, CancellationReason, ReadShellCommandOutputResult,
RequestCommandOutputResult, TransferShellCommandControlToUserResult,
WriteToLongRunningShellCommandResult,
},
blocklist::{
agent_view::{AgentViewController, AgentViewEntryOrigin},
BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIController,
BlocklistAIHistoryEvent,
},
},
terminal::{
model::block::BlockId,
model_events::{ModelEvent, ModelEventDispatcher},
TerminalModel,
},
BlocklistAIHistoryModel,
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentActionId, AIAgentActionResultType, AIAgentContext, CancellationReason,
ReadShellCommandOutputResult, RequestCommandOutputResult,
TransferShellCommandControlToUserResult, WriteToLongRunningShellCommandResult,
};
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewEntryOrigin};
use crate::ai::blocklist::context_model::block_context_from_terminal_model;
use crate::ai::blocklist::{
BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIController, BlocklistAIHistoryEvent,
};
use crate::server::telemetry::{CLISubagentControlState, TelemetryEvent};
use crate::terminal::model::block::BlockId;
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::terminal::TerminalModel;
use crate::BlocklistAIHistoryModel;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum UserTakeOverReason {
@@ -147,7 +140,7 @@ impl CLISubagentController {
let history_model = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_model, Self::handle_history_model_event);
ctx.subscribe_to_model(action_model, |me, event, ctx| match event {
ctx.subscribe_to_model(action_model, |me, _, event, ctx| match event {
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(_) => {
let mut terminal_model = me.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut();
@@ -202,7 +195,7 @@ impl CLISubagentController {
_ => (),
});
ctx.subscribe_to_model(model_event_dispatcher, |me, event, ctx| {
ctx.subscribe_to_model(model_event_dispatcher, |me, _, event, ctx| {
if let ModelEvent::BlockCompleted(block_completed_event) = event {
let terminal_model = me.terminal_model.lock();
let Some(block) = terminal_model
@@ -242,7 +235,7 @@ impl CLISubagentController {
me.controller.update(ctx, |controller, ctx| {
controller.cancel_conversation_progress(
conversation_id,
CancellationReason::OptimisticCLISubagentCompletion,
CancellationReason::CommandFinishedDuringInlineAgentView,
ctx,
);
});
@@ -317,8 +310,14 @@ impl CLISubagentController {
let active_block = terminal_model.block_list_mut().active_block_mut();
let block_id = active_block.id().clone();
if let Err(e) = active_block.take_over_control_for_user(reason) {
log::error!("Failed to take control for user: {e:?}");
let interaction_mode_debug = format!("{:?}", active_block.interaction_mode());
let lrc_state_debug = format!("{:?}", active_block.long_running_control_state());
if let Err(e) = active_block.take_over_control_for_user(reason.clone()) {
log::error!(
"Failed to take control for user: {e:?}, reason={reason:?}, \
block_id={block_id:?}, interaction_mode={interaction_mode_debug}, \
lrc_state={lrc_state_debug}"
);
return;
}
@@ -330,13 +329,18 @@ impl CLISubagentController {
// model lock before actually cancelling the conversation.
drop(terminal_model);
// Only cancel conversation if user manually took control (not when agent transfers control).
// Cancel the in-flight stream to stop the CLI subagent monitoring loop.
// When the user manually takes over, we use CLISubagentUserTakeover so the
// conversation status stays InProgress — the agent will resume once the command
// finishes or the user hands control back. We do NOT use ManuallyCancelled here
// because that would mark the conversation (and ambient task) as cancelled,
// which is incorrect since the conversation is still proceeding.
if should_cancel_conversation {
if let Some(conversation_id) = conversation_id {
self.controller.update(ctx, |controller, ctx| {
controller.cancel_conversation_progress(
conversation_id,
CancellationReason::ManuallyCancelled,
CancellationReason::CLISubagentUserTakeover,
ctx,
);
});
@@ -365,15 +369,23 @@ impl CLISubagentController {
let active_block = terminal_model.block_list_mut().active_block_mut();
let conversation_id = active_block.ai_conversation_id();
let block_id = active_block.id().clone();
let lrc_state_debug = format!("{:?}", active_block.long_running_control_state());
// Check if control was transferred from agent before handoff.
let was_transfer_from_agent = active_block
.long_running_control_state()
.and_then(|state| state.user_take_over_reason())
.is_some_and(|reason| reason.is_transfer_from_agent());
if let Err(e) = active_block.handoff_control_to_agent() {
log::error!("Failed to handoff control to agent: {e:?}");
log::error!(
"Failed to handoff control to agent: {e:?}, \
block_id={block_id:?}, lrc_state={lrc_state_debug}"
);
return;
}
log::info!(
"handoff_active_command_control_to_agent: block_id={block_id:?}, \
conversation_id={conversation_id:?}, lrc_state={lrc_state_debug}"
);
let action_id = active_block.requested_command_action_id().cloned();
let agent_has_control = active_block.is_agent_in_control();
drop(terminal_model);
@@ -464,11 +476,12 @@ impl CLISubagentController {
fn handle_history_model_event(
&mut self,
_: ModelHandle<BlocklistAIHistoryModel>,
event: &BlocklistAIHistoryEvent,
ctx: &mut ModelContext<Self>,
) {
if event
.terminal_view_id()
.terminal_surface_id()
.is_some_and(|id| id != self.terminal_view_id)
{
return;
@@ -1,15 +1,13 @@
//! Compact free-form text input used by inline AI block actions.
use galaxyui::presenter::ChildView;
use galaxyui::{
presenter::ChildView, AppContext, Element, Entity, FocusContext, SingletonEntity, View,
ViewContext, ViewHandle,
AppContext, Element, Entity, FocusContext, SingletonEntity, View, ViewContext, ViewHandle,
};
use crate::{
appearance::Appearance,
editor::{
EditorOptions, EditorView, Event as EditorEvent, PropagateAndNoOpEscapeKey,
PropagateAndNoOpNavigationKeys, PropagateHorizontalNavigationKeys, TextOptions,
},
use crate::appearance::Appearance;
use crate::editor::{
EditorOptions, EditorView, Event as EditorEvent, PropagateAndNoOpEscapeKey,
PropagateAndNoOpNavigationKeys, PropagateHorizontalNavigationKeys, TextOptions,
};
/// Wraps an [`EditorView`] for inline prompts that need a lightweight text input.
+23 -4
View File
@@ -1,10 +1,14 @@
use std::{collections::HashMap, ops::Range};
use std::collections::HashMap;
use std::ops::Range;
use itertools::Itertools;
use regex::RegexBuilder;
use warpui::SingletonEntity;
use super::{AIBlock, TextLocation};
use crate::ai::agent::{AIAgentTextSection, MessageId};
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
use crate::terminal::find::{FindOptions, FindableRichContentView, RichContentMatchId};
use itertools::Itertools;
use regex::RegexBuilder;
/// Represents the location of a find match in an AI block.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -35,6 +39,12 @@ impl FindState {
pub(super) fn location_for_match(&self, id: RichContentMatchId) -> Option<&FindMatchLocation> {
self.matches.get(&id)
}
/// The number of find matches currently cached for this block.
#[cfg(test)]
pub(crate) fn match_count(&self) -> usize {
self.matches.len()
}
}
impl FindableRichContentView for AIBlock {
@@ -47,8 +57,13 @@ impl FindableRichContentView for AIBlock {
self.clear_matches(ctx);
let mut new_match_ids = vec![];
// Match against the displayed query (including any "/agent" prefix added for the
// initial conversation query) so highlight ranges line up with the rendered text.
let initial_conversation_query = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&self.client_ids.conversation_id)
.and_then(|conversation| conversation.initial_user_query());
for (i, input) in self.model.inputs_to_render(ctx).iter().enumerate() {
if let Some(query) = input.user_query() {
if let Some(query) = input.display_user_query(initial_conversation_query.as_ref()) {
for find_match_range in compute_find_matches(&query, options).into_iter() {
let id = RichContentMatchId::default();
new_match_ids.push(id);
@@ -130,6 +145,10 @@ impl FindableRichContentView for AIBlock {
}
}
#[cfg(test)]
#[path = "find_tests.rs"]
mod tests;
/// Computes find matches (represented as character offsets) within the given `text`.
fn compute_find_matches(text: &str, options: &FindOptions) -> Vec<Range<usize>> {
let Some(query) = options.query.as_ref() else {
+54
View File
@@ -0,0 +1,54 @@
use std::collections::HashMap;
use std::sync::Arc;
use super::compute_find_matches;
use crate::ai::agent::{AIAgentInput, UserQueryMode};
use crate::terminal::find::FindOptions;
fn user_query_input(query: &str, mode: UserQueryMode) -> AIAgentInput {
AIAgentInput::UserQuery {
query: query.to_string(),
context: Arc::new([]),
static_query_type: None,
referenced_attachments: HashMap::new(),
user_query_mode: mode,
running_command: None,
intended_agent: None,
}
}
// Regression test for https://github.com/warpdotdev/warp/issues/11697.
// Find match highlights on the initial agent query must be computed against the displayed
// query (prefixed with "/agent "), not the stored query, so the ranges line up with the
// rendered text.
#[test]
fn find_matches_align_with_agent_prefixed_initial_query() {
let query = "list files".to_string();
let input = user_query_input(&query, UserQueryMode::Normal);
let displayed = input
.display_user_query(Some(&query))
.expect("user query should produce displayed text");
assert_eq!(displayed, "/agent list files");
let options = FindOptions::default().with_query(Some("list".to_string()));
// "list" starts after the "/agent " prefix (7 chars), so the match must be 7..11 — not
// 0..4, which is what matching against the un-prefixed stored query would produce.
assert_eq!(compute_find_matches(&displayed, &options), vec![7..11]);
}
// A "/plan" query is stored with its prefix already attached, so its highlights have always
// lined up. Lock that in alongside the agent-prefix fix.
#[test]
fn find_matches_align_with_plan_prefixed_query() {
let input = user_query_input("write tests", UserQueryMode::Plan);
let displayed = input
.display_user_query(None)
.expect("user query should produce displayed text");
assert_eq!(displayed, "/plan write tests");
let options = FindOptions::default().with_query(Some("tests".to_string()));
assert_eq!(compute_find_matches(&displayed, &options), vec![12..17]);
}
@@ -1,21 +1,16 @@
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::color::internal_colors;
use galaxyui::{
elements::{
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex,
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
},
keymap::FixedBinding,
ui_components::{
button::{Button, ButtonVariant},
components::{UiComponent, UiComponentStyles},
},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
use galaxyui::elements::{
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex, MainAxisAlignment,
MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
};
use crate::ui_components::icons::Icon;
use warpui::keymap::FixedBinding;
use warpui::ui_components::button::{Button, ButtonVariant};
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
use warpui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
use super::numbered_button::render_recommended_badge;
use crate::ui_components::icons::Icon;
const MARGIN_BETWEEN_BUTTONS: f32 = 4.;
const HAS_OPTIONS: &str = "HasOptions";
+33 -27
View File
@@ -1,22 +1,20 @@
mod helper;
mod model_impl;
use galaxy_core::features::FeatureFlag;
use chrono::TimeDelta;
pub use helper::AIBlockModelHelper;
pub use model_impl::*;
use session_sharing_protocol::common::ParticipantId;
use crate::ai::{
agent::{
conversation::AIConversationId, AIAgentExchangeId, AIAgentInput, AIAgentOutput,
CancellationReason, PassiveSuggestionTrigger, PassiveSuggestionTriggerType,
RenderableAIError, ServerOutputId, Shared,
},
llms::LLMId,
};
use chrono::TimeDelta;
use galaxy_core::features::FeatureFlag;
use galaxyui::{AppContext, ViewContext};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{
AIAgentExchangeId, AIAgentInput, AIAgentOutput, CancellationReason, PassiveSuggestionTrigger,
PassiveSuggestionTriggerType, RenderableAIError, ServerOutputId, Shared,
};
use crate::ai::llms::LLMId;
#[derive(Debug, Clone, Copy)]
pub enum PassiveRequestType {
UnitTestSuggestion,
@@ -220,24 +218,19 @@ pub trait AIBlockModel {
#[cfg(any(test, feature = "integration_tests"))]
pub mod testing {
use galaxyui::{AppContext, ViewContext};
use crate::ai::{
agent::{
conversation::AIConversationId, AIAgentInput, AIAgentOutput, ServerOutputId, Shared,
},
blocklist::{
model::{AIRequestType, PassiveRequestType, PassiveSuggestionTriggerType},
AIBlock,
},
llms::LLMId,
};
use super::{AIBlockModel, AIBlockOutputStatus, OutputStatusUpdateCallback};
use crate::ai::agent::{AIAgentInput, AIAgentOutput, ServerOutputId, Shared};
use crate::ai::blocklist::model::{
AIRequestType, PassiveRequestType, PassiveSuggestionTriggerType,
};
use crate::ai::blocklist::AIBlock;
pub struct FakeAIBlockModel {
input: Vec<AIAgentInput>,
output: Shared<AIAgentOutput>,
/// `None` models a block that is still streaming output, so its status
/// stays [`AIBlockOutputStatus::Pending`].
output: Option<Shared<AIAgentOutput>>,
model_id: LLMId,
}
@@ -245,7 +238,17 @@ pub mod testing {
pub fn new(input: Vec<AIAgentInput>, output: AIAgentOutput) -> Self {
Self {
input,
output: Shared::new(output),
output: Some(Shared::new(output)),
model_id: "fake-llm".to_owned().into(),
}
}
/// Builds a fake model whose status stays [`AIBlockOutputStatus::Pending`],
/// modeling a block that is still streaming output.
pub fn new_streaming(input: Vec<AIAgentInput>) -> Self {
Self {
input,
output: None,
model_id: "fake-llm".to_owned().into(),
}
}
@@ -255,8 +258,11 @@ pub mod testing {
type View = AIBlock;
fn status(&self, _app: &AppContext) -> AIBlockOutputStatus {
AIBlockOutputStatus::Complete {
output: self.output.clone(),
match &self.output {
Some(output) => AIBlockOutputStatus::Complete {
output: output.clone(),
},
None => AIBlockOutputStatus::Pending,
}
}
@@ -27,6 +27,7 @@ impl AIBlockModel for DebugAIBlockModel {
error_message: "No output received.".to_owned(),
will_attempt_resume: false,
waiting_for_network: false,
is_user_error: false,
},
},
}
+10 -14
View File
@@ -1,17 +1,13 @@
use galaxyui::{AppContext, EntityId, ModelHandle, SingletonEntity};
use crate::{
ai::{
agent::{
conversation::AIConversation, AIAgentAction, AIAgentActionId, AIAgentActionType,
AIAgentInput, AIAgentOutputMessageType, SummarizationType,
},
blocklist::BlocklistAIActionModel,
},
BlocklistAIHistoryModel,
};
use super::AIBlockModel;
use crate::ai::agent::conversation::AIConversation;
use crate::ai::agent::{
AIAgentAction, AIAgentActionId, AIAgentActionType, AIAgentInput, AIAgentOutputMessageType,
SummarizationType,
};
use crate::ai::blocklist::BlocklistAIActionModel;
use crate::BlocklistAIHistoryModel;
// Helper methods for accessing data on an impl of `AIBlockModel`.
//
@@ -27,7 +23,7 @@ pub trait AIBlockModelHelper {
fn contains_update_document_action(&self, app: &AppContext) -> bool;
fn is_latest_non_passive_exchange_in_root_task(&self, app: &AppContext) -> bool;
fn is_latest_visible_exchange_in_root_task(&self, app: &AppContext) -> bool;
fn is_latest_exchange_in_terminal_pane(
&self,
@@ -100,10 +96,10 @@ impl<T: ?Sized + AIBlockModel> AIBlockModelHelper for T {
}
}
fn is_latest_non_passive_exchange_in_root_task(&self, app: &AppContext) -> bool {
fn is_latest_visible_exchange_in_root_task(&self, app: &AppContext) -> bool {
self.conversation(app).is_some_and(|conversation| {
match (
conversation.last_non_passive_exchange(),
conversation.latest_visible_exchange(),
self.exchange_id(app),
) {
(Some(latest_exchange), Some(id)) => latest_exchange.id == id,
+37 -27
View File
@@ -5,20 +5,17 @@ use chrono::{Local, TimeDelta};
use galaxyui::{AppContext, SingletonEntity, View, ViewContext};
use history_model::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
use session_sharing_protocol::common::ParticipantId;
use crate::ai::{
agent::{
conversation::AIConversationId, AIAgentExchange, AIAgentExchangeId, AIAgentInput,
AIAgentOutputStatus, FinishedAIAgentOutput, ServerOutputId, Shared,
},
blocklist::{
history_model,
model::{AIRequestType, PassiveRequestType},
},
llms::LLMId,
};
use galaxyui::{AppContext, Entity, SingletonEntity, ViewContext};
use super::{AIBlockModel, AIBlockOutputStatus, OutputStatusUpdateCallback};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{
AIAgentExchange, AIAgentExchangeId, AIAgentInput, AIAgentOutputStatus, FinishedAIAgentOutput,
ServerOutputId, Shared,
};
use crate::ai::blocklist::history_model;
use crate::ai::blocklist::model::{AIRequestType, PassiveRequestType};
use crate::ai::llms::LLMId;
/// Standard [`AIBlock`] impl for live outputs corresponding to an `OutputStream`.
pub struct AIBlockModelImpl<V> {
@@ -31,7 +28,7 @@ pub struct AIBlockModelImpl<V> {
impl<V> AIBlockModelImpl<V>
where
V: View,
V: Entity,
{
pub fn new(
exchange_id: AIAgentExchangeId,
@@ -85,7 +82,7 @@ where
impl<V> AIBlockModel for AIBlockModelImpl<V>
where
V: View,
V: Entity,
{
type View = V;
@@ -208,19 +205,32 @@ where
exchange_id
);
ctx.subscribe_to_model(&history_model, move |me, _, event, ctx| {
let BlocklistAIHistoryEvent::UpdatedStreamingExchange {
exchange_id: event_exchange_id,
conversation_id: event_conversation_id,
..
} = event
else {
return;
};
if *event_exchange_id == exchange_id {
log::info!("[bedrock-debug] on_updated_output: callback fired for matching exchange_id={:?}", exchange_id);
callback(me, ctx);
} else if *event_conversation_id == conversation_id {
ctx.notify();
match event {
BlocklistAIHistoryEvent::UpdatedStreamingExchange {
exchange_id: event_exchange_id,
conversation_id: event_conversation_id,
..
} => {
if *event_exchange_id == exchange_id {
callback(me, ctx);
} else if *event_conversation_id == conversation_id {
ctx.notify();
}
}
BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { .. } => {
// Cross-pane orchestration credit rollup: the collapsed
// footer pill on an orchestrator's AIBlock derives its
// headline number from descendant credits, which the
// existing exchange-scoped notify path above doesn't
// cover. Trigger a re-render on any usage metadata
// change so the pill stays live. Filtering to only
// descendants of this block's conversation would
// require an O(depth) walk per event for every AI
// block in the app; an unconditional notify is cheap
// and tracks live status accurately.
ctx.notify();
}
_ => {}
}
});
}
@@ -1,11 +1,12 @@
use galaxyui::elements::{
ClippedScrollStateHandle, Container, CrossAxisAlignment, DispatchEventResult, EventHandler,
Flex, Hoverable, MouseInBehavior, MouseStateHandle, ParentElement, SavePosition, ScrollTarget,
ScrollToPositionMode,
};
use galaxyui::keymap::FixedBinding;
use galaxyui::ui_components::button::Button;
use galaxyui::ui_components::components::UiComponent;
use galaxyui::{
elements::{
ClippedScrollStateHandle, Container, CrossAxisAlignment, DispatchEventResult, EventHandler,
Flex, Hoverable, MouseInBehavior, MouseStateHandle, ParentElement, SavePosition,
ScrollTarget, ScrollToPositionMode,
},
keymap::FixedBinding,
ui_components::{button::Button, components::UiComponent},
AppContext, Element, Entity, TypedActionView, View, ViewContext, ViewHandle, WeakViewHandle,
};
@@ -130,6 +131,15 @@ impl NumberShortcutButtonBuilder {
on_click: Box::new(on_click),
}
}
/// Builds just this option's visual element, with no click or keyboard handling. The
/// ask-user-question card uses it to lay out off-screen measurement copies of every option, so
/// a multi-question card can size itself to its tallest question. Only the rendered size matters
/// there, so the button builder is invoked in its unselected state and the interactive
/// `on_click` half is dropped.
pub fn build_measurement_element(&self, app: &AppContext) -> Box<dyn Element> {
(self.button_builder)(false, app).build().finish()
}
}
pub fn numbered_shortcut_button<A: galaxyui::Action + Clone + 'static>(
@@ -1,14 +1,15 @@
use std::cell::RefCell;
use std::rc::Rc;
use pathfinder_geometry::vector::{vec2f, Vector2F};
use galaxy_core::ui::appearance::Appearance;
use galaxyui::elements::new_scrollable::SingleAxisConfig;
use galaxyui::elements::{ChildView, Clipped, ClippedScrollStateHandle, ConstrainedBox, Fill};
use galaxyui::platform::WindowStyle;
use galaxyui::{
elements::{
new_scrollable::SingleAxisConfig, ChildView, Clipped, ClippedScrollStateHandle,
ConstrainedBox, Fill,
},
platform::WindowStyle,
App, Entity, Event, Presenter, TypedActionView, View, ViewContext, ViewHandle, WindowId,
WindowInvalidation,
};
use pathfinder_geometry::vector::{vec2f, Vector2F};
use std::{cell::RefCell, rc::Rc};
use super::*;
+33 -16
View File
@@ -1,20 +1,18 @@
use crate::context_chips::spacing;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::color::internal_colors;
use galaxyui::{
elements::{
Border, Container, CornerRadius, CrossAxisAlignment, Expanded, Flex, FormattedTextElement,
MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
DEFAULT_UI_LINE_HEIGHT_RATIO,
},
ui_components::{
button::{Button, ButtonVariant},
components::{UiComponent, UiComponentStyles},
},
AppContext, Element, SingletonEntity, ViewHandle,
use galaxyui::elements::{
Border, Container, CornerRadius, CrossAxisAlignment, Expanded, Flex, FormattedTextElement,
MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
DEFAULT_UI_LINE_HEIGHT_RATIO,
};
use warpui::ui_components::button::{Button, ButtonVariant};
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
use warpui::{AppContext, Element, SingletonEntity, ViewHandle};
use super::compact_agent_input::CompactAgentInput;
use crate::context_chips::spacing;
const NUMBER_BADGE_BORDER_WIDTH: f32 = 1.;
const NUMBER_BADGE_VERTICAL_PADDING: f32 = 1.;
fn render_number_badge(
number: usize,
@@ -38,8 +36,8 @@ fn render_number_badge(
.finish(),
)
.with_horizontal_padding(5.)
.with_vertical_padding(1.)
.with_border(Border::all(1.).with_border_color(badge_border_color))
.with_vertical_padding(NUMBER_BADGE_VERTICAL_PADDING)
.with_border(Border::all(NUMBER_BADGE_BORDER_WIDTH).with_border_color(badge_border_color))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(3.)))
.with_background(badge_background)
.finish()
@@ -89,6 +87,7 @@ pub(super) fn build_numbered_button(
) -> Button {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let font_size = appearance.monospace_font_size();
let mut button = base_numbered_button(mouse_state, app);
@@ -109,12 +108,28 @@ pub(super) fn build_numbered_button(
}
let badge = render_number_badge(number, is_checked, appearance);
let badge_font_size = font_size.max(4.) - 1.;
let content_top_margin = ((badge_font_size * DEFAULT_UI_LINE_HEIGHT_RATIO)
+ (NUMBER_BADGE_VERTICAL_PADDING + NUMBER_BADGE_BORDER_WIDTH) * 2.
- (font_size * DEFAULT_UI_LINE_HEIGHT_RATIO))
.max(0.)
/ 2.;
let row = Flex::row()
// Use `Start`, not `Center`: `Center` would center the badge against the full content
// height, floating it off the first line when the label wraps. `content_top_margin`
// recenters it against a single line.
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(badge)
.with_child(
Shrinkable::new(1., Container::new(content).with_margin_left(8.).finish()).finish(),
Shrinkable::new(
1.,
Container::new(content)
.with_margin_left(8.)
.with_margin_top(content_top_margin)
.finish(),
)
.finish(),
)
.finish();
@@ -161,9 +176,11 @@ pub(super) fn build_text_button_content(
return label_element;
}
// Center the label and the (taller) "Recommended" chip against each other; the outer row keeps
// the number badge `Start`-aligned to the first line.
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Expanded::new(1., label_element).finish())
.with_child(
Container::new(render_recommended_badge(appearance))
@@ -1,26 +1,42 @@
use std::sync::Arc;
use parking_lot::RwLock;
use galaxy_core::features::FeatureFlag;
use galaxy_core::semantic_selection::SemanticSelection;
use galaxyui::elements::{
get_rich_content_position_id, ChildView, Container, CrossAxisAlignment, Expanded, Flex,
ParentElement, SavePosition, SelectableArea, SelectionHandle, Text,
};
use galaxyui::fonts::{Properties, Style, Weight};
use galaxyui::{
elements::{ChildView, Container, CrossAxisAlignment, Expanded, Flex, ParentElement, Text},
fonts::{Properties, Style, Weight},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
AppContext, Element, Entity, EntityId, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
use crate::{
ai::blocklist::block::view_impl::{
common::render_user_avatar, CONTENT_HORIZONTAL_PADDING, CONTENT_ITEM_VERTICAL_MARGIN,
},
appearance::Appearance,
ui_components::{blended_colors, icons::Icon},
view_components::action_button::{ActionButton, ButtonSize, NakedTheme},
use crate::ai::blocklist::block::view_impl::common::render_user_avatar;
use crate::ai::blocklist::block::view_impl::{
CONTENT_HORIZONTAL_PADDING, CONTENT_ITEM_VERTICAL_MARGIN,
};
use crate::appearance::Appearance;
use crate::terminal::block_list_element::BlockListMenuSource;
use crate::terminal::view::TerminalAction;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{ActionButton, ButtonSize, NakedTheme};
/// Renders a pending user query block with dimmed text and a "Queued" badge.
/// Displayed when a follow-up prompt is queued via `/fork-and-compact <prompt>`,
/// `/compact-and <prompt>`, `/queue <prompt>`, or for the initial prompt of a
/// non-oz Cloud Mode run waiting for its harness CLI to start.
/// Cloud Mode run waiting for its real shared-session transcript query to arrive.
pub struct PendingUserQueryBlock {
prompt: String,
user_display_name: String,
profile_image_path: Option<String>,
view_id: EntityId,
selection_handle: SelectionHandle,
/// In an `RwLock` so the `SelectableArea` can update it synchronously when a selection ends,
/// allowing the terminal view to read the value immediately for copy-on-select.
selected_text: Arc<RwLock<Option<String>>>,
close_button: Option<ViewHandle<ActionButton>>,
send_now_button: Option<ViewHandle<ActionButton>>,
}
@@ -58,21 +74,42 @@ impl PendingUserQueryBlock {
prompt,
user_display_name,
profile_image_path,
view_id: ctx.view_id(),
selection_handle: Default::default(),
selected_text: Default::default(),
close_button,
send_now_button,
}
}
/// Returns the currently selected prompt text within this block.
pub fn selected_text(&self, _ctx: &AppContext) -> Option<String> {
self.selected_text.read().clone()
}
pub fn prompt(&self) -> &str {
&self.prompt
}
/// Clears the text selection state and visual selection highlight.
pub fn clear_selection(&mut self, ctx: &mut ViewContext<Self>) {
self.selection_handle.clear();
*self.selected_text.write() = None;
ctx.notify();
}
}
#[derive(Clone, Debug)]
pub enum PendingUserQueryBlockAction {
Dismiss,
SendNow,
SelectText,
}
pub enum PendingUserQueryBlockEvent {
Dismissed,
SendNow,
TextSelected,
}
impl Entity for PendingUserQueryBlock {
@@ -90,6 +127,9 @@ impl TypedActionView for PendingUserQueryBlock {
PendingUserQueryBlockAction::SendNow => {
ctx.emit(PendingUserQueryBlockEvent::SendNow);
}
PendingUserQueryBlockAction::SelectText => {
ctx.emit(PendingUserQueryBlockEvent::TextSelected);
}
}
}
}
@@ -125,7 +165,7 @@ impl View for PendingUserQueryBlock {
)
.with_style(properties)
.with_color(dimmed_color)
.with_selectable(false)
.with_selectable(true)
.finish();
let queued_badge = Text::new(
@@ -141,11 +181,43 @@ impl View for PendingUserQueryBlock {
.with_selectable(false)
.finish();
let text_column = Flex::column()
let selectable_child = Flex::column()
.with_child(prompt_text)
.with_child(Container::new(queued_badge).with_margin_top(4.).finish())
.finish();
let semantic_selection = SemanticSelection::as_ref(app);
let selected_text = self.selected_text.clone();
let view_id = self.view_id;
let mut text_column = SelectableArea::new(
self.selection_handle.clone(),
move |selection_args, _, _| {
*selected_text.write() = selection_args.selection;
},
SavePosition::new(
selectable_child,
get_rich_content_position_id(&view_id).as_str(),
)
.finish(),
)
.with_word_boundaries_policy(semantic_selection.word_boundary_policy())
.with_smart_select_fn(semantic_selection.smart_select_fn())
.on_selection_updated(|ctx, _| {
ctx.dispatch_typed_action(PendingUserQueryBlockAction::SelectText)
})
.on_selection_right_click(move |ctx, position| {
ctx.dispatch_typed_action(TerminalAction::BlockListContextMenu(
BlockListMenuSource::RichContentTextRightClick {
rich_content_view_id: view_id,
position_in_rich_content: position,
},
))
});
if FeatureFlag::RectSelection.is_enabled() {
text_column = text_column.should_support_rect_select();
}
let mut buttons_column = Flex::column().with_spacing(2.);
if let Some(close_button) = &self.close_button {
buttons_column.add_child(ChildView::new(close_button).finish());
@@ -157,7 +229,7 @@ impl View for PendingUserQueryBlock {
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(avatar)
.with_child(Expanded::new(1., text_column).finish());
.with_child(Expanded::new(1., text_column.finish()).finish());
if self.close_button.is_some() || self.send_now_button.is_some() {
let buttons = Container::new(buttons_column.finish())
.with_margin_left(8.)
+20 -11
View File
@@ -1,14 +1,14 @@
use std::collections::HashMap;
use std::sync::Arc;
use galaxyui::elements::{MouseStateHandle, PartialClickableElement, SecretRange};
use galaxyui::platform::Cursor;
use itertools::Itertools;
use similar::DiffableStr;
use crate::ai::agent::{AIAgentOutput, AIAgentTextSection, AgentOutputText};
use crate::terminal::model::secrets::{SecretLevel, REGEX_LEVEL_METADATA, SECRETS_REGEX};
use super::{AIBlockAction, TextLocation};
use crate::ai::agent::{AIAgentOutput, AIAgentTextSection, AgentOutputText};
use crate::terminal::model::secrets::{SecretLevel, SecretsRegex, SECRETS_REGEX};
pub const SECRET_REDACTION_REPLACEMENT_CHARACTER: &str = "*";
@@ -22,11 +22,20 @@ pub(crate) fn find_secrets_in_text(text: &str) -> Vec<SecretRange> {
/// Returns the ranges of detected secrets in the given text along with their SecretLevel.
pub(crate) fn find_secrets_in_text_with_levels(text: &str) -> Vec<(SecretRange, SecretLevel)> {
// Combine all regex patterns into a single regex pattern with non-capturing groups, for efficiency.
// Note that we purposely use regex::Regex instead of RegexDFAs since we are working a Text (containing
// a normal String) rather than the Grid (where text is in Cells with 1 character each).
let regex = SECRETS_REGEX.read();
let metadata = REGEX_LEVEL_METADATA.read();
let secrets_regex: Arc<SecretsRegex> = { SECRETS_REGEX.lock().clone() };
find_secrets_in_text_with_levels_using_regex(text, &secrets_regex)
}
pub(crate) fn find_secrets_in_text_with_levels_using_regex(
text: &str,
secrets_regex: &SecretsRegex,
) -> Vec<(SecretRange, SecretLevel)> {
let SecretsRegex {
regex,
level_metadata,
..
} = secrets_regex;
let mut secret_ranges = vec![];
let mut byte_to_char_index = vec![0; text.len() + 1]; // Map byte index to char index
@@ -49,12 +58,12 @@ pub(crate) fn find_secrets_in_text_with_levels(text: &str) -> Vec<(SecretRange,
// Determine which pattern matched by getting the pattern ID and map via counts
let pattern_id = mat.pattern().as_usize();
let total_patterns = metadata.enterprise_count + metadata.user_count;
let total_patterns = level_metadata.enterprise_count + level_metadata.user_count;
if pattern_id >= total_patterns {
log::error!("Secret level not found for pattern ID {pattern_id}");
continue;
}
let secret_level = if pattern_id < metadata.enterprise_count {
let secret_level = if pattern_id < level_metadata.enterprise_count {
SecretLevel::Enterprise
} else {
SecretLevel::User
@@ -704,5 +713,5 @@ pub(crate) fn redact_secrets_in_element<T: PartialClickableElement>(
}
#[cfg(test)]
#[path = "secret_redaction_test.rs"]
#[path = "secret_redaction_tests.rs"]
mod test;
@@ -3,9 +3,8 @@ use galaxyui::fonts::FamilyId;
use regex::Regex;
use serial_test::serial;
use crate::terminal::model::secrets::{self, SecretLevel};
use super::*;
use crate::terminal::model::secrets::{self, SecretLevel};
#[test]
fn test_merge_no_ranges() {
+192 -186
View File
@@ -1,89 +1,75 @@
use std::{collections::HashSet, sync::Arc, time::Duration};
use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use super::{
cli_controller::{CLISubagentController, CLISubagentEvent, UserTakeOverReason},
model::{AIBlockModel, AIBlockModelImpl, AIBlockOutputStatus},
view_impl::common::{
random_load_output_message, render_switch_control_to_user_button, render_warping_indicator,
render_warping_indicator_base, ButtonProps, ForceRefreshButtonProps, MaybeShimmeringText,
WarpingIndicatorProps, WarpingProps, WAITING_FOR_USER_INPUT_MESSAGE,
},
};
use crate::{
ai::agent_tips::AITipModel,
terminal::{
input::buffer_model::InputBufferUpdateEvent,
view::ambient_agent::is_cloud_agent_pre_first_exchange,
},
};
use crate::{
ai::blocklist::agent_view::{
agent_view_bg_fill, child_agent_status_card::ChildAgentStatusCard, AgentMessageBar,
AgentViewController, EphemeralMessageModel,
},
terminal::input::{
buffer_model::InputBufferModel,
message_bar::common::render_standard_message_bar,
message_bar::{Message, MessageItem},
slash_command_model::SlashCommandModel,
suggestions_mode_model::InputSuggestionsModeModel,
},
};
use warp_multi_agent_api as api;
use crate::{
ai::{
agent::{
conversation::AIConversationId, icons, AIAgentExchangeId, AIAgentOutput,
AIAgentOutputMessageType, CancellationReason, SummarizationType,
},
blocklist::{
agent_view::shortcuts::AgentShortcutViewModel,
ai_brand_color,
model::AIBlockModelHelper,
summarization_cancel_dialog::{
self, SummarizationCancelDialog, SummarizationCancelDialogEvent,
},
BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIContextEvent,
BlocklistAIContextModel, BlocklistAIController, BlocklistAIHistoryEvent,
BlocklistAIInputEvent, BlocklistAIInputModel, ResponseStreamId,
},
llms::LLMPreferences,
AgentTip,
},
send_telemetry_from_app_ctx,
server::telemetry::TelemetryEvent,
settings::{InputModeSettings, InputSettings},
settings_view::keybindings::KeybindingChangedNotifier,
terminal::{
input::SET_INPUT_MODE_TERMINAL_ACTION_NAME,
model::block::LONG_RUNNING_COMMAND_DURATION_MS,
model_events::{ModelEvent, ModelEventDispatcher},
view::ambient_agent::{AmbientAgentViewModel, AmbientAgentViewModelEvent},
warpify::render::LEFT_STRIPE_WIDTH,
TerminalModel, CANCEL_COMMAND_KEYBINDING, TOGGLE_AUTOEXECUTE_MODE_KEYBINDING,
TOGGLE_HIDE_CLI_RESPONSES_KEYBINDING, TOGGLE_QUEUE_NEXT_PROMPT_KEYBINDING,
},
util::bindings::keybinding_name_to_keystroke,
BlocklistAIHistoryModel,
};
use galaxy_core::{
features::FeatureFlag,
ui::{appearance::Appearance, theme::Fill, Icon as CoreIcon},
};
use galaxyui::elements::shimmering_text::ShimmeringTextStateHandle;
use galaxyui::{
elements::{Border, Container, Empty, Flex, MouseStateHandle, ParentElement, Text},
keymap::Keystroke,
presenter::ChildView,
r#async::SpawnedFutureHandle,
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity, View, ViewContext,
ViewHandle,
};
use galaxyui::{r#async::Timer, TypedActionView};
use instant::Instant;
use markdown_parser::FormattedTextFragment;
use parking_lot::FairMutex;
use pathfinder_color::ColorU;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::Fill;
use galaxy_core::ui::Icon as CoreIcon;
use warp_multi_agent_api as api;
use galaxyui::elements::shimmering_text::ShimmeringTextStateHandle;
use galaxyui::elements::{Border, Container, Empty, Flex, MouseStateHandle, ParentElement, Text};
use galaxyui::keymap::Keystroke;
use galaxyui::presenter::ChildView;
use galaxyui::r#async::{SpawnedFutureHandle, Timer};
use galaxyui::{
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle,
};
use super::cli_controller::{CLISubagentController, CLISubagentEvent, UserTakeOverReason};
use super::model::{AIBlockModel, AIBlockModelImpl, AIBlockOutputStatus};
use super::view_impl::common::{
render_switch_control_to_user_button, render_warping_indicator, render_warping_indicator_base,
AutoExecuteButtonProps, ButtonProps, ForceRefreshButtonProps, MaybeShimmeringText,
WarpingIndicatorProps, WarpingProps, LOAD_OUTPUT_MESSAGE, WAITING_FOR_USER_INPUT_MESSAGE,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{
icons, AIAgentExchangeId, AIAgentOutput, AIAgentOutputMessageType, CancellationReason,
SummarizationType,
};
use crate::ai::agent_tips::AITipModel;
use crate::ai::blocklist::agent_view::shortcuts::AgentShortcutViewModel;
use crate::ai::blocklist::agent_view::{
agent_view_bg_fill, is_in_cloud_context, AgentMessageBar, AgentViewController,
EphemeralMessageModel,
};
use crate::ai::blocklist::model::AIBlockModelHelper;
use crate::ai::blocklist::summarization_cancel_dialog::{
self, SummarizationCancelDialog, SummarizationCancelDialogEvent,
};
use crate::ai::blocklist::{
ai_brand_color, BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIContextEvent,
BlocklistAIContextModel, BlocklistAIController, BlocklistAIHistoryEvent, BlocklistAIInputEvent,
BlocklistAIInputModel, QueuedQueryEvent, QueuedQueryModel, ResponseStreamId,
};
use crate::ai::llms::LLMPreferences;
use crate::ai::AgentTip;
use crate::server::telemetry::TelemetryEvent;
use crate::settings::{InputModeSettings, InputSettings};
use crate::settings_view::keybindings::KeybindingChangedNotifier;
use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent};
use crate::terminal::input::message_bar::common::render_wrapping_standard_message_bar;
use crate::terminal::input::slash_command_model::SlashCommandModel;
use crate::terminal::input::suggestions_mode_model::InputSuggestionsModeModel;
use crate::terminal::input::{HandoffComposeState, SET_INPUT_MODE_TERMINAL_ACTION_NAME};
use crate::terminal::model::block::LONG_RUNNING_COMMAND_DURATION_MS;
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::terminal::view::ambient_agent::{
is_cloud_agent_pre_first_exchange, AmbientAgentViewModel, AmbientAgentViewModelEvent,
};
use crate::terminal::warpify::render::LEFT_STRIPE_WIDTH;
use crate::terminal::{
TerminalModel, CANCEL_COMMAND_KEYBINDING, TOGGLE_AUTOEXECUTE_MODE_KEYBINDING,
TOGGLE_HIDE_CLI_RESPONSES_KEYBINDING, TOGGLE_QUEUE_NEXT_PROMPT_KEYBINDING,
};
use crate::util::bindings::keybinding_name_to_keystroke;
use crate::{send_telemetry_from_app_ctx, BlocklistAIHistoryModel};
pub fn init(app: &mut AppContext) {
summarization_cancel_dialog::init(app);
@@ -96,7 +82,6 @@ struct StateHandles {
stop_button: MouseStateHandle,
take_over_button: MouseStateHandle,
hide_cli_responses_button: MouseStateHandle,
github_auth_link: MouseStateHandle,
/// Tracks hover/press state for the inline `Check now` affordance rendered next to
/// `Last seen by agent ...` while the agent is polling a long-running command.
force_refresh_button: MouseStateHandle,
@@ -113,7 +98,7 @@ pub struct BlocklistAIStatusBar {
terminal_model: Arc<FairMutex<TerminalModel>>,
shimmering_text_handle: ShimmeringTextStateHandle,
state_handles: StateHandles,
ambient_agent_view_model: ModelHandle<AmbientAgentViewModel>,
ambient_agent_view_model: Option<ModelHandle<AmbientAgentViewModel>>,
autoexecute_keystroke: Option<Keystroke>,
queue_next_prompt_keystroke: Option<Keystroke>,
@@ -146,7 +131,6 @@ pub struct BlocklistAIStatusBar {
ephemeral_message_model: ModelHandle<EphemeralMessageModel>,
agent_message_bar: ViewHandle<AgentMessageBar>,
child_agent_status_card: ViewHandle<ChildAgentStatusCard>,
}
impl BlocklistAIStatusBar {
@@ -162,17 +146,18 @@ impl BlocklistAIStatusBar {
model_event_dispatcher: &ModelHandle<ModelEventDispatcher>,
terminal_model: Arc<FairMutex<TerminalModel>>,
shortcut_view_model: ModelHandle<AgentShortcutViewModel>,
ambient_agent_view_model: ModelHandle<AmbientAgentViewModel>,
ambient_agent_view_model: Option<ModelHandle<AmbientAgentViewModel>>,
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
slash_command_model: ModelHandle<SlashCommandModel>,
ephemeral_message_model: ModelHandle<EphemeralMessageModel>,
handoff_compose_state: ModelHandle<HandoffComposeState>,
terminal_view_id: EntityId,
ctx: &mut ViewContext<Self>,
) -> Self {
let history_model = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_model, move |me, _, event, ctx| {
if event
.terminal_view_id()
.terminal_surface_id()
.is_some_and(|id| id != terminal_view_id)
{
return;
@@ -189,7 +174,7 @@ impl BlocklistAIStatusBar {
}
me.reset_model_for_exchange(*exchange_id, *conversation_id, ctx);
}
BlocklistAIHistoryEvent::ClearedConversationsInTerminalView { .. } => {
BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface { .. } => {
me.active_exchange_model = None;
me.stop_warping_timer();
ctx.notify();
@@ -234,10 +219,15 @@ impl BlocklistAIStatusBar {
}
});
ctx.subscribe_to_model(&context_model, |_, _, event, ctx| {
if matches!(event, BlocklistAIContextEvent::PendingQueryStateUpdated) {
ctx.notify();
}
});
ctx.subscribe_to_model(&QueuedQueryModel::handle(ctx), |_, _, event, ctx| {
if matches!(
event,
BlocklistAIContextEvent::PendingQueryStateUpdated
| BlocklistAIContextEvent::QueueNextPromptToggled
QueuedQueryEvent::QueueNextPromptToggled { .. }
| QueuedQueryEvent::DefaultModeChanged
) {
ctx.notify();
}
@@ -303,6 +293,11 @@ impl BlocklistAIStatusBar {
ctx.notify();
});
ctx.observe(&AITipModel::handle(ctx), |me, tip_model, ctx| {
me.current_tip = tip_model.as_ref(ctx).current_tip().cloned();
ctx.notify();
});
let summarization_cancel_dialog =
ctx.add_typed_action_view(|_| SummarizationCancelDialog::default());
ctx.subscribe_to_view(
@@ -347,7 +342,7 @@ impl BlocklistAIStatusBar {
ctx.notify();
});
let agent_message_bar = ctx.add_view(|ctx| {
let agent_message_bar = ctx.add_typed_action_view(|ctx| {
AgentMessageBar::new(
agent_view_controller.clone(),
ephemeral_message_model.clone(),
@@ -357,28 +352,28 @@ impl BlocklistAIStatusBar {
input_suggestions_model,
slash_command_model,
context_model.clone(),
handoff_compose_state,
terminal_model.clone(),
ctx,
)
});
let child_agent_status_card = ctx.add_typed_action_view(|ctx| {
ChildAgentStatusCard::new(agent_view_controller.clone(), ctx)
});
ctx.subscribe_to_model(&ambient_agent_view_model, |me, _, event, ctx| match event {
AmbientAgentViewModelEvent::DispatchedAgent
| AmbientAgentViewModelEvent::ProgressUpdated => {
me.update_agent_tip(ctx);
ctx.notify();
}
AmbientAgentViewModelEvent::SessionReady { .. }
| AmbientAgentViewModelEvent::Failed { .. }
| AmbientAgentViewModelEvent::NeedsGithubAuth
| AmbientAgentViewModelEvent::Cancelled => {
ctx.notify();
}
_ => (),
});
if let Some(ambient_agent_view_model) = ambient_agent_view_model.as_ref() {
ctx.subscribe_to_model(ambient_agent_view_model, |me, _, event, ctx| match event {
AmbientAgentViewModelEvent::DispatchedAgent
| AmbientAgentViewModelEvent::ProgressUpdated => {
me.update_agent_tip(ctx);
ctx.notify();
}
AmbientAgentViewModelEvent::SessionReady { .. }
| AmbientAgentViewModelEvent::Failed { .. }
| AmbientAgentViewModelEvent::NeedsGithubAuth
| AmbientAgentViewModelEvent::Cancelled => {
ctx.notify();
}
_ => (),
});
}
Self {
active_exchange_model: None,
@@ -409,7 +404,6 @@ impl BlocklistAIStatusBar {
current_tip: None,
ephemeral_message_model,
agent_message_bar,
child_agent_status_card,
}
}
@@ -909,23 +903,28 @@ impl BlocklistAIStatusBar {
shimmering_text_handle: &self.shimmering_text_handle,
summarization_start_time: self.summarization_start_time,
auto_execute_button: (!model.request_type(app).is_passive_code_diff()).then_some(
ButtonProps {
AutoExecuteButtonProps {
button_handle: &self.state_handles.autoexecute_button,
keystroke: self.autoexecute_keystroke.as_ref(),
is_active: model
.conversation(app)
.map(|c| c.autoexecute_any_action())
.unwrap_or(false),
is_locked: is_in_cloud_context(
terminal_model.block_list().agent_view_state(),
&terminal_model,
),
},
),
queue_next_prompt_button: FeatureFlag::QueueSlashCommand.is_enabled().then_some(
ButtonProps {
button_handle: &self.state_handles.queue_next_prompt_button,
keystroke: self.queue_next_prompt_keystroke.as_ref(),
is_active: self
.context_model
.as_ref(app)
.is_queue_next_prompt_enabled(),
is_active: QueuedQueryModel::as_ref(app).is_queue_next_prompt_enabled(
conversation.id(),
active_block,
app,
),
},
),
stop_button: Some(ButtonProps {
@@ -961,16 +960,21 @@ impl BlocklistAIStatusBar {
return None;
}
let ambient_agent_model = self.ambient_agent_view_model.as_ref(app);
let ambient_agent_model = self
.ambient_agent_view_model
.as_ref()
.map(|ambient_agent_view_model| ambient_agent_view_model.as_ref(app))?;
// The step indicator is only meaningful while a spawn is in flight. Terminal states
// (`Failed`, `NeedsGithubAuth`, `Cancelled`) still carry an `AgentProgress` for
// telemetry purposes, so guard on `is_waiting_for_session()` rather than relying on
// `agent_progress()` being `None`.
if !ambient_agent_model.is_waiting_for_session() {
return None;
}
let progress = ambient_agent_model.agent_progress()?;
let progress_text = if progress.harness_started_at.is_some() {
"Starting Environment (Step 3/3)"
} else if progress.claimed_at.is_some() {
"Creating Environment (Step 2/3)"
} else {
"Connecting to Host (Step 1/3)"
};
let progress_text = progress.setup_status_text();
Some(render_warping_indicator_base(
WarpingIndicatorProps {
icon: None,
@@ -988,58 +992,58 @@ impl BlocklistAIStatusBar {
))
}
fn render_cloud_mode_setup_terminal_message(&self, app: &AppContext) -> Option<Message> {
fn render_cloud_mode_setup_terminal_message(
&self,
app: &AppContext,
) -> Option<Box<dyn Element>> {
if !FeatureFlag::CloudModeSetupV2.is_enabled() {
return None;
}
let ambient_agent_model = self.ambient_agent_view_model.as_ref(app);
let ambient_agent_model = self
.ambient_agent_view_model
.as_ref()
.map(|ambient_agent_view_model| ambient_agent_view_model.as_ref(app))?;
let theme = Appearance::as_ref(app).theme();
let error_color = theme.ansi_fg_red();
if let Some(auth_url) = ambient_agent_model.github_auth_url() {
return Some(Message::new(vec![
MessageItem::Icon {
icon: CoreIcon::Triangle,
color: Some(error_color),
},
MessageItem::Text {
content: "Missing GitHub authentication. ".into(),
color: Some(error_color),
},
MessageItem::hyperlink(
"Authenticate GitHub",
auth_url.to_owned(),
self.state_handles.github_auth_link.clone(),
),
]));
}
if let Some(error_message) = ambient_agent_model.error_message() {
return Some(Message::new(vec![
MessageItem::Icon {
icon: CoreIcon::Triangle,
color: Some(error_color),
},
MessageItem::Text {
content: error_message.to_owned().into(),
color: Some(error_color),
},
]));
let error_message = ambient_agent_model
.github_auth_error_message()
.unwrap_or("Missing GitHub authentication.");
return Some(render_wrapping_standard_message_bar(
CoreIcon::Triangle,
error_color,
error_color,
vec![
FormattedTextFragment::plain_text(format!("{error_message} ")),
FormattedTextFragment::hyperlink("Authenticate GitHub", auth_url.to_owned()),
],
app,
));
}
if ambient_agent_model.is_cancelled() {
let color = theme.disabled_text_color(theme.background()).into_solid();
return Some(Message::new(vec![
MessageItem::Icon {
icon: CoreIcon::StopFilled,
color: Some(color),
},
MessageItem::Text {
content: "Cloud agent run cancelled".into(),
color: Some(color),
},
]));
return Some(render_wrapping_standard_message_bar(
CoreIcon::StopFilled,
color,
color,
vec![FormattedTextFragment::plain_text(
"Cloud agent run cancelled",
)],
app,
));
}
if let Some(error_message) = ambient_agent_model.error_message() {
return Some(render_wrapping_standard_message_bar(
CoreIcon::Triangle,
error_color,
error_color,
vec![FormattedTextFragment::plain_text(error_message.to_owned())],
app,
));
}
None
@@ -1065,15 +1069,16 @@ fn latest_model_used_before_exchange<V: View>(
model_id: model_info.model_id.to_string(),
model_display_name: model_info.display_name.clone(),
is_fallback: model_info.is_fallback,
prompt_cache_expires_at: None,
})
})
}
fn render_agent_tip(tip: &AgentTip, app: &AppContext) -> Box<dyn Element> {
use crate::ai::agent_tips::AITip;
use galaxyui::text_layout::ClipConfig;
use markdown_parser::{FormattedTextFragment, FormattedTextLine};
use crate::ai::agent_tips::AITip;
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
@@ -1218,17 +1223,24 @@ impl View for BlocklistAIStatusBar {
if let Some(cloud_mode_setup_terminal_message) =
self.render_cloud_mode_setup_terminal_message(app)
{
return render_standard_message_bar(cloud_mode_setup_terminal_message, None, app);
return cloud_mode_setup_terminal_message;
}
let status_element =
if let Some(cloud_mode_setup_status) = self.render_cloud_mode_setup_status(app) {
cloud_mode_setup_status
} else if FeatureFlag::CloudModeSetupV2.is_enabled()
&& is_cloud_agent_pre_first_exchange(
&self.ambient_agent_view_model,
&self.agent_view_controller,
app,
)
&& self
.ambient_agent_view_model
.as_ref()
.is_some_and(|ambient_agent_view_model| {
let terminal_model = self.terminal_model.lock();
is_cloud_agent_pre_first_exchange(
Some(ambient_agent_view_model),
&self.agent_view_controller,
&terminal_model,
app,
)
})
{
render_warping_indicator_base(
WarpingIndicatorProps {
@@ -1288,16 +1300,20 @@ impl View for BlocklistAIStatusBar {
.is_none(),
) {
warping_indicator
} else if self
.ambient_agent_view_model
.as_ref(app)
.is_waiting_for_session()
{
} else if self.ambient_agent_view_model.as_ref().is_some_and(
|ambient_agent_view_model| {
ambient_agent_view_model
.as_ref(app)
.is_waiting_for_session()
},
) {
// Don't render warping indicator - the loading screen is shown in the main view
return Empty::new().finish();
} else if agent_view_controller.is_active() {
// The orchestration pill bar in the agent view header
// replaces the legacy child-agent status card rows;
// render only the message bar here.
return Flex::column()
.with_child(ChildView::new(&self.child_agent_status_card).finish())
.with_child(ChildView::new(&self.agent_message_bar).finish())
.finish();
} else {
@@ -1363,16 +1379,6 @@ impl View for BlocklistAIStatusBar {
container = container.with_vertical_padding(8.);
}
// When the agent view is active, keep the child agent status card
// visible above the warping/status indicator so it doesn't disappear
// while the agent is working.
if agent_view_controller.is_active() {
return Flex::column()
.with_child(ChildView::new(&self.child_agent_status_card).finish())
.with_child(container.finish())
.finish();
}
container.finish()
}
}
+12 -14
View File
@@ -1,19 +1,17 @@
use warpui::elements::{
Border, Container, CornerRadius, CrossAxisAlignment, Expanded, Flex, Hoverable,
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius,
};
use warpui::keymap::macros::*;
use warpui::keymap::{FixedBinding, Keystroke};
use warpui::platform::Cursor;
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use warpui::ui_components::keyboard_shortcut::KeyboardShortcut;
use warpui::ui_components::text::Span;
use warpui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
use crate::appearance::Appearance;
use crate::ui_components::blended_colors;
use galaxyui::{
elements::{
Border, Container, CornerRadius, CrossAxisAlignment, Expanded, Flex, Hoverable,
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius,
},
keymap::{macros::*, FixedBinding, Keystroke},
platform::Cursor,
ui_components::{
components::{Coords, UiComponent, UiComponentStyles},
keyboard_shortcut::KeyboardShortcut,
text::Span,
},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
};
type ItemLabelFn<T> = Box<dyn Fn(&T, &AppContext) -> Span>;
+278 -231
View File
@@ -29,70 +29,65 @@ pub mod output;
pub mod query;
mod todos;
use common::get_highlight_ranges_for_find_matches;
use galaxy_core::features::FeatureFlag;
use galaxy_core::semantic_selection::SemanticSelection;
use galaxyui::elements::{
Align, ConstrainedBox, CornerRadius, CrossAxisAlignment, Empty, Expanded, FormattedTextElement,
Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, Radius, SavePosition,
SelectableArea,
};
use galaxyui::{
elements::{Border, Container, Flex, ParentElement},
AppContext, Element, SingletonEntity,
};
use galaxyui::{View, ViewContext};
use pathfinder_color::ColorU;
use settings::Setting as _;
use std::collections::{HashMap, HashSet};
use crate::ai::agent::AIAgentCitation;
use crate::ai::agent::AIAgentInput;
use crate::ai::blocklist::block::view_impl::header::{
render_overflow_menu_button, OVERFLOW_BUTTON_SIZE,
};
use crate::ai::blocklist::inline_action::inline_action_icons::icon_size;
use crate::ai::blocklist::model::AIBlockModelHelper;
use crate::appearance::Appearance;
use crate::settings::{AISettings, InputModeSettings, InputSettings};
use crate::terminal::model::blocks::{BlockHeightItem, RemovableBlocklistItem, RichContentItem};
use crate::terminal::model::rich_content::RichContentType;
use crate::terminal::TerminalView;
use crate::util::truncation::truncate_from_end;
use super::secret_redaction::SecretRedactionState;
use super::{
attachment_names, AIBlock, AIBlockAction, DISPATCHED_REQUESTED_EDIT_KEYMAP_CONTEXT,
HAS_PENDING_ACTION, RICH_CONTENT_SECRET_FIRST_CHAR_POSITION_ID,
};
use super::TextLocation;
use crate::ai::blocklist::block::view_impl::comments::address_comment_chips;
use crate::ai::blocklist::block::{DetectedLinksState, RICH_CONTENT_LINK_FIRST_CHAR_POSITION_ID};
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
use crate::cloud_object::model::persistence::CloudModel;
use crate::settings_view::SettingsSection;
use crate::terminal::block_list_element::BlockListMenuSource;
use crate::terminal::grid_renderer::URL_COLOR;
use crate::terminal::model::ObfuscateSecrets;
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
use crate::terminal::view::TerminalAction;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::util::link_detection::DetectedLinkType;
use crate::workspace::WorkspaceAction;
use common::get_highlight_ranges_for_find_matches;
use itertools::Itertools;
use pathfinder_color::ColorU;
use settings::Setting as _;
use galaxy_core::features::FeatureFlag;
use galaxy_core::semantic_selection::SemanticSelection;
use galaxy_core::ui::color::contrast::{
foreground_color_with_minimum_contrast, MinimumAllowedContrast,
};
use galaxy_core::ui::color::Rgb;
use galaxy_core::ui::theme::{Fill, GalaxyTheme};
use galaxyui::elements::{Highlight, HighlightedRange, Text};
use galaxy_core::ui::theme::{Fill, WarpTheme};
use galaxyui::elements::{
Align, Border, Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty,
Expanded, Flex, FormattedTextElement, Highlight, HighlightedRange, Hoverable,
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, SavePosition,
SelectableArea, Text,
};
use galaxyui::fonts::Properties;
use galaxyui::platform::Cursor;
use galaxyui::text_layout::TextStyle;
use galaxyui::ui_components::components::UiComponent;
use itertools::Itertools;
use galaxyui::{AppContext, Element, SingletonEntity, View, ViewContext};
use super::secret_redaction::SecretRedactionState;
use super::{
attachment_names, AIBlock, AIBlockAction, TextLocation,
DISPATCHED_REQUESTED_EDIT_KEYMAP_CONTEXT, HAS_PENDING_ACTION,
RICH_CONTENT_SECRET_FIRST_CHAR_POSITION_ID,
};
use crate::ai::agent::{AIAgentCitation, AIAgentInput};
use crate::ai::blocklist::block::view_impl::comments::address_comment_chips;
use crate::ai::blocklist::block::view_impl::header::{
render_overflow_menu_button, OVERFLOW_BUTTON_SIZE,
};
use crate::ai::blocklist::block::{DetectedLinksState, RICH_CONTENT_LINK_FIRST_CHAR_POSITION_ID};
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
use crate::ai::blocklist::inline_action::inline_action_icons::icon_size;
use crate::ai::blocklist::model::AIBlockModelHelper;
use crate::appearance::Appearance;
use crate::cloud_object::model::persistence::CloudModel;
use crate::settings::{AISettings, InputModeSettings, InputSettings};
use crate::settings_view::SettingsSection;
use crate::terminal::block_list_element::BlockListMenuSource;
use crate::terminal::grid_renderer::URL_COLOR;
use crate::terminal::model::blocks::{BlockHeightItem, RemovableBlocklistItem, RichContentItem};
use crate::terminal::model::rich_content::RichContentType;
use crate::terminal::model::ObfuscateSecrets;
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
use crate::terminal::view::ambient_agent::is_cloud_agent_pre_first_exchange;
use crate::terminal::view::TerminalAction;
use crate::terminal::TerminalView;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::util::link_detection::DetectedLinkType;
use crate::util::truncation::truncate_from_end;
use crate::view_components::dropdown::DropdownItemAction;
use crate::workspace::WorkspaceAction;
/// Helper function to create gray strikethrough highlight for secrets
fn create_secret_gray_highlight() -> Highlight {
@@ -154,27 +149,6 @@ fn add_slash_command_highlight(
}
}
/// In shared ambient sessions, the first prompt is already shown by
/// `InitialUserQuery` during live startup/streaming.
///
/// To avoid duplicate UI, we suppress the first AI block's header/query only while the viewer is
/// live (not replaying historical conversation events).
///
/// That first prompt is rendered in the ambient-agent query block UI, so this helper only gates
/// duplicate rendering in the AI block path when that optimistic block was actually inserted.
fn should_hide_first_ai_block_query_and_header(
has_inserted_cloud_mode_user_query_block: bool,
is_shared_ambient_agent_session: bool,
is_first_exchange: bool,
is_receiving_agent_conversation_replay: bool,
) -> bool {
FeatureFlag::CloudModeSetupV2.is_enabled()
&& has_inserted_cloud_mode_user_query_block
&& is_shared_ambient_agent_session
&& is_first_exchange
&& !is_receiving_agent_conversation_replay
}
/// Adds the appropriate highlighting for secrets and links to the given text element.
#[allow(clippy::too_many_arguments)]
fn add_highlights_to_text(
@@ -704,6 +678,15 @@ pub fn render_citation(
let name = url.clone();
(Some(icon), name)
}
AIAgentCitation::AgentMemory { content, .. } => {
let icon = Icon::Cognition.to_warpui_icon(theme.foreground()).finish();
let name = if content.is_empty() {
String::from("Memory")
} else {
content.clone()
};
(Some(icon), name)
}
};
// Shorten the name to 30 chars.
@@ -747,6 +730,76 @@ pub fn render_citation(
)
}
/// Renders the Ask-User-Question speedbump footer: a short description label, a
/// dropdown for the `ask_user_question` permission, and a right-aligned
/// "Manage AI Autonomy permissions" link. Matches the visual rhythm of
/// [`render_autonomy_checkbox_setting_speedbump_footer`].
pub fn render_autonomy_dropdown_setting_speedbump_footer<A>(
description: &'static str,
dropdown: &warpui::ViewHandle<crate::view_components::dropdown::Dropdown<A>>,
settings_link_handle: MouseStateHandle,
app: &AppContext,
) -> Box<dyn Element>
where
A: DropdownItemAction,
{
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
Clipped::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max)
.with_child(
Container::new(
Text::new(
description,
appearance.ui_font_family(),
appearance.monospace_font_size() - 1.,
)
.with_color(blended_colors::text_sub(theme, theme.surface_1()))
.with_selectable(false)
.finish(),
)
.with_margin_right(8.)
.finish(),
)
.with_child(
Container::new(warpui::elements::ChildView::new(dropdown).finish())
.with_margin_right(8.)
.finish(),
)
.with_child(
Expanded::new(
1.,
Align::new(
appearance
.ui_builder()
.link(
"Manage AI Autonomy permissions".into(),
None,
Some(Box::new(move |ctx| {
ctx.dispatch_typed_action(
WorkspaceAction::ShowSettingsPageWithSearch {
search_query: "Autonomy".to_string(),
section: Some(SettingsSection::AI),
},
);
})),
settings_link_handle,
)
.build()
.finish(),
)
.right()
.finish(),
)
.finish(),
)
.finish(),
)
.finish()
}
/// TODO: All AIBlock footer-related rendering logic should probably be put into its own View.
/// This function is needed both above (i.e. `block.rs`) and below (i.e. `output.rs`), and as such
/// cannot reside in `output.rs` because we don't want to make `mod output` public.
@@ -846,26 +899,6 @@ impl View for AIBlock {
};
let addressed_comment_ids = conversation.addressed_comment_ids();
let mut contents = Flex::column();
let (is_shared_ambient_agent_session, is_receiving_agent_conversation_replay) = {
let terminal_model = self.terminal_model.lock();
(
terminal_model.is_shared_ambient_agent_session(),
terminal_model.is_receiving_agent_conversation_replay(),
)
};
let is_first_exchange = conversation
.first_exchange()
.is_some_and(|exchange| exchange.id == self.client_ids.client_exchange_id);
let has_inserted_cloud_mode_user_query_block = self
.ambient_agent_view_model
.as_ref(app)
.has_inserted_cloud_mode_user_query_block();
let should_hide_first_block_query_and_header = should_hide_first_ai_block_query_and_header(
has_inserted_cloud_mode_user_query_block,
is_shared_ambient_agent_session,
is_first_exchange,
is_receiving_agent_conversation_replay,
);
let input_props = input::Props {
comments: &self.comment_states,
@@ -893,143 +926,135 @@ impl View for AIBlock {
element_below_user_query,
))
});
let query_and_index_is_some =
query_and_index.is_some() && !should_hide_first_block_query_and_header;
let query_and_index_is_some = query_and_index.is_some();
let attachment_name_list = if FeatureFlag::ImageAsContext.is_enabled() {
attachment_names(self.model.inputs_to_render(app))
} else {
vec![]
};
if !should_hide_first_block_query_and_header {
if let Some((
query_for_display,
input_index,
query_prefix_highlight_len,
elements_below_query,
)) = query_and_index
{
let mut did_render_header = false;
if let Some(header) = header::render(
header::Props {
attached_blocks_chip_mouse_state: &self
.state_handles
.attached_blocks_chip_state_handle,
overflow_menu_mouse_state: &self.state_handles.overflow_menu_handle,
rewind_button: &self.rewind_button,
num_attached_context_blocks: self.num_attached_context_blocks,
has_attached_context_selected_text: self.has_attached_context_selected_text,
directory_context: &self.directory_context,
view_id: &self.view_id,
exchange_id: &self.client_ids.client_exchange_id,
conversation_id: &self.client_ids.conversation_id,
is_selected_text_attached_as_context: self
.context_model
.as_ref(app)
.pending_context_selected_text()
.is_some(),
is_restored: self.is_restored(),
},
app,
) {
// Only render the prompt "header" for blocks containing a user query (as opposed to a
// requested command result).
contents.add_child(header.with_content_item_spacing().finish());
did_render_header = true;
}
// Derive the display info for the participant who initiated this exchange.
// For non-shared sessions, this is just the current user.
// For shared sessions, this is the user who initiated the request.
let (avatar_display_name, profile_image_path, avatar_color) = self
.model
.response_initiator(app)
.and_then(|participant_id| {
app.view_with_id::<TerminalView>(self.window_id, self.terminal_view_id)
.and_then(|terminal_view| {
terminal_view.read(app, |view, app| {
view.shared_session_presence_manager().and_then(move |pm| {
pm.as_ref(app).get_participant(&participant_id).map(
|participant| {
// Get the display info from the participant
// who sent this query.
(
participant
.info
.profile_data
.display_name
.clone(),
participant.info.profile_data.photo_url.clone(),
Some(participant.color),
)
},
)
})
if let Some((
query_for_display,
input_index,
query_prefix_highlight_len,
elements_below_query,
)) = query_and_index
{
let mut did_render_header = false;
if let Some(header) = header::render(
header::Props {
attached_blocks_chip_mouse_state: &self
.state_handles
.attached_blocks_chip_state_handle,
overflow_menu_mouse_state: &self.state_handles.overflow_menu_handle,
rewind_button: &self.rewind_button,
num_attached_context_blocks: self.num_attached_context_blocks,
has_attached_context_selected_text: self.has_attached_context_selected_text,
directory_context: &self.directory_context,
view_id: &self.view_id,
exchange_id: &self.client_ids.client_exchange_id,
conversation_id: &self.client_ids.conversation_id,
is_selected_text_attached_as_context: self
.context_model
.as_ref(app)
.pending_context_selected_text()
.is_some(),
is_restored: self.is_restored(),
},
app,
) {
// Only render the prompt "header" for blocks containing a user query (as opposed to a
// requested command result).
contents.add_child(header.with_content_item_spacing().finish());
did_render_header = true;
}
// Derive the display info for the participant who initiated this exchange.
// For non-shared sessions, this is just the current user.
// For shared sessions, this is the user who initiated the request.
let (avatar_display_name, profile_image_path, avatar_color) = self
.model
.response_initiator(app)
.and_then(|participant_id| {
app.view_with_id::<TerminalView>(self.window_id, self.terminal_view_id)
.and_then(|terminal_view| {
terminal_view.read(app, |view, app| {
view.shared_session_presence_manager().and_then(move |pm| {
pm.as_ref(app).get_participant(&participant_id).map(
|participant| {
// Get the display info from the participant
// who sent this query.
(
participant.info.profile_data.display_name.clone(),
participant.info.profile_data.photo_url.clone(),
Some(participant.color),
)
},
)
})
})
})
// Fallback to the current user's info if this is not a shared session
// or the participant is not found.
.unwrap_or((
self.user_display_name.clone(),
self.profile_image_path.clone(),
None,
));
if let Some(rendered_query) = query::maybe_render(
query::Props {
user_display_name: &avatar_display_name,
profile_image_path: profile_image_path.as_ref(),
avatar_color,
query_and_index: Some((&query_for_display, input_index)),
query_prefix_highlight_len,
detected_links_state: &self.detected_links_state,
secret_redaction_state: &self.secret_redaction_state,
is_selecting_text: self.state_handles.selection_handle.is_selecting(),
is_ai_input_enabled: self
.context_model
.as_ref(app)
.pending_context_selected_text()
.is_some(),
attachments: &attachment_name_list,
find_context: self.find_model.as_ref(app).is_find_bar_open().then_some(
FindContext {
model: self.find_model.as_ref(app),
state: &self.find_state,
},
),
},
app,
) {
if did_render_header {
contents.add_child(rendered_query.with_content_item_spacing().finish());
} else {
// The query element is designed to be exactly icon_size() height.
let rendered_query_height = icon_size(app);
let margin_bottom = (CONTENT_ITEM_VERTICAL_MARGIN
- (OVERFLOW_BUTTON_SIZE - rendered_query_height).max(0.))
.max(0.);
contents.add_child(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(Expanded::new(1., rendered_query).finish())
.with_child(render_overflow_menu_button(
self.state_handles.overflow_menu_handle.clone(),
self.view_id,
self.client_ids.client_exchange_id,
self.client_ids.conversation_id,
self.is_restored(),
app,
))
.finish()
.with_content_item_spacing()
.with_margin_bottom(margin_bottom)
.finish(),
);
}
})
})
// Fallback to the current user's info if this is not a shared session
// or the participant is not found.
.unwrap_or((
self.user_display_name.clone(),
self.profile_image_path.clone(),
None,
));
if let Some(rendered_query) = query::maybe_render(
query::Props {
user_display_name: &avatar_display_name,
profile_image_path: profile_image_path.as_ref(),
avatar_color,
query_and_index: Some((&query_for_display, input_index)),
query_prefix_highlight_len,
detected_links_state: &self.detected_links_state,
secret_redaction_state: &self.secret_redaction_state,
is_selecting_text: self.state_handles.selection_handle.is_selecting(),
is_ai_input_enabled: self
.context_model
.as_ref(app)
.pending_context_selected_text()
.is_some(),
attachments: &attachment_name_list,
find_context: self.find_model.as_ref(app).is_find_bar_open().then_some(
FindContext {
model: self.find_model.as_ref(app),
state: &self.find_state,
},
),
},
app,
) {
if did_render_header {
contents.add_child(rendered_query.with_content_item_spacing().finish());
} else {
// The query element is designed to be exactly icon_size() height.
let rendered_query_height = icon_size(app);
let margin_bottom = (CONTENT_ITEM_VERTICAL_MARGIN
- (OVERFLOW_BUTTON_SIZE - rendered_query_height).max(0.))
.max(0.);
contents.add_child(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(Expanded::new(1., rendered_query).finish())
.with_child(render_overflow_menu_button(
self.state_handles.overflow_menu_handle.clone(),
self.view_id,
self.client_ids.client_exchange_id,
self.client_ids.conversation_id,
self.is_restored(),
app,
))
.finish()
.with_content_item_spacing()
.with_margin_bottom(margin_bottom)
.finish(),
);
}
}
if let Some(element) = elements_below_query {
contents.add_child(element.with_agent_output_item_spacing(app).finish());
}
if let Some(element) = elements_below_query {
contents.add_child(element.with_agent_output_item_spacing(app).finish());
}
}
@@ -1037,8 +1062,22 @@ impl View for AIBlock {
let terminal_model = self.terminal_model.lock();
let shared_session_status = terminal_model.shared_session_status().clone();
let is_conversation_transcript_viewer = terminal_model.is_conversation_transcript_viewer();
let is_cloud_agent_pre_first_exchange = is_cloud_agent_pre_first_exchange(
self.ambient_agent_view_model.as_ref(),
&self.agent_view_controller,
&terminal_model,
app,
);
drop(terminal_model);
#[cfg(not(target_family = "wasm"))]
let is_cloud_agent_context = FeatureFlag::CloudMode.is_enabled()
&& self
.ambient_agent_view_model
.as_ref()
.is_some_and(|model| model.as_ref(app).is_ambient_agent());
contents.add_child(output::render(
output::Props {
model: self.model.as_ref(),
@@ -1046,6 +1085,7 @@ impl View for AIBlock {
action_buttons: &self.action_buttons,
view_screenshot_buttons: &self.view_screenshot_buttons,
action_model: &self.action_model,
active_session: &self.active_session,
editor_views: &self.code_editor_views,
current_working_directory: self.current_working_directory.as_ref(),
shell_launch_data: self.shell_launch_data.as_ref(),
@@ -1091,10 +1131,13 @@ impl View for AIBlock {
shared_session_status: &shared_session_status,
terminal_view_id: self.terminal_view_id,
is_conversation_transcript_viewer,
#[cfg(not(target_family = "wasm"))]
is_cloud_agent_context,
aws_bedrock_credentials_error_view: self
.aws_bedrock_credentials_error_view
.as_ref(),
imported_comments: &self.imported_comments,
run_agents_card_views: &self.run_agents_card_views,
#[cfg(feature = "local_fs")]
resolved_code_block_paths: &self.resolved_code_block_paths,
#[cfg(feature = "local_fs")]
@@ -1102,9 +1145,10 @@ impl View for AIBlock {
thinking_display_mode: AISettings::as_ref(app).thinking_display_mode,
conversation_has_imported_comments: self
.model
.is_latest_non_passive_exchange_in_root_task(app)
.is_latest_visible_exchange_in_root_task(app)
&& self.has_imported_comments_in_current_thread(app),
ask_user_question_view: self.ask_user_question_view.as_ref(),
is_cloud_agent_pre_first_exchange,
},
app,
));
@@ -1182,10 +1226,9 @@ impl View for AIBlock {
false
}
});
let should_add_top_padding = !should_hide_first_block_query_and_header
&& (contains_user_query_and_is_not_pin_to_top
|| renders_below_requested_command_view
|| (!is_previous_blocklist_item_ai_block && !self.is_passive_conversation(app)));
let should_add_top_padding = contains_user_query_and_is_not_pin_to_top
|| renders_below_requested_command_view
|| (!is_previous_blocklist_item_ai_block && !self.is_passive_conversation(app));
if should_add_top_padding {
content = content.with_padding_top(CONTENT_VERTICAL_PADDING);
@@ -1197,8 +1240,11 @@ impl View for AIBlock {
let mut selectable = SelectableArea::new(
self.state_handles.selection_handle.clone(),
move |selection_args, _, _| {
*selected_text.write() = selection_args.selection;
move |selection_args, ctx, _| {
*selected_text.write() = selection_args
.selection
.filter(|selection| !selection.is_empty());
ctx.dispatch_typed_action(AIBlockAction::SelectText);
},
SavePosition::new(content.finish(), self.saved_position_id().as_str()).finish(),
)
@@ -1343,7 +1389,8 @@ impl AIAgentInput {
| AIAgentInput::ActionResult { .. }
| AIAgentInput::MessagesReceivedFromAgents { .. }
| AIAgentInput::EventsFromAgents { .. }
| AIAgentInput::PassiveSuggestionResult { .. } => None,
| AIAgentInput::PassiveSuggestionResult { .. }
| AIAgentInput::OrchestrationConfigUpdate { .. } => None,
}
}
}
@@ -1,7 +1,3 @@
use crate::ai::agent::comment::ReviewComment;
use crate::ai::agent::icons::addressed_comment_icon;
use crate::ai::blocklist::block::CommentElementState;
use crate::code_review::comments::ReviewCommentBatch;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::Icon;
@@ -11,6 +7,11 @@ use galaxyui::elements::{
};
use galaxyui::{AppContext, Element, SingletonEntity};
use crate::ai::agent::comment::ReviewComment;
use crate::ai::agent::icons::addressed_comment_icon;
use crate::ai::blocklist::block::CommentElementState;
use crate::code_review::comments::ReviewCommentBatch;
const COMMENT_CHIP_MAX_HEIGHT: f32 = 200.;
/// Displays a series of chips for the "Address Comments" input type.
+333 -190
View File
@@ -10,116 +10,95 @@ use std::iter;
use std::path::Path;
#[cfg(feature = "local_fs")]
use std::path::PathBuf;
use std::sync::Arc;
use galaxy_core::{
features::FeatureFlag,
ui::{appearance::Appearance, color::blend::Blend, theme::color::internal_colors},
};
use galaxyui::{
assets::asset_cache::{AssetCache, AssetSource, AssetState},
elements::{
new_scrollable::{ScrollableAppearance, SingleAxisConfig},
Align, Axis, Border, ChildAnchor, ChildView, ClippedScrollStateHandle, ConstrainedBox,
Container, CornerRadius, CrossAxisAlignment, DispatchEventResult, Empty, EventHandler,
Expanded, Fill, Flex, FormattedTextElement, HeadingFontSizeMultipliers, Hoverable,
Image as WarpImage, MainAxisAlignment, MainAxisSize, MouseStateHandle, NewScrollable,
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, SavePosition,
ScrollTarget, ScrollToPositionMode, ScrollbarWidth, Shrinkable, Stack, Table,
TableColumnWidth, TableConfig, TableHeader, TableVerticalSizing, Text, Wrap,
},
fonts::{Properties, Weight},
image_cache::{CacheOption, ImageType},
keymap::Keystroke,
platform::Cursor,
text_layout::{ClipConfig, TextAlignment, TextStyle},
ui_components::{
button::Button,
components::{Coords, UiComponent, UiComponentStyles},
},
Action, AppContext, Element, EventContext, SingletonEntity, View, ViewHandle,
};
use itertools::Itertools;
use markdown_parser::{FormattedText, FormattedTextInline, TableAlignment};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use std::sync::Arc;
use super::{add_highlights_to_rich_text, add_highlights_to_text, output::LinkActionConstructors};
use crate::ai::agent::MessageId;
use crate::terminal::find::BlockListMatch;
use crate::terminal::grid_renderer::{FOCUSED_MATCH_COLOR, MATCH_COLOR};
use crate::{
ai::{
agent::{conversation::AIConversation, icons, ShellCommandDelay},
blocklist::{
block::status_bar::BlocklistAIStatusBarAction, history_model::BlocklistAIHistoryModel,
BlocklistAIActionModel, ShellCommandExecutor,
},
loading::shimmering_warp_loading_text,
},
terminal::{self, TerminalModel},
util::link_detection::{add_link_detection_mouse_interactions, DetectedLinksState},
workspaces::{user_workspaces::UserWorkspaces, workspace::CustomerType},
};
use crate::{
ai::{
agent::{
icons::red_stop_icon, AIAgentAction, AIAgentActionType, AIAgentInput,
AIAgentOutputMessageType, AIAgentTextSection, AgentOutputImage, AgentOutputImageLayout,
AgentOutputMermaidDiagram, AgentOutputTable, AgentOutputTableRendering,
ProgrammingLanguage, RenderableAIError, SummarizationType, WebSearchStatus,
},
blocklist::{
block::{
find::FindState, view_impl::CONTENT_HORIZONTAL_PADDING, AIBlockAction,
CollapsibleElementState, CollapsibleExpansionState, EmbeddedCodeEditorView,
TableSectionHandles,
},
code_block::{
render_code_block_plain, render_code_block_with_warp_text, CodeBlockOptions,
CodeSnippetButtonHandles,
},
inline_action::{
aws_bedrock_credentials_error::AwsBedrockCredentialsErrorView,
inline_action_header::{
INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
},
inline_action_icons::{self, icon_size},
requested_action::RenderableAction,
},
model::{AIBlockModel, AIBlockModelHelper},
secret_redaction::{redact_secrets_in_element, SecretRedactionState},
view_util::error_color,
TextLocation,
},
AIRequestUsageModel,
},
code::{editor::view::CodeEditorView, editor_management::CodeSource},
notebooks::editor::{markdown_table_appearance, rich_text_styles},
settings_view::SettingsSection,
terminal::{
find::TerminalFindModel, safe_mode_settings::get_secret_obfuscation_mode,
view::TerminalAction, ShellLaunchData,
},
ui_components::{
avatar::{Avatar, AvatarContent},
blended_colors,
buttons::icon_button,
icons::Icon,
},
workspace::WorkspaceAction,
};
use crate::{
search::slash_command_menu::static_commands::commands,
settings::{FontSettings, InputSettings},
};
use galaxy_core::channel::ChannelState;
use galaxy_editor::content::{
edit::resolve_asset_source_relative_to_directory, mermaid_diagram::mermaid_asset_source,
};
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::color::blend::Blend;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_editor::content::edit::resolve_asset_source_relative_to_directory;
use galaxy_editor::content::mermaid_diagram::mermaid_asset_source;
use galaxy_util::path::to_relative_path;
use galaxyui::assets::asset_cache::{AssetCache, AssetSource, AssetState};
use galaxyui::elements::new_scrollable::{ScrollableAppearance, SingleAxisConfig};
use galaxyui::elements::shimmering_text::ShimmeringTextStateHandle;
use galaxyui::elements::{Highlight, HighlightedRange};
use galaxyui::elements::{
Align, Axis, Border, ChildAnchor, ChildView, Clipped, ClippedScrollStateHandle, ConstrainedBox,
Container, CornerRadius, CrossAxisAlignment, DispatchEventResult, Empty, EventHandler,
Expanded, Fill, Flex, FormattedTextElement, HeadingFontSizeMultipliers, Highlight,
HighlightedRange, Hoverable, Image as WarpImage, MainAxisAlignment, MainAxisSize,
MouseStateHandle, NewScrollable, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Radius, SavePosition, ScrollTarget, ScrollToPositionMode, ScrollbarWidth,
Shrinkable, Stack, Table, TableColumnWidth, TableConfig, TableHeader, TableVerticalSizing,
Text, Wrap,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::image_cache::{CacheOption, ImageType};
use galaxyui::keymap::Keystroke;
use galaxyui::platform::Cursor;
use galaxyui::text_layout::{ClipConfig, TextAlignment, TextStyle};
use galaxyui::ui_components::button::Button;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{Action, AppContext, Element, EventContext, SingletonEntity, View, ViewHandle};
use super::output::LinkActionConstructors;
use super::{add_highlights_to_rich_text, add_highlights_to_text};
use crate::ai::agent::conversation::AIConversation;
use crate::ai::agent::icons::red_stop_icon;
use crate::ai::agent::{
icons, AIAgentAction, AIAgentActionType, AIAgentInput, AIAgentOutputMessageType,
AIAgentTextSection, AgentOutputImage, AgentOutputImageLayout, AgentOutputMermaidDiagram,
AgentOutputTable, AgentOutputTableRendering, MessageId, ProgrammingLanguage, RenderableAIError,
ShellCommandDelay, SummarizationType, UserQueryMode, WebSearchStatus,
};
use crate::ai::blocklist::block::find::FindState;
use crate::ai::blocklist::block::status_bar::BlocklistAIStatusBarAction;
use crate::ai::blocklist::block::view_impl::CONTENT_HORIZONTAL_PADDING;
use crate::ai::blocklist::block::{
AIBlockAction, CollapsibleElementState, CollapsibleExpansionState, EmbeddedCodeEditorView,
TableSectionHandles,
};
use crate::ai::blocklist::code_block::{
render_code_block_plain, render_code_block_with_warp_text, CodeBlockOptions,
CodeSnippetButtonHandles,
};
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
use crate::ai::blocklist::inline_action::aws_bedrock_credentials_error::AwsBedrockCredentialsErrorView;
use crate::ai::blocklist::inline_action::inline_action_header::{
INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
};
use crate::ai::blocklist::inline_action::inline_action_icons::{self, icon_size};
use crate::ai::blocklist::inline_action::requested_action::RenderableAction;
use crate::ai::blocklist::model::{AIBlockModel, AIBlockModelHelper};
use crate::ai::blocklist::secret_redaction::{redact_secrets_in_element, SecretRedactionState};
use crate::ai::blocklist::view_util::error_color;
use crate::ai::blocklist::{BlocklistAIActionModel, ShellCommandExecutor, TextLocation};
use crate::ai::loading::shimmering_warp_loading_text;
use crate::ai::AIRequestUsageModel;
use crate::code::editor::view::CodeEditorView;
use crate::code::editor_management::CodeSource;
use crate::notebooks::editor::{markdown_table_appearance, rich_text_styles};
use crate::search::slash_command_menu::static_commands::commands;
use crate::settings::{FontSettings, InputSettings};
use crate::settings_view::SettingsSection;
use crate::terminal::find::TerminalFindModel;
use crate::terminal::grid_renderer::{FOCUSED_MATCH_COLOR, MATCH_COLOR};
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
use crate::terminal::view::TerminalAction;
use crate::terminal::{self, ShellLaunchData, TerminalModel};
use crate::ui_components::avatar::{Avatar, AvatarContent};
use crate::ui_components::blended_colors;
use crate::ui_components::buttons::icon_button;
use crate::ui_components::icons::Icon;
use crate::util::link_detection::{add_link_detection_mouse_interactions, DetectedLinksState};
use crate::workspace::WorkspaceAction;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::workspaces::workspace::CustomerType;
pub const STATUS_ICON_SIZE_DELTA: f32 = 4.;
pub const STATUS_FOOTER_VERTICAL_PADDING: f32 = 4.;
@@ -209,6 +188,7 @@ pub const BLOCKED_ACTION_MESSAGE_FOR_GREP_OR_FILE_GLOB: &str =
"OK if I search the files in this directory?";
const BLOCKLIST_VISUAL_SECTION_HEIGHT_LINE_MULTIPLIER: f32 = 10.0;
const BLOCKLIST_MERMAID_MAX_HEIGHT_LINE_MULTIPLIER: f32 = 40.0;
const INLINE_IMAGE_HEIGHT: f32 = 164.;
const INLINE_IMAGE_MAX_WIDTH: f32 = 218.;
const INLINE_IMAGE_SPACING: f32 = 32.;
@@ -227,7 +207,7 @@ pub struct WarpingProps<'a, V> {
pub summarization_start_time: Option<instant::Instant>,
pub hide_responses_button: Option<(ButtonProps<'a>, bool)>,
pub take_over_lrc_control_button: Option<ButtonProps<'a>>,
pub auto_execute_button: Option<ButtonProps<'a>>,
pub auto_execute_button: Option<AutoExecuteButtonProps<'a>>,
pub queue_next_prompt_button: Option<ButtonProps<'a>>,
pub stop_button: Option<ButtonProps<'a>>,
/// Inline `Check now` affordance displayed alongside `Last seen by agent ...`
@@ -250,6 +230,18 @@ pub struct ButtonProps<'a> {
pub is_active: bool,
}
/// Props for the auto-approve / fast-forward button in the warping indicator.
///
/// When `is_locked` is set, the button is rendered in its always-on state and
/// the click handler (plus the action dispatched by the keybinding) are no-ops.
/// Used for ambient agent conversations where fast-forward is always conceptually on.
pub struct AutoExecuteButtonProps<'a> {
pub button_handle: &'a MouseStateHandle,
pub keystroke: Option<&'a Keystroke>,
pub is_active: bool,
pub is_locked: bool,
}
pub struct ForceRefreshButtonProps<'a> {
pub button_handle: &'a MouseStateHandle,
/// The block the force-refresh should target.
@@ -590,6 +582,23 @@ pub struct WarpingIndicatorProps {
pub secondary_element: Option<Box<dyn Element>>,
}
/// Computes the fixed height of the warping-indicator footer.
///
/// The warping text occupies a single line. When a secondary element (an agent
/// tip or fallback-model explanation) is present, it renders on a second line
/// below the warping text, so the footer must reserve room for that extra line;
/// otherwise the `Clipped` wrapper — which keeps action chips from overflowing
/// narrow panes — also clips the secondary line. The extra line accounts for the
/// secondary element's font size (`monospace_font_size - 3`, see
/// `render_agent_tip` / `render_fallback_explanation`) plus its 1px top margin.
fn warping_footer_height(monospace_font_size: f32, has_secondary_element: bool) -> f32 {
let mut height = STATUS_FOOTER_VERTICAL_PADDING * 2. + monospace_font_size;
if has_secondary_element {
height += (monospace_font_size - 3.) + 1.;
}
height
}
/// Helper function to render text in the "warping..." footer.
/// Additional text that does not use the shimmering text animation can be passed in via
/// `non_shimmering_text` which is useful if you want some part of the text to constantly update
@@ -607,6 +616,10 @@ pub fn render_warping_indicator_base(
is_passive_code_diff,
secondary_element,
} = props;
// Whether a secondary element (an agent tip or fallback-model explanation)
// will be rendered on a second line below the warping text. Captured before
// `secondary_element` is consumed so the container can reserve room for it.
let has_secondary_element = secondary_element.is_some();
// Unicode code point for the Warp glyph that is embedded in the version of Roboto we bundle
// into the app. This code point MUST be rendered using Roboto (the default ui font) or else the
// glyph may not be rendered.
@@ -621,19 +634,6 @@ pub fn render_warping_indicator_base(
let text = render_output_status_text(warping_indicator_text, appearance, app);
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(6.);
if let Some(icon) = icon {
row = row.with_child(
ConstrainedBox::new(icon)
.with_width(icon_size(app) - STATUS_ICON_SIZE_DELTA)
.with_height(icon_size(app) - STATUS_ICON_SIZE_DELTA)
.finish(),
);
}
let text_content = {
let mut row = Flex::row().with_child(Shrinkable::new(1., text).finish());
@@ -692,14 +692,29 @@ pub fn render_warping_indicator_base(
);
}
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(6.);
if let Some(icon) = icon {
row = row.with_child(
ConstrainedBox::new(icon)
.with_width(icon_size(app) - STATUS_ICON_SIZE_DELTA)
.with_height(icon_size(app) - STATUS_ICON_SIZE_DELTA)
.finish(),
);
}
row = row.with_child(Expanded::new(1., text_col.finish()).finish());
if let Some(buttons) = buttons {
row = row.with_child(buttons);
}
let content = Clipped::new(row.finish()).finish();
if is_passive_code_diff {
Container::new(row.finish())
Container::new(content)
// Use custom padding for the passive code diff block
.with_padding_top(8.)
.with_padding_bottom(4.)
@@ -707,8 +722,11 @@ pub fn render_warping_indicator_base(
.finish()
} else {
let mut container = Container::new(
ConstrainedBox::new(row.finish())
.with_height(STATUS_FOOTER_VERTICAL_PADDING * 2. + appearance.monospace_font_size())
ConstrainedBox::new(content)
.with_height(warping_footer_height(
appearance.monospace_font_size(),
has_secondary_element,
))
.finish(),
)
.with_padding_right(CONTENT_HORIZONTAL_PADDING);
@@ -857,6 +875,7 @@ fn render_hide_responses_button(
props.keystroke,
tooltip_text.to_string(),
props.is_active,
false,
|ctx| {
ctx.dispatch_typed_action(BlocklistAIStatusBarAction::ToggleHideResponses);
},
@@ -888,6 +907,7 @@ pub fn render_switch_control_to_user_button(
props.keystroke,
tooltip.to_string(),
props.is_active,
false,
|ctx| {
ctx.dispatch_typed_action(TerminalAction::SetInputModeTerminal);
},
@@ -911,6 +931,7 @@ fn render_stop_button(props: ButtonProps, appearance: &Appearance) -> Box<dyn El
props.keystroke,
"Stop agent task".to_string(),
props.is_active,
false,
|ctx: &mut EventContext<'_>| {
ctx.dispatch_typed_action(BlocklistAIStatusBarAction::Stop);
},
@@ -948,14 +969,21 @@ fn render_queue_next_prompt_button(
props.keystroke,
tooltip_text.to_string(),
props.is_active,
false,
|ctx| {
ctx.dispatch_typed_action(TerminalAction::ToggleQueueNextPrompt);
},
)
}
fn render_auto_approve_button(props: ButtonProps, appearance: &Appearance) -> Box<dyn Element> {
let icon = if props.is_active {
fn render_auto_approve_button(
props: AutoExecuteButtonProps,
appearance: &Appearance,
) -> Box<dyn Element> {
// In locked mode (ambient/cloud agent conversations), the button is always
// rendered in its "on" state regardless of the underlying conversation state.
let is_active = props.is_active || props.is_locked;
let icon = if is_active {
Icon::FastForwardFilled
} else {
Icon::FastForward
@@ -972,7 +1000,9 @@ fn render_auto_approve_button(props: ButtonProps, appearance: &Appearance) -> Bo
)
.finish();
let tooltip_text = if props.is_active {
let tooltip_text = if props.is_locked {
"Fast forward is always enabled for cloud agent conversations"
} else if is_active {
"Turn off auto-approve all agent actions"
} else {
"Auto-approve all agent actions for this task"
@@ -984,8 +1014,12 @@ fn render_auto_approve_button(props: ButtonProps, appearance: &Appearance) -> Bo
icon,
props.keystroke,
tooltip_text.to_string(),
props.is_active,
|ctx| {
is_active,
props.is_locked,
move |ctx| {
if props.is_locked {
return;
}
ctx.dispatch_typed_action(TerminalAction::ToggleAutoexecuteMode);
},
)
@@ -1063,6 +1097,7 @@ fn render_force_refresh_inline(
.finish()
}
#[allow(clippy::too_many_arguments)]
fn render_warping_indicator_button<F>(
mouse_state: MouseStateHandle,
appearance: &Appearance,
@@ -1070,6 +1105,7 @@ fn render_warping_indicator_button<F>(
keybinding: Option<&Keystroke>,
tooltip: String,
is_active: bool,
is_disabled: bool,
mut on_click: F,
) -> Box<dyn Element>
where
@@ -1116,6 +1152,12 @@ where
UiComponentStyles::default().set_background(internal_colors::fg_overlay_3(theme).into()),
);
let cursor = if is_disabled {
Cursor::Arrow
} else {
Cursor::PointingHand
};
let mut button = Button::new(
mouse_state,
styles,
@@ -1125,7 +1167,7 @@ where
)
.with_custom_label(button_content)
.with_tooltip(move || ui_builder.tool_tip(tooltip.clone()).build().finish())
.with_cursor(Some(Cursor::PointingHand));
.with_cursor(Some(cursor));
if is_active {
button = button.active();
@@ -1207,11 +1249,11 @@ pub fn render_text_sections<V: View, A: Action>(
// Carve off a subslice of the pre-allocated handles for just
// this group, and advance the counter so subsequent image
// sections pick up handles from later in the slice.
let handle_start = *props.starting_image_section_index;
let handle_end = (handle_start + image_group.images.len())
.min(props.image_section_tooltip_handles.len());
let handles_for_group =
&props.image_section_tooltip_handles[handle_start..handle_end];
let handles_for_group = image_tooltip_handles_for_group(
props.image_section_tooltip_handles,
*props.starting_image_section_index,
image_group.images.len(),
);
*props.starting_image_section_index += image_group.images.len();
let render_context = ImageRenderContext {
detected_links: props.detected_links,
@@ -1313,6 +1355,7 @@ pub fn render_text_sections<V: View, A: Action>(
working_directory: props.current_working_directory,
open_code_block_action_factory: props.open_code_block_action_factory,
copy_code_action_factory: props.copy_code_action_factory,
selectable: props.selectable,
#[cfg(feature = "local_fs")]
resolved_code_block_paths: props.resolved_code_block_paths,
},
@@ -1386,6 +1429,17 @@ fn text_sections_with_indices(
.map(move |(offset, section)| (starting_text_section_index + offset, section))
}
fn image_tooltip_handles_for_group(
tooltip_handles: &[MouseStateHandle],
starting_image_section_index: usize,
image_group_len: usize,
) -> &[MouseStateHandle] {
let available_handles = tooltip_handles
.get(starting_image_section_index..)
.unwrap_or(&[]);
&available_handles[..available_handles.len().min(image_group_len)]
}
#[derive(Clone, Copy)]
struct IndexedImageSection<'a> {
section_index: usize,
@@ -1798,12 +1852,26 @@ enum VisualMarkdownAlignment {
Center,
}
/// How a visual markdown block (image or mermaid diagram) is sized.
#[derive(Clone, Copy, Debug)]
enum VisualMarkdownSizing {
/// Fixed element height. Width is fixed when `width` is set, otherwise
/// bounded by `max_width`, otherwise falls back to a square of `height`.
FixedHeight {
height: f32,
width: Option<f32>,
max_width: Option<f32>,
},
/// Fill the available width, deriving the element height from the
/// image's intrinsic aspect ratio, capped at `max_height`. When the cap
/// applies, the width shrinks proportionally instead of letterboxing.
FitWidth { max_height: f32 },
}
#[derive(Clone)]
struct VisualMarkdownBlockOptions<A: 'static> {
height: f32,
width: Option<f32>,
sizing: VisualMarkdownSizing,
copy_action_factory: Option<CopyCodeActionFactory<A>>,
max_width: Option<f32>,
alignment: VisualMarkdownAlignment,
lightbox_trigger: Option<VisualMarkdownLightboxTrigger>,
/// When `Some(non_empty)`, the rendered image is wrapped in the standard
@@ -1879,6 +1947,9 @@ fn load_renderable_image_asset(
#[cfg(not(feature = "local_fs"))]
let asset_source = blocklist_image_asset_source(&image.source, current_working_directory)?;
if !should_load_blocklist_image_asset(&asset_source) {
return None;
}
let asset_state = AssetCache::as_ref(app).load_asset::<ImageType>(asset_source.clone());
if matches!(asset_state, AssetState::FailedToLoad(_)) {
return None;
@@ -1905,6 +1976,16 @@ fn can_render_blocklist_image(
.is_some()
}
#[cfg(target_arch = "wasm32")]
fn should_load_blocklist_image_asset(asset_source: &AssetSource) -> bool {
!matches!(asset_source, AssetSource::LocalFile { .. })
}
#[cfg(not(target_arch = "wasm32"))]
fn should_load_blocklist_image_asset(_: &AssetSource) -> bool {
true
}
fn render_image_section<A: Action>(
props: ImageSectionProps<'_, A>,
app: &AppContext,
@@ -2001,10 +2082,12 @@ fn render_inline_image_group_item<A: Action>(
asset_source,
indexed_image.image.markdown_source.clone(),
VisualMarkdownBlockOptions {
height: INLINE_IMAGE_HEIGHT,
width: Some(width),
sizing: VisualMarkdownSizing::FixedHeight {
height: INLINE_IMAGE_HEIGHT,
width: Some(width),
max_width: None,
},
copy_action_factory: render_context.copy_action_factory,
max_width: None,
alignment: VisualMarkdownAlignment::Left,
lightbox_trigger: lightbox_trigger_for_section(
render_context.lightbox_collection,
@@ -2094,10 +2177,12 @@ fn render_block_image_group_row<A: Action>(
asset_source,
indexed_image.image.markdown_source.clone(),
VisualMarkdownBlockOptions {
height: BLOCK_IMAGE_THUMBNAIL_SIZE,
width: Some(BLOCK_IMAGE_THUMBNAIL_SIZE),
sizing: VisualMarkdownSizing::FixedHeight {
height: BLOCK_IMAGE_THUMBNAIL_SIZE,
width: Some(BLOCK_IMAGE_THUMBNAIL_SIZE),
max_width: None,
},
copy_action_factory: render_context.copy_action_factory,
max_width: None,
alignment: VisualMarkdownAlignment::Center,
lightbox_trigger: lightbox_trigger_for_section(
render_context.lightbox_collection,
@@ -2158,14 +2243,23 @@ fn render_mermaid_diagram_section<A: Action>(
}
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let sizing = if matches!(asset_state, AssetState::Loaded { .. }) {
VisualMarkdownSizing::FitWidth {
max_height: mermaid_section_max_height(app),
}
} else {
VisualMarkdownSizing::FixedHeight {
height: visual_section_height(app),
width: None,
max_width: None,
}
};
let mermaid_block = render_visual_markdown_block(
asset_source,
diagram.markdown_source.clone(),
VisualMarkdownBlockOptions {
height: visual_section_height(app),
width: None,
sizing,
copy_action_factory,
max_width: visual_section_max_width(&asset_state, visual_section_height(app)),
alignment: VisualMarkdownAlignment::Center,
lightbox_trigger: lightbox_trigger_for_section(lightbox_collection, section_index),
// Mermaid diagrams don't carry CommonMark image titles.
@@ -2226,15 +2320,30 @@ fn render_visual_markdown_block<A: Action>(
.contain()
.before_load(placeholder)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)));
let mut content = ConstrainedBox::new(Box::new(image)).with_height(options.height);
if let Some(width) = finite_positive_visual_size(options.width) {
content = content.with_width(width);
} else if let Some(max_width) = finite_positive_visual_size(options.max_width) {
content = content.with_max_width(max_width);
} else if let Some(fallback_width) = finite_positive_visual_size(Some(options.height)) {
content = content.with_width(fallback_width);
}
let content = content.finish();
let content = match options.sizing {
VisualMarkdownSizing::FixedHeight {
height,
width,
max_width,
} => {
let mut content = ConstrainedBox::new(Box::new(image)).with_height(height);
if let Some(width) = finite_positive_visual_size(width) {
content = content.with_width(width);
} else if let Some(max_width) = finite_positive_visual_size(max_width) {
content = content.with_max_width(max_width);
} else if let Some(fallback_width) = finite_positive_visual_size(Some(height)) {
content = content.with_width(fallback_width);
}
content.finish()
}
VisualMarkdownSizing::FitWidth { max_height } => {
let mut content = ConstrainedBox::new(Box::new(image.layout_using_paint_bounds()));
if let Some(max_height) = finite_positive_visual_size(Some(max_height)) {
content = content.with_max_height(max_height);
}
content.finish()
}
};
let content = match options.alignment {
VisualMarkdownAlignment::Left => Align::new(content).left().finish(),
VisualMarkdownAlignment::Center => Align::new(content).finish(),
@@ -2384,17 +2493,24 @@ fn is_supported_blocklist_image_source(source: &str) -> bool {
.map(|ext| {
matches!(
ext.to_ascii_lowercase().as_str(),
"jpg" | "jpeg" | "png" | "gif" | "webp" | "svg"
"jpg" | "jpeg" | "png" | "gif" | "bmp" | "tiff" | "tif" | "webp" | "ico" | "svg"
)
})
.unwrap_or(false)
}
fn visual_section_height(app: &AppContext) -> f32 {
blocklist_base_line_height(app) * BLOCKLIST_VISUAL_SECTION_HEIGHT_LINE_MULTIPLIER
}
fn mermaid_section_max_height(app: &AppContext) -> f32 {
blocklist_base_line_height(app) * BLOCKLIST_MERMAID_MAX_HEIGHT_LINE_MULTIPLIER
}
fn blocklist_base_line_height(app: &AppContext) -> f32 {
rich_text_styles(Appearance::as_ref(app), FontSettings::as_ref(app))
.base_line_height()
.as_f32()
* BLOCKLIST_VISUAL_SECTION_HEIGHT_LINE_MULTIPLIER
}
const TABLE_BLOCK_CORNER_RADIUS: f32 = 8.0;
@@ -2724,6 +2840,8 @@ pub struct CodeSectionProps<'a, A: 'static> {
pub working_directory: Option<&'a String>,
pub open_code_block_action_factory: Option<OpenCodeBlockActionFactory<A>>,
pub copy_code_action_factory: Option<CopyCodeActionFactory<A>>,
/// Whether the code block text should be selectable within the parent SelectableArea.
pub selectable: bool,
/// Pre-resolved code block file paths from the background detection task.
/// Keyed by original path; value is the resolved absolute path (or None if unresolvable).
#[cfg(feature = "local_fs")]
@@ -2925,6 +3043,7 @@ pub fn render_code_output_section<A: Action>(
footer_element: language_text,
mouse_handles: props.button_handles.cloned(),
},
props.selectable,
app,
source,
)
@@ -2939,12 +3058,8 @@ pub fn get_highlight_ranges_for_find_matches(
) -> impl Iterator<Item = HighlightedRange> {
let find_match_locations = find_state.matches_for_location(location);
let focused_match_location = find_model
.block_list_find_run()
.and_then(|run| match run.focused_match() {
Some(BlockListMatch::RichContent { match_id, .. }) => Some(match_id),
_ => None,
})
.and_then(|match_id| find_state.location_for_match(*match_id));
.focused_rich_content_match_id()
.and_then(|match_id| find_state.location_for_match(match_id));
let mut highlighted_ranges = vec![];
for find_match_location in find_match_locations {
let is_focused_match =
@@ -3019,17 +3134,33 @@ pub struct FailedOutputProps<'a> {
pub fn render_failed_output(props: FailedOutputProps, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let error_text = match props.error {
RenderableAIError::QuotaLimit => {
let ai_request_usage_model = AIRequestUsageModel::as_ref(app);
let formatted_next_refresh_time = ai_request_usage_model
.next_refresh_time()
.format("%B %d")
.to_string();
// While an automatic retry/resume is still in flight, don't surface the underlying
// transport failure at all. These are typically transient and recover on their own,
// so showing the alarming "Warp lost connection" banner (plus debug info) for every
// blip is noisy and misleading. Render nothing during in-flight recovery; the full
// error banner is only shown once recovery has actually failed. Dogfood builds
// (Local/Dev) opt out so developers still see every transport failure aggressively.
if props.error.should_suppress_during_recovery() {
return Empty::new().finish();
}
format!(
"{ERROR_APOLOGY_TEXT}\n\nYou've reached your credit limit. Your credit limit resets on {formatted_next_refresh_time}.",
)
let error_text = match props.error {
RenderableAIError::QuotaLimit {
user_display_message,
} => {
if let Some(message) = user_display_message {
format!("{ERROR_APOLOGY_TEXT}\n\n{message}")
} else {
let ai_request_usage_model = AIRequestUsageModel::as_ref(app);
let formatted_next_refresh_time = ai_request_usage_model
.next_refresh_time()
.format("%B %d")
.to_string();
format!(
"{ERROR_APOLOGY_TEXT}\n\nYou've reached your credit limit. Your credit limit resets on {formatted_next_refresh_time}.",
)
}
}
RenderableAIError::ServerOverloaded => {
"Warp is currently overloaded. Please try again later.".to_string()
@@ -3037,22 +3168,19 @@ pub fn render_failed_output(props: FailedOutputProps, app: &AppContext) -> Box<d
RenderableAIError::InternalWarpError => {
format!("{ERROR_APOLOGY_TEXT}\n\n{INTERNAL_WARP_ERROR}")
}
RenderableAIError::Other {
error_message,
will_attempt_resume,
waiting_for_network,
} => {
if *will_attempt_resume {
if *waiting_for_network {
format!(
"{error_message}\n\nWill resume conversation when network connectivity is restored..."
)
} else {
format!("{error_message}\n\nAttempting to resume conversation...")
}
} else {
format!("{ERROR_APOLOGY_TEXT}\n\n{error_message}")
}
RenderableAIError::Other { error_message, .. } => {
// A still-recovering `Other` error is handled by the early return above; once we
// reach here recovery has failed, so surface the error directly.
format!("{ERROR_APOLOGY_TEXT}\n\n{error_message}")
}
RenderableAIError::AgentExitedShell => {
format!("{ERROR_APOLOGY_TEXT}\n\n{}", props.error)
}
RenderableAIError::TransientNetworkError { .. } => {
// Recovering transient errors are handled by the early return above; once we
// reach here recovery has failed. These carry their own complete user-facing
// copy (plus debug info), so the apology prefix adds nothing.
props.error.to_string()
}
RenderableAIError::InvalidApiKey {
provider,
@@ -3483,7 +3611,6 @@ const USER_AVATAR_PALETTE: &[ColorU] = &[
];
fn session_avatar_color() -> ColorU {
use rand::Rng;
use std::sync::OnceLock;
static COLOR: OnceLock<ColorU> = OnceLock::new();
*COLOR.get_or_init(|| {
@@ -3528,13 +3655,28 @@ pub struct UserQueryProps<'a> {
pub find_context: Option<FindContext<'a>>,
pub font_properties: &'a Properties,
}
pub(crate) fn user_query_mode_prefix_highlight_len(mode: UserQueryMode) -> Option<usize> {
match mode {
UserQueryMode::Normal => None,
UserQueryMode::Plan => Some(commands::PLAN.name.len()),
UserQueryMode::Orchestrate => Some(commands::ORCHESTRATE.name.len()),
}
}
pub(super) fn query_prefix_highlight_len(
input: &AIAgentInput,
displayed_query: &str,
) -> Option<usize> {
if displayed_query.starts_with(commands::PLAN.name) {
Some(commands::PLAN.name.len())
} else if displayed_query.starts_with(commands::CREATE_ENVIRONMENT.name) {
if let AIAgentInput::UserQuery {
user_query_mode, ..
} = input
{
if let Some(prefix_len) = user_query_mode_prefix_highlight_len(*user_query_mode) {
return Some(prefix_len);
}
}
if displayed_query.starts_with(commands::CREATE_ENVIRONMENT.name) {
Some(commands::CREATE_ENVIRONMENT.name.len())
} else if displayed_query.starts_with(commands::AGENT.name) {
Some(commands::AGENT.name.len())
@@ -3558,7 +3700,8 @@ pub(super) fn query_prefix_highlight_len(
| AIAgentInput::ActionResult { .. }
| AIAgentInput::MessagesReceivedFromAgents { .. }
| AIAgentInput::EventsFromAgents { .. }
| AIAgentInput::PassiveSuggestionResult { .. } => None,
| AIAgentInput::PassiveSuggestionResult { .. }
| AIAgentInput::OrchestrationConfigUpdate { .. } => None,
}
}
}
@@ -1,36 +1,38 @@
use itertools::Itertools;
use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use ai::skills::{ParsedSkill, SkillProvider, SkillScope};
use itertools::Itertools;
use ui_components::lightbox::{LightboxImage, LightboxImageSource};
use warp_util::local_or_remote_path::LocalOrRemotePath;
#[cfg(feature = "local_fs")]
use galaxyui::assets::asset_cache::AssetSource;
use galaxyui::App;
use galaxyui::elements::{Empty, MouseStateHandle};
use galaxyui::{App, Element};
#[cfg(feature = "local_fs")]
use super::{blocklist_image_asset_source, ResolvedBlocklistImageSources};
use super::{
collect_visual_markdown_lightbox_collection, compute_visual_section_width,
format_elapsed_compact, inline_image_source_label, lightbox_trigger_for_section,
query_prefix_highlight_len, render_scrollable_collapsible_content, text_sections_with_indices,
image_tooltip_handles_for_group, inline_image_source_label,
is_supported_blocklist_image_source, lightbox_trigger_for_section, query_prefix_highlight_len,
render_scrollable_collapsible_content, text_sections_with_indices, warping_footer_height,
CollapsibleElementState, CollapsibleExpansionState, VisualMarkdownLightboxCollection,
};
use crate::{
ai::agent::{
AIAgentInput, AIAgentTextSection, AgentOutputImage, AgentOutputImageLayout,
AgentOutputMermaidDiagram, MessageId, UserQueryMode,
},
features::FeatureFlag,
search::slash_command_menu::static_commands::commands,
use crate::ai::agent::{
AIAgentInput, AIAgentTextSection, AgentOutputImage, AgentOutputImageLayout,
AgentOutputMermaidDiagram, MessageId, UserQueryMode,
};
use galaxyui::{elements::Empty, Element};
use ui_components::lightbox::{LightboxImage, LightboxImageSource};
use crate::features::FeatureFlag;
use crate::search::slash_command_menu::static_commands::commands;
#[test]
fn query_prefix_highlight_len_highlights_invoke_skill_inputs() {
let input = AIAgentInput::InvokeSkill {
context: Arc::new([]),
skill: ParsedSkill {
path: PathBuf::from("/tmp/.agents/skills/review-pr/SKILL.md"),
path: LocalOrRemotePath::Local(PathBuf::from("/tmp/.agents/skills/review-pr/SKILL.md")),
name: "review-pr".to_string(),
description: "Review a pull request.".to_string(),
content: String::new(),
@@ -126,6 +128,17 @@ fn text_sections_with_indices_preserve_image_section_alignment_after_empty_text_
assert_eq!(rendered_image_indices, vec![2, 4]);
}
#[test]
fn image_tooltip_handles_for_group_uses_available_handles_only() {
let handles = [MouseStateHandle::default(), MouseStateHandle::default()];
assert_eq!(image_tooltip_handles_for_group(&handles, 0, 1).len(), 1);
assert_eq!(image_tooltip_handles_for_group(&handles, 1, 4).len(), 1);
assert_eq!(image_tooltip_handles_for_group(&handles, 2, 1).len(), 0);
assert_eq!(image_tooltip_handles_for_group(&handles, 3, 1).len(), 0);
assert_eq!(image_tooltip_handles_for_group(&[], 1, 1).len(), 0);
}
#[test]
fn render_scrollable_collapsible_content_returns_none_when_collapsed() {
let state = CollapsibleElementState {
@@ -148,6 +161,26 @@ fn render_scrollable_collapsible_content_returns_none_when_collapsed() {
);
}
#[test]
fn warping_footer_height_reserves_a_line_for_the_secondary_element() {
// Regression: the warping indicator's footer is a fixed-height, clipped
// container. When an agent tip (or fallback-model explanation) is present it
// renders on a second line, so the footer must be taller than the
// single-line case — otherwise the clip (added to keep action chips from
// overflowing narrow panes) hides the tip entirely.
let font_size = 13.;
let without_tip = warping_footer_height(font_size, false);
let with_tip = warping_footer_height(font_size, true);
assert!(
with_tip > without_tip,
"footer with a secondary element ({with_tip}) should be taller than without ({without_tip})",
);
// The extra room must cover the secondary line: its font size
// (monospace_font_size - 3) plus the 1px top margin on the tip container.
assert_eq!(with_tip - without_tip, (font_size - 3.) + 1.);
}
#[test]
fn compute_visual_section_width_rejects_non_finite_dimensions() {
assert_eq!(compute_visual_section_width(f32::INFINITY, 20., 40.), None);
@@ -278,6 +311,7 @@ fn blocklist_image_asset_source_uses_cached_resolution_when_available() {
"diagram.png".to_string(),
Some(AssetSource::LocalFile {
path: cached_path.clone(),
content_version: None,
}),
)]);
@@ -288,34 +322,47 @@ fn blocklist_image_asset_source_uses_cached_resolution_when_available() {
);
match resolved {
Some(AssetSource::LocalFile { path }) => assert_eq!(path, cached_path),
Some(AssetSource::LocalFile { path, .. }) => assert_eq!(path, cached_path),
other => panic!("expected cached local file asset source, got {other:?}"),
}
}
/// `is_supported_blocklist_image_source` should accept the same image extensions
/// that `galaxy_util::file_type::is_binary_file` recognises (plus `svg`, which is
/// text/XML and not in `is_binary_file`). Until #9395 / this fix landed the
/// blocklist list was only `jpg | jpeg | png | gif | webp | svg`, so inline
/// references to local `.bmp` / `.tiff` / `.tif` / `.ico` images failed the
/// support check and silently rendered as plain text.
#[test]
fn format_elapsed_compact_shows_seconds_under_one_minute() {
assert_eq!(format_elapsed_compact(Duration::from_secs(0)), "0s");
assert_eq!(format_elapsed_compact(Duration::from_secs(1)), "1s");
assert_eq!(format_elapsed_compact(Duration::from_secs(42)), "42s");
assert_eq!(format_elapsed_compact(Duration::from_secs(59)), "59s");
}
#[test]
fn format_elapsed_compact_shows_minutes_under_120_minutes() {
assert_eq!(format_elapsed_compact(Duration::from_secs(60)), "1m");
assert_eq!(format_elapsed_compact(Duration::from_secs(90)), "1m");
assert_eq!(format_elapsed_compact(Duration::from_secs(120)), "2m");
assert_eq!(format_elapsed_compact(Duration::from_secs(45 * 60)), "45m");
assert_eq!(
format_elapsed_compact(Duration::from_secs(119 * 60 + 59)),
"119m"
);
}
#[test]
fn format_elapsed_compact_shows_hours_at_120_minutes_and_above() {
assert_eq!(format_elapsed_compact(Duration::from_secs(120 * 60)), "2h");
assert_eq!(format_elapsed_compact(Duration::from_secs(180 * 60)), "3h");
assert_eq!(format_elapsed_compact(Duration::from_secs(600 * 60)), "10h");
fn is_supported_blocklist_image_source_covers_common_local_formats() {
for source in [
"diagram.jpg",
"diagram.jpeg",
"diagram.png",
"diagram.gif",
"diagram.bmp",
"diagram.tiff",
"diagram.tif",
"diagram.webp",
"diagram.ico",
"diagram.svg",
] {
assert!(
is_supported_blocklist_image_source(source),
"{source} should be a supported local image source"
);
}
// Case-insensitive on the extension.
assert!(is_supported_blocklist_image_source("PHOTO.PNG"));
assert!(is_supported_blocklist_image_source("scan.TIFF"));
// HTTP / HTTPS sources are intentionally rejected regardless of extension.
assert!(!is_supported_blocklist_image_source(
"http://example.com/x.png"
));
assert!(!is_supported_blocklist_image_source(
"https://example.com/x.png"
));
// Non-image extensions stay rejected.
assert!(!is_supported_blocklist_image_source("doc.pdf"));
assert!(!is_supported_blocklist_image_source("notes.md"));
}
+6 -11
View File
@@ -2,17 +2,13 @@
//! when the query was submitted.
use galaxy_core::features::FeatureFlag;
use galaxy_util::path::user_friendly_path;
use galaxyui::elements::MouseStateHandle;
use galaxyui::elements::{ChildView, Hoverable, SavePosition};
use galaxyui::platform::Cursor;
use galaxyui::EntityId;
use galaxyui::{
elements::{
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, MainAxisAlignment,
MainAxisSize, ParentElement, Radius, Text,
},
AppContext, Element, SingletonEntity, ViewHandle,
use galaxyui::elements::{
ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, Hoverable,
Icon as ElementIcon, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius,
SavePosition, Text,
};
use warpui::platform::Cursor;
use warpui::{AppContext, Element, EntityId, SingletonEntity, ViewHandle};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::AIAgentExchangeId;
@@ -26,7 +22,6 @@ use crate::terminal::view::{TerminalAction, WARP_PROMPT_HEIGHT_LINES};
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::ActionButton;
use galaxyui::elements::Icon as ElementIcon;
/// Data required to render the AI block header.
pub(super) struct Props<'a> {
@@ -1,8 +1,9 @@
use crate::ai::blocklist::block::ImportedCommentGroup;
use galaxyui::elements::{CrossAxisAlignment, Flex, ParentElement};
use galaxyui::prelude::ChildView;
use galaxyui::{AppContext, Element};
use crate::ai::blocklist::block::ImportedCommentGroup;
pub(crate) fn render_imported_comments(
group: &ImportedCommentGroup,
app: &AppContext,
@@ -1,6 +1,7 @@
use std::collections::{HashMap, HashSet};
use crate::ai::blocklist::block::CommentElementState;
use crate::code_review::comments::CommentId;
use std::collections::{HashMap, HashSet};
#[derive(Copy, Clone)]
pub(super) struct Props<'a> {
@@ -1,27 +1,34 @@
//! Rendering functions for orchestration-related output items (messaging & agent management).
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use pathfinder_color::ColorU;
use galaxyui::elements::{
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex, Hoverable,
MouseStateHandle, ParentElement, Radius, Text,
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex, FormattedTextElement,
Hoverable, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
};
use galaxyui::platform::Cursor;
use galaxyui::{AppContext, Element, SingletonEntity};
use pathfinder_color::ColorU;
use galaxyui::elements::FormattedTextElement;
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
use super::common::render_scrollable_collapsible_content;
use super::output::{action_icon, Props};
use super::WithContentItemSpacing;
use crate::ai::agent::conversation::{
AIConversation, AIConversationId, ConversationStatus, StatusColorStyle,
};
use crate::ai::agent::{
AIAgentActionId, AIAgentActionResultType, MessageId, ReceivedMessageDisplay,
SendMessageToAgentResult, StartAgentExecutionMode, StartAgentResult,
};
use crate::ai::blocklist::action_model::AIActionStatus;
use crate::ai::blocklist::agent_view::orchestration_avatar::OrchestrationAvatar;
use crate::ai::blocklist::agent_view::orchestration_conversation_links::{
conversation_id_for_agent_id, conversation_navigation_card_with_icon,
dispatch_focus_or_open_child_agent_pane,
};
use crate::ai::blocklist::block::model::AIBlockModelHelper;
use crate::ai::blocklist::block::{AIBlockAction, CollapsibleExpansionState};
use crate::ai::blocklist::block::{
received_message_collapsible_id, AIBlockAction, CollapsibleExpansionState,
};
use crate::ai::blocklist::inline_action::inline_action_header::{
ICON_MARGIN, INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
};
@@ -35,31 +42,92 @@ use crate::terminal::view::TerminalAction;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use super::common::render_scrollable_collapsible_content;
use super::output::{action_icon, Props};
use super::WithContentItemSpacing;
const GENERATING_TITLE_PLACEHOLDER: &str = "Generating title...";
const ORCHESTRATION_COLLAPSED_MAX_HEIGHT: f32 = 200.;
#[derive(Clone, Debug, PartialEq, Eq)]
struct OrchestrationParticipant {
display_name: String,
avatar: OrchestrationAvatar,
/// The participant's conversation, when resolved. `None` for the
/// orchestrator and unknown agents (avatar stays non-clickable).
conversation_id: Option<AIConversationId>,
}
impl OrchestrationParticipant {
fn orchestrator() -> Self {
Self {
display_name: "Orchestrator".to_string(),
avatar: OrchestrationAvatar::Orchestrator,
conversation_id: None,
}
}
fn unknown_child() -> Self {
Self {
display_name: "Unknown agent".to_string(),
avatar: OrchestrationAvatar::agent("Unknown agent".to_string()),
conversation_id: None,
}
}
fn is_orchestrator(&self) -> bool {
matches!(&self.avatar, OrchestrationAvatar::Orchestrator)
}
}
#[cfg(test)]
fn agent_display_name_from_id(
agent_id: &str,
orchestrator_agent_id: Option<&str>,
app: &AppContext,
) -> String {
if orchestrator_agent_id.is_some_and(|id| id == agent_id) {
return "Orchestrator agent".to_string();
}
participant_for_agent_id(agent_id, orchestrator_agent_id, app).display_name
}
fn participant_for_agent_id(
agent_id: &str,
orchestrator_agent_id: Option<&str>,
app: &AppContext,
) -> OrchestrationParticipant {
if let Some(conversation_id) = conversation_id_for_agent_id(agent_id, app) {
if let Some(conversation) =
BlocklistAIHistoryModel::as_ref(app).conversation(&conversation_id)
{
if let Some(agent_name) = conversation.agent_name() {
return agent_name.to_string();
}
return participant_for_conversation(
conversation,
orchestrator_agent_id,
Some(agent_id),
);
}
}
"Unknown agent".to_string()
if orchestrator_agent_id.is_some_and(|id| id == agent_id) {
return OrchestrationParticipant::orchestrator();
}
OrchestrationParticipant::unknown_child()
}
fn participant_for_conversation(
conversation: &AIConversation,
orchestrator_agent_id: Option<&str>,
agent_id: Option<&str>,
) -> OrchestrationParticipant {
let is_orchestrator = agent_id
.map(|id| {
orchestrator_agent_id.is_some_and(|orchestrator_id| id == orchestrator_id)
|| (orchestrator_agent_id.is_none()
&& conversation.parent_conversation_id().is_none())
})
.unwrap_or_else(|| conversation.parent_conversation_id().is_none());
if is_orchestrator {
return OrchestrationParticipant::orchestrator();
}
let display_name = conversation.agent_name().unwrap_or("Agent").to_string();
OrchestrationParticipant {
display_name: display_name.clone(),
avatar: OrchestrationAvatar::agent(display_name),
conversation_id: Some(conversation.id()),
}
}
fn orchestrator_agent_id_for_conversation(
@@ -74,118 +142,269 @@ fn orchestrator_agent_id_for_conversation(
}
}
fn render_message_fields(
fields: &[(&str, &str)],
body: &str,
fn participant_for_current_conversation(
props: Props,
orchestrator_agent_id: Option<&str>,
app: &AppContext,
) -> OrchestrationParticipant {
props
.model
.conversation(app)
.map(|conversation| {
participant_for_conversation(
conversation,
orchestrator_agent_id,
conversation.orchestration_agent_id().as_deref(),
)
})
.unwrap_or_else(OrchestrationParticipant::orchestrator)
}
fn transcript_metadata(recipients: &[OrchestrationParticipant], subject: &str) -> Option<String> {
let recipients = recipients
.iter()
.filter(|participant| !participant.is_orchestrator())
.map(|participant| participant.display_name.as_str())
.collect::<Vec<_>>()
.join(", ");
match (recipients.is_empty(), subject.is_empty()) {
(true, true) => None,
(true, false) => Some(subject.to_string()),
(false, true) => Some(format!("to {recipients}")),
(false, false) => Some(format!("to {recipients} • {subject}")),
}
}
struct TranscriptRowData<'a> {
participant: &'a OrchestrationParticipant,
recipients: &'a [OrchestrationParticipant],
subject: &'a str,
body: &'a str,
message_id: &'a MessageId,
is_streaming: bool,
}
fn render_transcript_row(
data: TranscriptRowData<'_>,
props: Props,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let font_family = appearance.ui_font_family();
let font_size = appearance.monospace_font_size();
let label_color = blended_colors::text_disabled(theme, theme.surface_2());
let value_color: ColorU = theme.main_text_color(theme.background()).into();
let metadata_color = blended_colors::text_disabled(theme, theme.surface_2());
let body_color: ColorU = theme.main_text_color(theme.background()).into();
let collapsible_state = if data.body.is_empty() {
None
} else {
props.collapsible_block_states.get(data.message_id)
};
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
for (label, value) in fields {
let line = Flex::row()
.with_child(
Text::new(label.to_string(), font_family, font_size)
.with_color(label_color)
.finish(),
)
.with_child(
Text::new(value.to_string(), font_family, font_size)
.with_color(value_color)
.finish(),
)
let name = FormattedTextFragment::bold(&data.participant.display_name);
let header_row_element: Box<dyn Element> = if let Some(state) = collapsible_state {
// Wrap the name + chevron in one clickable element so either toggles
// the section. Text is non-selectable + non-interactive so clicks
// register and the pointing-hand cursor isn't reset by the text.
let header = render_formatted_text_element(vec![name], app)
.set_selectable(false)
.disable_mouse_interaction()
.finish();
column.add_child(line);
}
let text_color = theme.foreground();
let icon_sz = icon_size(app);
let is_expanded = matches!(
state.expansion_state,
CollapsibleExpansionState::Expanded { .. }
);
let chevron_icon = if is_expanded {
Icon::ChevronDown
} else {
Icon::ChevronRight
};
let toggle_mouse_state = state.expansion_toggle_mouse_state.clone();
let message_id_clone = data.message_id.clone();
if !body.is_empty() {
column.add_child(
let expandable = Hoverable::new(toggle_mouse_state, move |_| {
// Make the bold name a Shrinkable child so very long agent names
// shrink within the available width instead of pushing the chevron
// past the transcript column.
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Shrinkable::new(1., header).finish())
.with_child(
Container::new(
ConstrainedBox::new(chevron_icon.to_warpui_icon(text_color).finish())
.with_width(icon_sz)
.with_height(icon_sz)
.finish(),
)
.with_margin_left(6.)
.finish(),
)
.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(AIBlockAction::ToggleCollapsibleBlockExpanded(
message_id_clone.clone(),
));
});
// Wrap the Hoverable in a Shrinkable inside an outer row so it
// receives a bounded width constraint from the parent column. This
// lets the inner Shrinkable around the bold name actually shrink
// when the name is long, while the Hoverable's click bounds still
// size to its content (bold name + chevron) when it fits.
Flex::row()
.with_child(Shrinkable::new(1., expandable.finish()).finish())
.finish()
} else {
let header = render_formatted_text_element(vec![name], app).finish();
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Shrinkable::new(1., header).finish())
.finish()
};
let mut content = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
content.add_child(header_row_element);
if let Some(metadata) = transcript_metadata(data.recipients, data.subject) {
content.add_child(
Container::new(
Text::new(body.to_string(), font_family, font_size)
.with_color(value_color)
Text::new(metadata, font_family, font_size)
.with_color(metadata_color)
.with_selectable(true)
.finish(),
)
.with_margin_top(4.)
.with_margin_top(2.)
.finish(),
);
}
if !data.body.is_empty() {
let body_element = Container::new(
Text::new(data.body.to_string(), font_family, font_size)
.with_color(body_color)
.with_selectable(true)
.finish(),
)
.with_margin_top(8.)
.finish();
if let Some(body) =
render_collapsible_body(data.message_id, body_element, data.is_streaming, props)
{
content.add_child(body);
}
}
column.finish()
let avatar = data.participant.avatar.render(app);
let avatar_element: Box<dyn Element> = if let (Some(conversation_id), Some(mouse_state)) = (
data.participant.conversation_id,
props
.state_handles
.transcript_avatar_handles
.get(data.message_id),
) {
// Navigate to the child's pane: focus if already open, otherwise
// open a new pane.
let mouse_state = mouse_state.clone();
let self_terminal_view_id = props.terminal_view_id;
Hoverable::new(mouse_state, move |_| avatar)
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, app, _| {
dispatch_focus_or_open_child_agent_pane(
conversation_id,
self_terminal_view_id,
ctx,
app,
);
})
.finish()
} else {
avatar
};
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(
Container::new(avatar_element)
.with_margin_right(12.)
.finish(),
)
.with_child(Shrinkable::new(1., content.finish()).finish())
.finish()
}
pub(super) fn render_messages_received_from_agents(
messages: &[ReceivedMessageDisplay],
props: Props,
message_id: &MessageId,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let status_icon = inline_action_icons::green_check_icon(appearance).finish();
let chevron = render_collapse_chevron(message_id, props, app);
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
// Header row with icon and collapse chevron
let header = render_requested_action_row_for_text(
format!("Messages received ({})", messages.len()).into(),
appearance.ui_font_family(),
Some(status_icon),
chevron,
false,
false,
app,
);
column.add_child(header);
if messages.is_empty() {
return Empty::new().finish();
}
let orchestrator_agent_id = props
.model
.conversation(app)
.and_then(|conversation| orchestrator_agent_id_for_conversation(conversation, app));
// Collect all messages into a single collapsible body.
let mut messages_column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
for msg in messages {
let sender_name =
agent_display_name_from_id(&msg.sender_agent_id, orchestrator_agent_id.as_deref(), app);
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
for (index, msg) in messages.iter().enumerate() {
let sender =
participant_for_agent_id(&msg.sender_agent_id, orchestrator_agent_id.as_deref(), app);
let recipients = msg
.addresses
.iter()
.map(|agent_id| {
agent_display_name_from_id(agent_id, orchestrator_agent_id.as_deref(), app)
participant_for_agent_id(agent_id, orchestrator_agent_id.as_deref(), app)
})
.collect::<Vec<_>>()
.join(", ");
let fields = [
("From: ", sender_name.as_str()),
("To: ", recipients.as_str()),
("Subject: ", msg.subject.as_str()),
];
let message_block = Container::new(render_message_fields(&fields, &msg.message_body, app))
.with_margin_top(8.)
.with_margin_left(8.)
.finish();
messages_column.add_child(message_block);
.collect::<Vec<_>>();
let row_message_id = received_message_collapsible_id(&msg.message_id);
let row = render_transcript_row(
TranscriptRowData {
participant: &sender,
recipients: &recipients,
subject: &msg.subject,
body: &msg.message_body,
message_id: &row_message_id,
is_streaming: false,
},
props,
app,
);
let mut row_container = Container::new(row);
if index > 0 {
row_container = row_container.with_margin_top(12.);
}
column.add_child(row_container.finish());
}
if let Some(body) = render_collapsible_body(message_id, messages_column.finish(), false, props)
{
column.add_child(body);
}
column.finish().with_agent_output_item_spacing(app).finish()
}
Container::new(column.finish())
.with_horizontal_padding(8.)
.with_vertical_padding(8.)
.with_background_color(blended_colors::neutral_2(theme))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.finish()
fn participant_display_names(participants: &[OrchestrationParticipant]) -> String {
participants
.iter()
.map(|participant| participant.display_name.as_str())
.collect::<Vec<_>>()
.join(", ")
}
fn participant_for_agent_ids(
agent_ids: &[String],
orchestrator_agent_id: Option<&str>,
app: &AppContext,
) -> Vec<OrchestrationParticipant> {
agent_ids
.iter()
.map(|agent_id| participant_for_agent_id(agent_id, orchestrator_agent_id, app))
.collect()
}
fn render_transcript_row_with_spacing(
data: TranscriptRowData<'_>,
props: Props,
app: &AppContext,
) -> Box<dyn Element> {
render_transcript_row(data, props, app)
.with_agent_output_item_spacing(app)
.finish()
}
@@ -206,11 +425,9 @@ pub(super) fn render_send_message(
.model
.conversation(app)
.and_then(|conversation| orchestrator_agent_id_for_conversation(conversation, app));
let recipients = address
.iter()
.map(|agent_id| agent_display_name_from_id(agent_id, orchestrator_agent_id.as_deref(), app))
.collect::<Vec<_>>()
.join(", ");
let recipient_participants =
participant_for_agent_ids(address, orchestrator_agent_id.as_deref(), app);
let recipients = participant_display_names(&recipient_participants);
if let Some(AIActionStatus::Finished(result)) = &status {
let AIAgentActionResultType::SendMessageToAgent(result) = &result.result else {
@@ -222,40 +439,23 @@ pub(super) fn render_send_message(
};
match result {
SendMessageToAgentResult::Success { .. } => {
let status_icon = inline_action_icons::green_check_icon(appearance).finish();
let chevron = render_collapse_chevron(message_id, props, app);
let header = render_requested_action_row_for_text(
format!("Sent message to {recipients}: {subject}").into(),
appearance.ui_font_family(),
Some(status_icon),
chevron,
false,
false,
let sender = participant_for_current_conversation(
props,
orchestrator_agent_id.as_deref(),
app,
);
return render_transcript_row_with_spacing(
TranscriptRowData {
participant: &sender,
recipients: &recipient_participants,
subject,
body: message,
message_id,
is_streaming: false,
},
props,
app,
);
let fields = [("To: ", recipients.as_str()), ("Subject: ", subject)];
let body_element = Container::new(render_message_fields(&fields, message, app))
.with_margin_top(4.)
.with_margin_left(8.)
.finish();
let mut column =
Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
column.add_child(header);
if let Some(body) = render_collapsible_body(message_id, body_element, false, props)
{
column.add_child(body);
}
return Container::new(column.finish())
.with_horizontal_padding(8.)
.with_vertical_padding(8.)
.with_background_color(blended_colors::neutral_2(theme))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.finish()
.with_agent_output_item_spacing(app)
.finish();
}
SendMessageToAgentResult::Error(error) => {
let label = format!("Failed to send message to {recipients}: {error}");
@@ -432,40 +632,30 @@ pub(super) fn render_start_agent(
}
}
if let Some(card_data) = child_conversation_card_data {
// Render inline subagent panel instead of navigation card
if let Some(panel_state) = props.state_handles.subagent_panel_states.get(action_id) {
column.add_child(
crate::ai::blocklist::agent_view::subagent_inline_panel::render_subagent_inline_panel(
panel_state,
action_id,
app,
),
);
} else {
// Fallback: render the navigation card if no panel state exists yet
let navigation_card_handle = props
.state_handles
.orchestration_navigation_card_handles
.get(action_id)
.cloned()
.unwrap_or_else(|| {
log::error!(
"Missing orchestration navigation card handle for StartAgent action {:?}",
action_id
);
MouseStateHandle::default()
});
let status_icon = card_data.status.status_icon_and_color(theme);
column.add_child(render_conversation_navigation_card_row(
&card_data.agent_name,
Some(&card_data.title),
Some(status_icon),
card_data.conversation_id,
navigation_card_handle,
true,
app,
));
}
let navigation_card_handle = props
.state_handles
.orchestration_navigation_card_handles
.get(action_id)
.cloned()
.unwrap_or_else(|| {
log::error!(
"Missing orchestration navigation card handle for StartAgent action {:?}",
action_id
);
MouseStateHandle::default()
});
let status_icon = card_data
.status
.status_icon_and_color(theme, StatusColorStyle::Standard);
column.add_child(render_conversation_navigation_card_row(
&card_data.agent_name,
Some(&card_data.title),
Some(status_icon),
card_data.conversation_id,
navigation_card_handle,
true,
app,
));
}
return column
@@ -1,17 +1,19 @@
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
use crate::ai::agent::{StartAgentExecutionMode, StartAgentResult};
use crate::BlocklistAIHistoryModel;
use ai::agent::action_result::StartAgentVersion;
use galaxy_cli::agent::Harness;
use galaxy_core::ui::appearance::Appearance;
use galaxyui::elements::MouseStateHandle;
use galaxyui::{App, EntityId};
use super::{
agent_display_name_from_id, child_conversation_card_data_for_result,
agent_display_name_from_id, child_conversation_card_data_for_result, participant_for_agent_id,
render_conversation_navigation_card_row, start_agent_cancelled_prefix,
start_agent_error_prefix, start_agent_in_progress_prefix, start_agent_success_suffix,
ChildConversationCardData,
transcript_metadata, ChildConversationCardData, OrchestrationAvatar, OrchestrationParticipant,
};
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
use crate::ai::agent::{StartAgentExecutionMode, StartAgentResult};
use crate::test_util::settings::initialize_history_persistence_for_tests;
use crate::BlocklistAIHistoryModel;
#[test]
fn child_conversation_card_data_for_success_result_returns_conversation_id_and_title() {
@@ -19,7 +21,7 @@ fn child_conversation_card_data_for_success_result_returns_conversation_id_and_t
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let conversation_id = history_model.update(&mut app, |history_model, ctx| {
let conversation_id =
history_model.start_new_conversation(EntityId::new(), false, false, ctx);
history_model.start_new_conversation(EntityId::new(), false, false, false, ctx);
history_model.set_server_conversation_token_for_conversation(
conversation_id,
"child-agent-id".to_string(),
@@ -76,6 +78,7 @@ fn start_agent_copy_uses_remote_labels_for_remote_children() {
worker_host: String::new(),
harness_type: String::new(),
title: String::new(),
auth_secret_name: None,
};
assert_eq!(start_agent_success_suffix(&execution_mode), " remotely.");
@@ -99,7 +102,7 @@ fn child_conversation_card_data_for_success_result_without_available_title_uses_
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let conversation_id = history_model.update(&mut app, |history_model, ctx| {
let conversation_id =
history_model.start_new_conversation(EntityId::new(), false, false, ctx);
history_model.start_new_conversation(EntityId::new(), false, false, false, ctx);
history_model.set_server_conversation_token_for_conversation(
conversation_id,
"child-agent-id".to_string(),
@@ -164,7 +167,7 @@ fn agent_display_name_from_id_returns_child_agent_name() {
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
history_model.update(&mut app, |history_model, ctx| {
let conversation_id =
history_model.start_new_conversation(EntityId::new(), false, false, ctx);
history_model.start_new_conversation(EntityId::new(), false, false, false, ctx);
history_model.set_server_conversation_token_for_conversation(
conversation_id,
"child-agent-id".to_string(),
@@ -188,7 +191,7 @@ fn agent_display_name_from_id_returns_orchestrator_label() {
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
history_model.update(&mut app, |history_model, ctx| {
let conversation_id =
history_model.start_new_conversation(EntityId::new(), false, false, ctx);
history_model.start_new_conversation(EntityId::new(), false, false, false, ctx);
let conversation = history_model
.conversation_mut(&conversation_id)
.expect("conversation should exist");
@@ -199,7 +202,7 @@ fn agent_display_name_from_id_returns_orchestrator_label() {
let actual = app.read(|ctx| {
agent_display_name_from_id("orchestrator-agent-id", Some("orchestrator-agent-id"), ctx)
});
assert_eq!(actual, "Orchestrator agent");
assert_eq!(actual, "Orchestrator");
});
}
@@ -212,6 +215,239 @@ fn agent_display_name_from_id_returns_unknown_fallback() {
assert_eq!(actual, "Unknown agent");
});
}
#[test]
fn participant_for_agent_id_uses_pill_style_child_agent_avatar() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
history_model.update(&mut app, |history_model, ctx| {
let terminal_view_id = EntityId::new();
let parent_conversation_id =
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx);
history_model.set_server_conversation_token_for_conversation(
parent_conversation_id,
"orchestrator-agent-id".to_string(),
);
let child_conversation_id = history_model.start_new_child_conversation(
terminal_view_id,
"Agent 1".to_string(),
parent_conversation_id,
Some(Harness::Claude),
ctx,
);
history_model.set_server_conversation_token_for_conversation(
child_conversation_id,
"child-agent-id".to_string(),
);
});
let actual = app.read(|ctx| {
participant_for_agent_id("child-agent-id", Some("orchestrator-agent-id"), ctx)
});
assert_eq!(actual.display_name, "Agent 1");
assert_eq!(
actual.avatar,
OrchestrationAvatar::agent("Agent 1".to_string())
);
});
}
/// A restored child run id (persisted via run_id in the SQLite
/// `agent_conversations` row) must resolve to the child's display name
/// after `BlocklistAIHistoryModel::new` eagerly hydrates the orchestration
/// child into `conversations_by_id`. Otherwise this falls back to
/// "Unknown agent" because the child conversation is not loaded into memory
/// until its hidden pane materializes lazily.
#[test]
fn participant_for_restored_child_run_id_resolves_to_agent_name() {
use chrono::Utc;
use uuid::Uuid;
use crate::persistence::model::{
AgentConversation, AgentConversationData, AgentConversationRecord,
};
App::test((), |app| async move {
let parent_id = AIConversationId::new();
let child_id = AIConversationId::new();
let parent_run_id = Uuid::new_v4().to_string();
let child_run_id = Uuid::new_v4().to_string();
let now = Utc::now().naive_utc();
// A persisted child conversation with at least one root task so
// `AIConversation::new_restored` succeeds, parent linkage via
// `parent_conversation_id`, and a `run_id` so the orchestration
// transcript can resolve its display name from a server-side
// agent identifier.
let child = AgentConversation {
conversation: AgentConversationRecord {
id: 1,
conversation_id: child_id.to_string(),
conversation_data: serde_json::to_string(&AgentConversationData {
server_conversation_token: Some("child-token".to_string()),
conversation_usage_metadata: None,
reverted_action_ids: None,
forked_from_server_conversation_token: None,
artifacts_json: None,
parent_agent_id: Some(parent_run_id.clone()),
agent_name: Some("Agent 1".to_string()),
orchestration_harness_type: None,
parent_conversation_id: Some(parent_id.to_string()),
is_remote_child: false,
root_task_is_optimistic: None,
run_id: Some(child_run_id.clone()),
autoexecute_override: None,
last_event_sequence: None,
pinned: false,
})
.expect("child conversation data should serialize"),
last_modified_at: now,
},
tasks: vec![warp_multi_agent_api::Task {
id: format!("task-{child_id}"),
messages: vec![warp_multi_agent_api::Message {
fetched_memories: vec![],
id: "child-msg".to_string(),
task_id: format!("task-{child_id}"),
server_message_data: String::new(),
citations: vec![],
message: Some(warp_multi_agent_api::message::Message::UserQuery(
warp_multi_agent_api::message::UserQuery {
query: "Child query".to_string(),
context: None,
referenced_attachments: Default::default(),
mode: None,
intended_agent: Default::default(),
},
)),
request_id: "request-1".to_string(),
timestamp: None,
}],
dependencies: None,
description: "Child query".to_string(),
summary: String::new(),
server_data: String::new(),
}],
};
// Parent must also be hydrated so the orchestrator id is in the
// run-id reverse index.
let parent = AgentConversation {
conversation: AgentConversationRecord {
id: 2,
conversation_id: parent_id.to_string(),
conversation_data: serde_json::to_string(&AgentConversationData {
server_conversation_token: Some("parent-token".to_string()),
conversation_usage_metadata: None,
reverted_action_ids: None,
forked_from_server_conversation_token: None,
artifacts_json: None,
parent_agent_id: None,
agent_name: None,
orchestration_harness_type: None,
parent_conversation_id: None,
is_remote_child: false,
root_task_is_optimistic: None,
run_id: Some(parent_run_id.clone()),
autoexecute_override: None,
last_event_sequence: None,
pinned: false,
})
.expect("parent conversation data should serialize"),
last_modified_at: now - chrono::Duration::seconds(1),
},
tasks: vec![warp_multi_agent_api::Task {
id: format!("task-{parent_id}"),
messages: vec![warp_multi_agent_api::Message {
fetched_memories: vec![],
id: "parent-msg".to_string(),
task_id: format!("task-{parent_id}"),
server_message_data: String::new(),
citations: vec![],
message: Some(warp_multi_agent_api::message::Message::UserQuery(
warp_multi_agent_api::message::UserQuery {
query: "Parent query".to_string(),
context: None,
referenced_attachments: Default::default(),
mode: None,
intended_agent: Default::default(),
},
)),
request_id: "request-2".to_string(),
timestamp: None,
}],
dependencies: None,
description: "Parent query".to_string(),
summary: String::new(),
server_data: String::new(),
}],
};
app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[child, parent]));
// Before Fix C the child would not be loaded into
// `conversations_by_id`, so `participant_for_agent_id` would return
// "Unknown agent". With Fix C, the child is eagerly hydrated and the
// display name resolves.
let participant =
app.read(|ctx| participant_for_agent_id(&child_run_id, Some(&parent_run_id), ctx));
assert_eq!(
participant.display_name, "Agent 1",
"restored child run id must resolve to the child's display name, not 'Unknown agent'",
);
assert_eq!(
participant.avatar,
OrchestrationAvatar::agent("Agent 1".to_string()),
);
});
}
#[test]
fn transcript_metadata_uses_transcript_copy_without_technical_labels() {
let recipients = vec![OrchestrationParticipant {
display_name: "Agent 1".to_string(),
avatar: OrchestrationAvatar::agent("Agent 1".to_string()),
conversation_id: None,
}];
let metadata = transcript_metadata(&recipients, "Fix tests").expect("metadata");
assert_eq!(metadata, "to Agent 1 • Fix tests");
for legacy_label in ["Messages received", "From:", "To:", "Subject:"] {
assert!(
!metadata.contains(legacy_label),
"Transcript metadata should not contain old technical label {legacy_label}: {metadata}"
);
}
}
#[test]
fn transcript_metadata_omits_orchestrator_recipients() {
let recipients = vec![OrchestrationParticipant::orchestrator()];
assert_eq!(
transcript_metadata(&recipients, "Status update"),
Some("Status update".to_string())
);
assert_eq!(transcript_metadata(&recipients, ""), None);
}
#[test]
fn transcript_metadata_preserves_non_orchestrator_recipients() {
let recipients = vec![
OrchestrationParticipant::orchestrator(),
OrchestrationParticipant {
display_name: "Agent 1".to_string(),
avatar: OrchestrationAvatar::agent("Agent 1".to_string()),
conversation_id: None,
},
];
assert_eq!(
transcript_metadata(&recipients, "Fix tests"),
Some("to Agent 1 • Fix tests".to_string())
);
}
#[test]
fn conversation_navigation_card_row_renders_title_without_legacy_subtitle() {
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,21 @@
use ai::agent::action::UploadArtifactRequest;
use ai::skills::{ParsedSkill, SkillProvider, SkillReference, SkillScope};
use repo_metadata::repositories::DetectedRepositories;
use repo_metadata::{DirectoryWatcher, RepoMetadataModel};
use warp_util::host_id::HostId;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use warp_util::remote_path::RemotePath;
use warp_util::standardized_path::StandardizedPath;
use warpui::App;
use watcher::HomeDirectoryWatcher;
use super::{
format_upload_artifact_text, parsed_skill_for_common_locations, read_skill_display_text,
};
use crate::ai::agent::UploadArtifactResult;
use super::format_upload_artifact_text;
use crate::ai::skills::SkillManager;
use crate::settings::AISettings;
use crate::warp_managed_paths_watcher::WarpManagedPathsWatcher;
#[test]
fn format_upload_artifact_text_includes_request_details() {
@@ -63,3 +76,135 @@ fn format_upload_artifact_text_includes_terminal_status() {
format_upload_artifact_text(&request, Some(&UploadArtifactResult::Cancelled));
assert_eq!(cancelled_text, "Upload artifact: reports/daily.txt");
}
fn make_skill(name: &str) -> ParsedSkill {
ParsedSkill {
name: name.to_string(),
description: String::new(),
path: LocalOrRemotePath::Local(
std::path::PathBuf::from("/home/user/.agents/skills")
.join(name)
.join("SKILL.md"),
),
content: String::new(),
line_range: None,
provider: SkillProvider::Agents,
scope: SkillScope::Home,
}
}
#[test]
fn read_skill_display_text_shows_slash_command_when_skill_found() {
let skill = make_skill("hello-world");
let reference = SkillReference::Path(skill.path.clone());
assert_eq!(
read_skill_display_text(Some(&skill), &reference),
"/hello-world"
);
}
#[test]
fn read_skill_display_text_no_double_slash_when_skill_not_found_with_path_reference() {
// When the skill is not in the manager the fallback is skill_reference.to_string(),
// which for a path reference is an absolute path starting with '/'. The display
// text must NOT prepend an extra '/' — doing so would produce '//home/…'.
let path = LocalOrRemotePath::Local(std::path::PathBuf::from(
"/home/devbox/.warp-local/skills/hello-world/SKILL.md",
));
let reference = SkillReference::Path(path);
let display = read_skill_display_text(None, &reference);
assert!(
!display.starts_with("//"),
"display text must not start with '//': {display}"
);
assert!(
display.starts_with('/'),
"display text should start with '/': {display}"
);
}
#[test]
fn read_skill_display_text_bundled_id_fallback_when_skill_not_found() {
let reference = SkillReference::BundledSkillId("create-pr".to_string());
let display = read_skill_display_text(None, &reference);
assert_eq!(display, "@warp-skill:create-pr");
}
fn remote_location(host_id: &HostId, path: &str) -> LocalOrRemotePath {
LocalOrRemotePath::Remote(RemotePath::new(
host_id.clone(),
StandardizedPath::try_new(path).unwrap(),
))
}
#[test]
fn parsed_skill_for_common_locations_resolves_cached_remote_skill() {
let host_id = HostId::new("remote-host".to_string());
let skill = ParsedSkill {
name: "deploy".to_string(),
description: "Deploy skill".to_string(),
path: remote_location(&host_id, "/repo/.agents/skills/deploy/SKILL.md"),
content: "# Deploy".to_string(),
line_range: None,
provider: SkillProvider::Agents,
scope: SkillScope::Project,
};
let locations = vec![
remote_location(&host_id, "/repo/.agents/skills/deploy/README.md"),
remote_location(&host_id, "/repo/.agents/skills/deploy/scripts/run.sh"),
];
App::test((), |mut app| async move {
app.add_singleton_model(DirectoryWatcher::new);
app.add_singleton_model(AISettings::new_with_defaults);
app.add_singleton_model(|_| DetectedRepositories::default());
app.add_singleton_model(RepoMetadataModel::new);
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing);
let manager = app.add_singleton_model(SkillManager::new);
manager.update(&mut app, |manager, _| {
manager.add_skill_for_testing(skill.clone());
});
let resolved = manager.read(&app, |_, ctx| {
parsed_skill_for_common_locations(locations, ctx).map(|skill| skill.path.clone())
});
assert_eq!(resolved, Some(skill.path));
});
}
#[test]
fn parsed_skill_for_common_locations_does_not_mix_remote_hosts() {
let first_host = HostId::new("first-host".to_string());
let second_host = HostId::new("second-host".to_string());
let skill = ParsedSkill {
name: "deploy".to_string(),
description: "Deploy skill".to_string(),
path: remote_location(&first_host, "/repo/.agents/skills/deploy/SKILL.md"),
content: "# Deploy".to_string(),
line_range: None,
provider: SkillProvider::Agents,
scope: SkillScope::Project,
};
let locations = vec![
remote_location(&first_host, "/repo/.agents/skills/deploy/README.md"),
remote_location(&second_host, "/repo/.agents/skills/deploy/README.md"),
];
App::test((), |mut app| async move {
app.add_singleton_model(DirectoryWatcher::new);
app.add_singleton_model(AISettings::new_with_defaults);
app.add_singleton_model(|_| DetectedRepositories::default());
app.add_singleton_model(RepoMetadataModel::new);
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing);
let manager = app.add_singleton_model(SkillManager::new);
manager.update(&mut app, |manager, _| {
manager.add_skill_for_testing(skill);
});
assert!(manager.read(&app, |_, ctx| {
parsed_skill_for_common_locations(locations, ctx).is_none()
}));
});
}

Some files were not shown because too many files have changed in this diff Show More