Improve local tool execution and shell output panes

This commit is contained in:
2026-08-28 10:20:16 -05:00
parent a69b927cc3
commit 88c1ef9716
23 changed files with 807 additions and 238 deletions
+14 -2
View File
@@ -274,6 +274,18 @@ enum StartedAction {
Async { phase: RunningActionPhase },
}
impl StartedAction {
fn allows_compatible_following_actions(self) -> bool {
matches!(
self,
Self::Sync
| Self::Async {
phase: RunningActionPhase::Parallel(_)
}
)
}
}
/// Returns whether another action may join the currently running phase.
///
/// Parallel phases only admit additional actions that classify into the same group and
@@ -1421,7 +1433,7 @@ impl BlocklistAIActionModel {
ActionExecutionInitiator::User,
ctx,
)
.is_some_and(|result| matches!(result, StartedAction::Sync))
.is_some_and(StartedAction::allows_compatible_following_actions)
{
self.try_to_execute_available_actions(conversation_id, ctx);
}
@@ -1441,7 +1453,7 @@ impl BlocklistAIActionModel {
ActionExecutionInitiator::User,
ctx,
)
.is_some_and(|result| matches!(result, StartedAction::Sync))
.is_some_and(StartedAction::allows_compatible_following_actions)
{
self.try_to_execute_available_actions(conversation_id, ctx);
}
+70 -8
View File
@@ -24,10 +24,11 @@ pub(super) mod use_computer;
pub(super) mod wait_for_events;
use std::any::Any;
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use ai::agent::action_result::{InsertReviewCommentsResult, RequestCommandOutputResult};
pub use ask_user_question::AskUserQuestionExecutor;
@@ -52,10 +53,11 @@ use galaxy_util::file_type::is_buffer_binary;
use galaxyui::r#async::{Spawnable, SpawnableOutput};
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use grep::GrepExecutor;
use instant::Instant;
#[cfg(feature = "local_fs")]
use mime_guess::from_path;
use notebooks::NotebookExecutor;
use parking_lot::FairMutex;
use parking_lot::{FairMutex, Mutex};
use read_documents::ReadDocumentsExecutor;
pub(super) use read_files::ReadFilesExecutor;
use read_mcp_resource::ReadMCPResourceExecutor;
@@ -96,7 +98,7 @@ use crate::ai::get_relevant_files::controller::GetRelevantFilesController;
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::session::{ExecuteCommandOptions, Session, SessionId};
use crate::terminal::model_events::ModelEventDispatcher;
use crate::terminal::shell::ShellType;
use crate::terminal::{ShellLaunchData, TerminalModel};
@@ -146,6 +148,34 @@ pub(super) enum RunningActionPhase {
Parallel(ParallelExecutionPolicy),
}
const GIT_REPOSITORY_CACHE_TTL: Duration = Duration::from_secs(60);
type GitRepositoryCacheEntries = HashMap<(SessionId, String), (Instant, bool)>;
#[derive(Clone, Default)]
struct GitRepositoryCache {
entries: Arc<Mutex<GitRepositoryCacheEntries>>,
}
impl GitRepositoryCache {
fn get(&self, session_id: SessionId, path: &str) -> Option<bool> {
let key = (session_id, path.to_string());
let mut entries = self.entries.lock();
let (cached_at, is_repository) = entries.get(&key).copied()?;
if cached_at.elapsed() < GIT_REPOSITORY_CACHE_TTL {
Some(is_repository)
} else {
entries.remove(&key);
None
}
}
fn insert(&self, session_id: SessionId, path: String, is_repository: bool) {
let mut entries = self.entries.lock();
entries.retain(|_, (cached_at, _)| cached_at.elapsed() < GIT_REPOSITORY_CACHE_TTL);
entries.insert((session_id, path), (Instant::now(), is_repository));
}
}
#[derive(Debug, Clone, Copy)]
struct ExecuteActionInput<'a> {
action: &'a AIAgentAction,
@@ -355,10 +385,21 @@ impl BlocklistAIActionExecutor {
let request_file_edits_executor = ctx.add_model(|ctx| {
RequestFileEditsExecutor::new(active_session.clone(), terminal_view_id, ctx)
});
let grep_executor =
ctx.add_model(|_| GrepExecutor::new(active_session.clone(), terminal_view_id));
let file_glob_executor =
ctx.add_model(|_| FileGlobExecutor::new(active_session.clone(), terminal_view_id));
let git_repository_cache = GitRepositoryCache::default();
let grep_executor = ctx.add_model(|_| {
GrepExecutor::new(
active_session.clone(),
terminal_view_id,
git_repository_cache.clone(),
)
});
let file_glob_executor = ctx.add_model(|_| {
FileGlobExecutor::new(
active_session.clone(),
terminal_view_id,
git_repository_cache,
)
});
let read_mcp_resource_executor = ctx
.add_model(|_| ReadMCPResourceExecutor::new(active_session.clone(), terminal_view_id));
let call_mcp_tool_executor =
@@ -1550,8 +1591,15 @@ fn build_is_git_repository_command(absolute_path: &str, shell_type: ShellType) -
}
/// 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.
/// Uses native metadata for compatible local sessions and a shell probe otherwise.
async fn is_file_path(path: &str, session: &Session) -> bool {
#[cfg(feature = "local_fs")]
if session.is_local() && !session.is_wsl() && !session.is_msys2() {
return async_fs::metadata(path)
.await
.is_ok_and(|metadata| metadata.is_file());
}
let command = build_is_file_path_command(path, session.shell().shell_type());
session
.execute_command(&command, None, None, ExecuteCommandOptions::default())
@@ -1574,6 +1622,20 @@ async fn is_git_repository(absolute_path: &str, session: &Session) -> anyhow::Re
Ok(command_output.success())
}
async fn is_git_repository_cached(
absolute_path: &str,
session: &Session,
cache: &GitRepositoryCache,
) -> anyhow::Result<bool> {
if let Some(is_repository) = cache.get(session.id(), absolute_path) {
return Ok(is_repository);
}
let is_repository = is_git_repository(absolute_path, session).await?;
cache.insert(session.id(), absolute_path.to_string(), is_repository);
Ok(is_repository)
}
fn get_server_output_id(
conversation_id: AIConversationId,
ctx: &mut AppContext,
@@ -26,13 +26,14 @@ use crate::{send_telemetry_from_app_ctx, TelemetryEvent};
const FILE_GLOB_TIMEOUT: Duration = Duration::from_secs(10);
use super::{
get_server_output_id, is_git_repository, ActionExecution, AnyActionExecution,
ExecuteActionInput, PreprocessActionInput,
get_server_output_id, is_git_repository_cached, ActionExecution, AnyActionExecution,
ExecuteActionInput, GitRepositoryCache, PreprocessActionInput,
};
pub struct FileGlobExecutor {
active_session: ModelHandle<ActiveSession>,
terminal_view_id: EntityId,
git_repository_cache: GitRepositoryCache,
}
fn log_file_glob_error(conversation_id: AIConversationId, ctx: &mut AppContext) {
@@ -41,10 +42,15 @@ fn log_file_glob_error(conversation_id: AIConversationId, ctx: &mut AppContext)
}
impl FileGlobExecutor {
pub fn new(active_session: ModelHandle<ActiveSession>, terminal_view_id: EntityId) -> Self {
pub(super) fn new(
active_session: ModelHandle<ActiveSession>,
terminal_view_id: EntityId,
git_repository_cache: GitRepositoryCache,
) -> Self {
Self {
active_session,
terminal_view_id,
git_repository_cache,
}
}
@@ -134,11 +140,18 @@ impl FileGlobExecutor {
let patterns_clone = patterns.clone();
let conversation_id_clone = input.conversation_id;
let is_file_glob_v2 = is_file_glob_v2(&input);
let git_repository_cache = self.git_repository_cache.clone();
ActionExecution::new_async(
async move {
match run_file_glob(patterns_clone, absolute_path, session, shell_launch_data)
.with_timeout(FILE_GLOB_TIMEOUT)
.await
match run_file_glob(
patterns_clone,
absolute_path,
session,
shell_launch_data,
git_repository_cache,
)
.with_timeout(FILE_GLOB_TIMEOUT)
.await
{
Ok(result) => result,
Err(_) => Err(anyhow::anyhow!("File glob operation timed out")),
@@ -204,6 +217,7 @@ async fn run_file_glob(
absolute_path: String,
session: Option<Arc<Session>>,
shell_launch_data: Option<ShellLaunchData>,
git_repository_cache: GitRepositoryCache,
) -> anyhow::Result<FileGlobV2Result> {
if patterns.is_empty() {
return Err(anyhow::anyhow!("No patterns provided to file_glob"));
@@ -213,12 +227,13 @@ async fn run_file_glob(
};
let shell_type = session.shell().shell_type();
let is_in_git_repo = is_git_repository(&absolute_path, session.as_ref())
.await
.unwrap_or_else(|e| {
log::error!("Failed to run command to check if in git repository: {e:?}");
false
});
let is_in_git_repo =
is_git_repository_cached(&absolute_path, session.as_ref(), &git_repository_cache)
.await
.unwrap_or_else(|e| {
log::error!("Failed to run command to check if in git repository: {e:?}");
false
});
if is_in_git_repo {
run_git_ls_files_command(
@@ -5,14 +5,14 @@ use std::sync::Arc;
use std::time::Duration;
use futures::future::BoxFuture;
use futures::FutureExt;
use futures::{FutureExt, StreamExt};
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui::r#async::FutureExt as AsyncFutureExt;
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use super::{
get_server_output_id, is_file_path, is_git_repository, ActionExecution, AnyActionExecution,
ExecuteActionInput, PreprocessActionInput,
get_server_output_id, is_file_path, is_git_repository_cached, ActionExecution,
AnyActionExecution, ExecuteActionInput, GitRepositoryCache, PreprocessActionInput,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::redaction::redact_secrets;
@@ -30,6 +30,8 @@ use crate::terminal::ShellLaunchData;
use crate::{send_telemetry_from_app_ctx, PrivacySettings, TelemetryEvent};
const GREP_TIMEOUT: Duration = Duration::from_secs(10);
const GREP_MAX_MATCHES: usize = 1_000;
const GREP_MAX_MATCHES_PER_FILE: usize = 50;
const NON_ZERO_EXIT_CODE_ERROR: &str = "Grep command exited with non-zero exit code";
/// Information about the Grep call that resulted in an error, used to send
@@ -179,13 +181,19 @@ fn log_grep_error(
pub struct GrepExecutor {
active_session: ModelHandle<ActiveSession>,
terminal_view_id: EntityId,
git_repository_cache: GitRepositoryCache,
}
impl GrepExecutor {
pub fn new(active_session: ModelHandle<ActiveSession>, terminal_view_id: EntityId) -> Self {
pub(super) fn new(
active_session: ModelHandle<ActiveSession>,
terminal_view_id: EntityId,
git_repository_cache: GitRepositoryCache,
) -> Self {
Self {
active_session,
terminal_view_id,
git_repository_cache,
}
}
@@ -264,11 +272,18 @@ impl GrepExecutor {
let absolute_path_clone = absolute_path.clone();
let working_directory_clone = current_working_directory.clone();
let conversation_id_clone = input.conversation_id;
let git_repository_cache = self.git_repository_cache.clone();
ActionExecution::new_async(
async move {
match run_grep(queries_clone, absolute_path, session, shell_launch_data)
.with_timeout(GREP_TIMEOUT)
.await
match run_grep(
queries_clone,
absolute_path,
session,
shell_launch_data,
git_repository_cache,
)
.with_timeout(GREP_TIMEOUT)
.await
{
Ok(result) => result,
Err(_) => Err(GrepError::new("Grep operation timed out".to_string())),
@@ -345,6 +360,7 @@ async fn run_grep(
absolute_path: String,
session: Option<Arc<Session>>,
shell_launch_data: Option<ShellLaunchData>,
git_repository_cache: GitRepositoryCache,
) -> Result<GrepResult, GrepError> {
if queries.is_empty() {
return Err(GrepError::new("No queries provided to grep".to_string()));
@@ -373,13 +389,13 @@ async fn run_grep(
Cow::Borrowed(absolute_path.as_str())
};
// TODO(CODE-239): Cache the result of this check.
let is_grep_in_git_repo = is_git_repository(&execute_directory, &session)
.await
.unwrap_or_else(|e| {
log::error!("Failed to run command to check if in git repository: {e:?}");
false
});
let is_grep_in_git_repo =
is_git_repository_cached(&execute_directory, &session, &git_repository_cache)
.await
.unwrap_or_else(|e| {
log::error!("Failed to run command to check if in git repository: {e:?}");
false
});
let shell_type = session.shell().shell_type();
// The most optimized tool to perform the search is `git grep`;
@@ -428,10 +444,13 @@ async fn run_grep(
#[cfg(not(target_family = "wasm"))]
async fn run_ripgrep(queries: &[String], absolute_path: String) -> Result<GrepResult, GrepError> {
let path = PathBuf::from(absolute_path);
let result = galaxy_ripgrep::search::search(queries, &[path], false, false).await;
let result = galaxy_ripgrep::search::search_streaming(queries, &[path], false, false);
match result {
Ok(matches) => {
let mut matches = matches.take(GREP_MAX_MATCHES + 1).collect::<Vec<_>>().await;
let truncated = matches.len() > GREP_MAX_MATCHES;
matches.truncate(GREP_MAX_MATCHES);
let mut files_map: HashMap<PathBuf, Vec<GrepLineMatch>> = HashMap::new();
for m in matches {
files_map
@@ -448,7 +467,10 @@ async fn run_ripgrep(queries: &[String], absolute_path: String) -> Result<GrepRe
matched_lines,
})
.collect();
Ok(GrepResult::Success { matched_files })
Ok(GrepResult::Success {
matched_files,
truncated,
})
}
Err(e) => Err(GrepError::new(format!("Ripgrep search failed: {e}"))),
}
@@ -482,7 +504,10 @@ async fn run_git_grep_command(
shell_launch_data,
Some(execute_directory.to_string()),
)
.map(|matched_files| GrepResult::Success { matched_files })
.map(|(matched_files, truncated)| GrepResult::Success {
matched_files,
truncated,
})
.map_err(|e| {
GrepError::new(e.to_string())
.with_command(grep_command)
@@ -496,6 +521,7 @@ async fn run_git_grep_command(
// matches.
Ok(GrepResult::Success {
matched_files: vec![],
truncated: false,
})
} else {
Err(GrepError::new_for_non_zero_exit_code()
@@ -531,7 +557,10 @@ async fn run_grep_command(
shell_launch_data,
Some(execute_directory.to_string()),
)
.map(|matched_files| GrepResult::Success { matched_files })
.map(|(matched_files, truncated)| GrepResult::Success {
matched_files,
truncated,
})
.map_err(|e| {
GrepError::new(e.to_string())
.with_command(grep_command)
@@ -545,6 +574,7 @@ async fn run_grep_command(
// matches.
Ok(GrepResult::Success {
matched_files: vec![],
truncated: false,
})
} else {
Err(GrepError::new_for_non_zero_exit_code()
@@ -580,7 +610,10 @@ async fn run_select_string_command(
shell_launch_data,
Some(execute_directory.to_string()),
)
.map(|matched_files| GrepResult::Success { matched_files })
.map(|(matched_files, truncated)| GrepResult::Success {
matched_files,
truncated,
})
.map_err(|e| {
GrepError::new(e.to_string())
.with_command(select_string_command)
@@ -595,7 +628,9 @@ 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();
let mut grep_command = format!(
"git --no-pager grep --color=never --untracked -nIE -m {GREP_MAX_MATCHES_PER_FILE}"
);
for query in queries {
// Queries can originate from model output and project instructions. Keep
// them as grep arguments so shell substitutions like $() are inert.
@@ -613,7 +648,8 @@ fn build_grep_command(queries: &[String], target_path: &str, shell_type: ShellTy
// * "-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();
let mut grep_command =
format!("grep --color=never -nrIHE --devices=skip -m {GREP_MAX_MATCHES_PER_FILE}");
for query in queries {
// Queries can originate from model output and project instructions. Keep
// them as grep arguments so shell substitutions like $() are inert.
@@ -627,7 +663,7 @@ 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 {}",
"Get-ChildItem -Path {} -Recurse -File | Select-String -NoEmphasis -CaseSensitive -Pattern {} | Select-Object -First {}",
shell_quote_arg(target_path, ShellType::PowerShell),
queries
.iter()
@@ -635,7 +671,8 @@ fn build_select_string_command(queries: &[String], target_path: &str) -> String
// strings, so patterns must be single-quoted data arguments.
.map(|q| shell_quote_arg(q, ShellType::PowerShell))
.collect::<Vec<_>>()
.join(",")
.join(","),
GREP_MAX_MATCHES + 1,
)
}
@@ -648,10 +685,11 @@ fn parse_grep_output(
output: &str,
shell_launch_data: Option<ShellLaunchData>,
current_working_directory: Option<String>,
) -> anyhow::Result<Vec<GrepFileMatch>> {
) -> anyhow::Result<(Vec<GrepFileMatch>, bool)> {
let mut matched_files = HashMap::new();
let mut lines = output.trim().lines();
for line in output.trim().split("\n") {
for line in lines.by_ref().take(GREP_MAX_MATCHES) {
let mut parts = line.split(":");
let file = parts.next();
let line_number = parts.next();
@@ -677,17 +715,24 @@ fn parse_grep_output(
.push(GrepLineMatch { line_number });
}
Ok(matched_files
.into_iter()
.map(|(file, matched_lines)| GrepFileMatch {
file_path: host_native_absolute_path(
file,
&shell_launch_data,
&current_working_directory,
),
matched_lines,
})
.collect())
let truncated = lines.next().is_some()
|| matched_files
.values()
.any(|matched_lines| matched_lines.len() >= GREP_MAX_MATCHES_PER_FILE);
Ok((
matched_files
.into_iter()
.map(|(file, matched_lines)| GrepFileMatch {
file_path: host_native_absolute_path(
file,
&shell_launch_data,
&current_working_directory,
),
matched_lines,
})
.collect(),
truncated,
))
}
impl Entity for GrepExecutor {
@@ -81,7 +81,7 @@ fn build_git_grep_command_single_quotes_shell_substitution() {
assert_eq!(
command,
"git --no-pager grep --color=never --untracked -nIE -e '$(touch /tmp/warp-poc); `id`' '/tmp/repo path'"
"git --no-pager grep --color=never --untracked -nIE -m 50 -e '$(touch /tmp/warp-poc); `id`' '/tmp/repo path'"
);
}
@@ -93,7 +93,7 @@ fn build_grep_command_escapes_single_quotes() {
assert_eq!(
command,
r#"grep --color=never -nrIHE --devices=skip -e 'owner'"'"'s code' '/tmp/repo'"#
r#"grep --color=never -nrIHE --devices=skip -m 50 -e 'owner'"'"'s code' '/tmp/repo'"#
);
}
@@ -105,6 +105,23 @@ fn build_select_string_command_single_quotes_powershell_substitution() {
assert_eq!(
command,
r#"Get-ChildItem -Path 'C:\repo path' -Recurse -File | Select-String -NoEmphasis -CaseSensitive -Pattern '$(New-Item C:\pwn); ''literal'''"#
r#"Get-ChildItem -Path 'C:\repo path' -Recurse -File | Select-String -NoEmphasis -CaseSensitive -Pattern '$(New-Item C:\pwn); ''literal''' | Select-Object -First 1001"#
);
}
#[test]
fn parse_grep_output_reports_when_results_reach_the_per_file_limit() {
let output = (1..=GREP_MAX_MATCHES_PER_FILE)
.map(|line| format!("src/lib.rs:{line}:match"))
.collect::<Vec<_>>()
.join("\n");
let (matched_files, truncated) = parse_grep_output(&output, None, None).unwrap();
assert_eq!(matched_files.len(), 1);
assert_eq!(
matched_files[0].matched_lines.len(),
GREP_MAX_MATCHES_PER_FILE
);
assert!(truncated);
}
@@ -1,6 +1,6 @@
use std::path::{Path, PathBuf};
use futures::future::BoxFuture;
use futures::future::{join_all, BoxFuture};
use futures::FutureExt;
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
@@ -236,14 +236,24 @@ impl ReadFilesExecutor {
// Local path.
ActionExecution::Async {
execute_future: Box::pin(async move {
let result = read_local_file_context(
&locations,
current_working_directory,
shell,
None,
None,
)
.await?;
let reads = locations.iter().map(|location| {
read_local_file_context(
std::slice::from_ref(location),
current_working_directory.clone(),
shell.clone(),
None,
None,
)
});
let mut result = super::ReadFileContextResult {
file_contexts: Vec::new(),
missing_files: Vec::new(),
};
for file_result in join_all(reads).await {
let file_result = file_result?;
result.file_contexts.extend(file_result.file_contexts);
result.missing_files.extend(file_result.missing_files);
}
if result.missing_files.is_empty() {
Ok(ReadFilesResult::Success {
files: result.file_contexts,
@@ -123,6 +123,20 @@ fn parallel_phase_only_admits_matching_autoexecutable_actions() {
));
}
#[test]
fn parallel_user_started_action_allows_compatible_automatic_followups() {
let parallel = StartedAction::Async {
phase: RunningActionPhase::Parallel(execute::ParallelExecutionPolicy::ReadOnlyLocalContext),
};
let serial = StartedAction::Async {
phase: RunningActionPhase::Serial,
};
assert!(StartedAction::Sync.allows_compatible_following_actions());
assert!(parallel.allows_compatible_following_actions());
assert!(!serial.allows_compatible_following_actions());
}
#[test]
fn phased_scheduling_stops_at_serial_barrier_and_resumes_afterward() {
let read_only_phase =
+78 -27
View File
@@ -28,7 +28,7 @@ use std::sync::Arc;
use ai::agent::action::{AskUserQuestionItem, InsertReviewComment, RunAgentsRequest};
use base64::Engine as _;
use chrono::Duration;
use cli_controller::{CLISubagentController, CLISubagentEvent};
use cli_controller::{CLISubagentController, CLISubagentEvent, LongRunningCommandControlState};
use find::FindState;
use galaxy_agent_core::RuntimeActivityStatus;
use galaxy_core::features::FeatureFlag;
@@ -1445,18 +1445,38 @@ impl AIBlock {
initial_requested_command_action_id: Some(initial_requested_command_action_id),
..
} => {
me.collapse_requested_command_view(initial_requested_command_action_id, ctx);
let restore_completed_takeover = {
let terminal_model = me.terminal_model.lock();
terminal_model
.block_list()
.block_for_ai_action_id(initial_requested_command_action_id)
.is_some_and(|block| {
block.finished()
&& block.long_running_control_state().is_some_and(
LongRunningCommandControlState::is_user_in_control,
)
})
};
if restore_completed_takeover {
me.expand_requested_command_view(initial_requested_command_action_id, ctx);
} else {
me.collapse_requested_command_view(
initial_requested_command_action_id,
ctx,
);
}
}
CLISubagentEvent::UpdatedControl {
requested_command_action_id: Some(requested_command_action_id),
agent_has_control,
..
} => {
if let Some(requested_command_view) =
me.requested_commands.get(requested_command_action_id)
{
requested_command_view
.view
.update(ctx, |_, ctx| ctx.notify());
if *agent_has_control {
me.expand_requested_command_view(requested_command_action_id, ctx);
} else {
me.requested_commands_to_auto_collapse
.remove(requested_command_action_id);
me.collapse_requested_command_view(requested_command_action_id, ctx);
}
}
_ => {}
@@ -1465,18 +1485,36 @@ impl AIBlock {
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();
if terminal_model
.block_list()
.block_with_id(&block_completed_event.block_id)
.and_then(|block| block.agent_interaction_metadata())
.is_some_and(|metadata| {
let (requested_action_id, should_restore_takeover) = {
let mut terminal_model = me.terminal_model.lock();
let command_metadata = terminal_model
.block_list()
.block_with_id(&block_completed_event.block_id)
.and_then(|block| block.agent_interaction_metadata());
let action_id = command_metadata
.and_then(|metadata| metadata.requested_command_action_id().cloned())
.filter(|id| me.requested_action_ids.contains(id));
let should_restore = command_metadata.is_some_and(|metadata| {
metadata
.requested_command_action_id()
.is_some_and(|id| me.requested_action_ids.contains(id))
})
{
ctx.notify();
.long_running_control_state()
.is_some_and(LongRunningCommandControlState::is_user_in_control)
});
if should_restore {
if let Some(action_id) = &action_id {
terminal_model
.block_list_mut()
.set_visibility_of_block_for_ai_action(action_id, false);
}
}
(action_id, should_restore)
};
if let Some(action_id) = requested_action_id {
if should_restore_takeover {
me.requested_commands_to_auto_collapse.remove(&action_id);
me.expand_requested_command_view(&action_id, ctx);
} else {
ctx.notify();
}
}
}
});
@@ -3702,12 +3740,17 @@ impl AIBlock {
.action_model
.as_ref(ctx)
.get_action_status(self.client_ids.conversation_id, action_id);
let has_finished_command_block = {
let (has_finished_command_block, user_controls_command) = {
let terminal_model = self.terminal_model.lock();
terminal_model
let command_block = terminal_model
.block_list()
.block_for_ai_action_id(action_id)
.is_some_and(|block| block.finished())
.block_for_ai_action_id(action_id);
(
command_block.is_some_and(|block| block.finished()),
command_block
.and_then(|block| block.long_running_control_state())
.is_some_and(LongRunningCommandControlState::is_user_in_control),
)
};
if !has_finished_command_block
&& !action_status.is_some_and(|a| a.is_running() || a.is_done())
@@ -3727,12 +3770,11 @@ impl AIBlock {
self.requested_commands_to_auto_collapse.remove(action_id);
}
// Requested-command output is rendered by the inline pane whenever it is
// expanded. Keep the backing terminal block hidden in both states so collapsing
// the pane does not move the output to the bottom of the conversation.
// Agent-owned command output stays contained in the inline pane. During user
// takeover, the same live backing terminal block becomes the interactive surface.
ctx.emit(AIBlockEvent::UpdateInlineActionVisibility {
action_id: action_id.clone(),
is_visible: false,
is_visible: user_controls_command && !has_finished_command_block,
});
ctx.notify();
}
@@ -6105,6 +6147,15 @@ impl AIBlock {
self.requested_commands.iter()
}
pub(crate) fn requested_command_view(
&self,
action_id: &AIAgentActionId,
) -> Option<ViewHandle<RequestedCommandView>> {
self.requested_commands
.get(action_id)
.map(|command| command.view.clone())
}
/// Collects all imported review comments stored in this block
/// and the base branch (if any). Returns `None` when this block has no imported comments.
pub(crate) fn collect_imported_comments(&self) -> Option<ImportedBlockComments> {
@@ -711,6 +711,11 @@ impl CLISubagentController {
let action_id = active_block.requested_command_action_id().cloned();
let conversation_id = active_block.ai_conversation_id();
let agent_has_control = active_block.is_agent_in_control();
if let Some(action_id) = &action_id {
terminal_model
.block_list_mut()
.set_visibility_of_block_for_ai_action(action_id, true);
}
// Conversation cancellation potentially takes a lock on terminal model if the
// cancelled action is a shell command action, so we have to drop the terminal
// model lock before actually cancelling the conversation.
@@ -775,6 +780,11 @@ impl CLISubagentController {
);
let action_id = active_block.requested_command_action_id().cloned();
let agent_has_control = active_block.is_agent_in_control();
if let Some(action_id) = &action_id {
terminal_model
.block_list_mut()
.set_visibility_of_block_for_ai_action(action_id, false);
}
drop(terminal_model);
if let Some(agent_view_controller) = &self.agent_view_controller {
agent_view_controller.update(ctx, |controller, ctx| {
@@ -17,7 +17,6 @@ use galaxyui::elements::{
};
use galaxyui::keymap::{Context, EditableBinding, FixedBinding, Keystroke};
use galaxyui::ui_components::components::UiComponent as _;
use galaxyui::units::IntoPixels;
use galaxyui::{
AppContext, Element, Entity, EntityId, EventContext, ModelHandle, SingletonEntity,
TypedActionView, UpdateView, View, ViewContext, ViewHandle,
@@ -46,7 +45,6 @@ use crate::ai::blocklist::inline_action::inline_action_header::{
ExpandedConfig, HeaderConfig, InteractionMode, RightClickConfig,
INLINE_ACTION_HORIZONTAL_PADDING,
};
use crate::ai::blocklist::inline_action::requested_action::render_requested_action_body_text;
use crate::ai::blocklist::inline_action::tool_pane::render_tool_pane_shell;
use crate::ai::blocklist::model::{AIBlockModel, AIBlockModelHelper};
use crate::ai::blocklist::{
@@ -57,9 +55,13 @@ use crate::cmd_or_ctrl_shift;
use crate::code::editor::view::{CodeEditorEvent, CodeEditorRenderOptions, CodeEditorView};
use crate::editor::InteractionState;
use crate::menu::{Event as MenuEvent, Menu, MenuItemFields, MenuVariant};
use crate::settings::InputModeSettings;
use crate::settings::{FontSettings, InputModeSettings};
use crate::terminal::block_list_viewport::InputMode;
use crate::terminal::blockgrid_element::TerminalGridSnapshotElement;
use crate::terminal::ligature_settings::should_use_ligature_rendering;
use crate::terminal::model::block::Block;
use crate::terminal::model::grid::Dimensions;
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
use crate::terminal::TerminalModel;
use crate::ui_components::blended_colors;
use crate::ui_components::json_tree::{JsonTreeState, PathSegment};
@@ -335,8 +337,6 @@ pub struct RequestedCommandView {
header_mouse_state: MouseStateHandle,
output_scroll_state: ClippedScrollStateHandle,
follow_command_output: Cell<bool>,
last_command_output_len: Cell<usize>,
last_output_scroll_start: Cell<f32>,
is_editing: bool,
// A requested command can either be copied directly off of one citation (such as a Warp Drive
@@ -597,8 +597,6 @@ impl RequestedCommandView {
header_mouse_state: Default::default(),
output_scroll_state: ClippedScrollStateHandle::new(),
follow_command_output: Cell::new(true),
last_command_output_len: Cell::new(0),
last_output_scroll_start: Cell::new(0.),
copied_from_citation: None,
derived_from_citations: Default::default(),
citation_state_handles: Default::default(),
@@ -714,9 +712,7 @@ impl RequestedCommandView {
self.is_header_expanded = value;
if value && self.action_type.is_requested_command() {
self.follow_command_output.set(true);
self.last_command_output_len.set(0);
self.last_output_scroll_start
.set(self.output_scroll_state.scroll_start().as_f32());
self.output_scroll_state.set_follow_end(true);
}
if is_user_initiated {
self.is_user_expanded = value;
@@ -781,6 +777,21 @@ impl RequestedCommandView {
self.is_header_expanded
}
fn is_user_controlling_command(&self) -> bool {
self.action_type.is_requested_command()
&& self
.terminal_model
.lock()
.block_list()
.block_for_ai_action_id(&self.action_id)
.is_some_and(|block| {
block.is_executing()
&& block
.long_running_control_state()
.is_some_and(LongRunningCommandControlState::is_user_in_control)
})
}
/// We use the requested command footer to show citations.
fn maybe_render_footer(&self, app: &AppContext) -> Option<Box<dyn Element>> {
let appearance = Appearance::as_ref(app);
@@ -1548,26 +1559,6 @@ impl View for RequestedCommandView {
let is_input_pinned_to_top =
*InputModeSettings::as_ref(app).input_mode.value() == InputMode::PinnedToTop;
// Tracks whether the expanded command has a terminal output block directly below it.
// Used to remove the bottom margin for visual continuity with the terminal block.
let is_rendered_above_expanded_command_block = {
let terminal_model = self.terminal_model.lock();
is_last_output_message_in_output
&& self.action_type.is_requested_command()
&& action_status.as_ref().is_some_and(|status| {
status.is_running() || (status.is_success() || status.is_failed())
})
&& !is_input_pinned_to_top
&& self.is_header_expanded
&& terminal_model
.block_list()
.is_requested_command_block_immediately_after_ai_block(
self.ai_block_view_id,
&self.action_id,
)
};
// When the requested command is expanded but there is no subsequent block containing
// command details beneath, then the command details must be rendered inline.
let should_render_editor = self.is_header_expanded
@@ -1583,47 +1574,56 @@ impl View for RequestedCommandView {
&& self.action_type.is_mcp_tool()
&& !self.command_text.is_empty();
// Requested command blocks are hidden terminal blocks. Keep their output in this pane
// instead of revealing the terminal block below the pane when the header is expanded.
let command_output = if self.is_header_expanded && self.action_type.is_requested_command() {
let terminal_model = self.terminal_model.lock();
terminal_model
.block_list()
.block_for_ai_action_id(&self.action_id)
.map(|block| {
let (command, output) = block.command_and_output_with_secret_obfuscated(false);
if output.is_empty() {
command
// Requested command blocks stay hidden terminal blocks. Snapshot their real terminal grid
// so ANSI styling, cursor state, alternate-screen TUIs, and images render inside this pane.
let command_output_grid =
if self.is_header_expanded && self.action_type.is_requested_command() {
let terminal_model = self.terminal_model.lock();
let requested_block = terminal_model
.block_list()
.block_for_ai_action_id(&self.action_id);
let active_block_owns_alt_screen = terminal_model.is_alt_screen_active()
&& terminal_model
.block_list()
.active_block()
.requested_command_action_id()
.is_some_and(|action_id| action_id == &self.action_id);
requested_block.and_then(|block| {
let (grid, row_count) = if active_block_owns_alt_screen {
let grid = terminal_model.alt_screen().grid_handler();
(grid.clone(), grid.visible_rows())
} else {
format!("{command}\n\n{output}")
}
let output_grid = block.output_grid();
(
output_grid.grid_handler().clone(),
output_grid.len_displayed(),
)
};
(row_count > 0).then_some((
grid,
row_count,
terminal_model.colors(),
terminal_model.override_colors(),
terminal_model.image_id_to_metadata.clone(),
))
})
.filter(|output| !output.is_empty())
} else {
None
};
} else {
None
};
if let Some(output) = command_output.as_ref() {
let scroll_start = self.output_scroll_state.scroll_start().as_f32();
let last_scroll_start = self.last_output_scroll_start.get();
// Keep following live command output until the user deliberately scrolls upward.
// Collapsing and reopening the pane resumes following from the bottom.
if self.follow_command_output.get() && scroll_start + 0.5 < last_scroll_start {
if command_output_grid.is_some() {
let is_at_bottom = self.output_scroll_state.is_scrolled_to_end();
if self.follow_command_output.get() && !is_at_bottom {
self.follow_command_output.set(false);
} else if !self.follow_command_output.get() && is_at_bottom {
self.follow_command_output.set(true);
}
if self.follow_command_output.get()
&& output.len() != self.last_command_output_len.get()
{
// Clipped scrollables clamp this sentinel to their actual maximum during layout.
self.output_scroll_state.scroll_to(f32::MAX.into_pixels());
}
self.last_command_output_len.set(output.len());
self.last_output_scroll_start.set(scroll_start);
self.output_scroll_state
.set_follow_end(self.follow_command_output.get());
}
let should_render_command_output = command_output.is_some();
let should_render_command_output = command_output_grid.is_some();
let has_citations_footer =
!self.derived_from_citations.is_empty() && !self.block_model.status(app).is_streaming();
@@ -1713,30 +1713,28 @@ impl View for RequestedCommandView {
);
}
if let Some(output) = command_output {
let output_text = render_requested_action_body_text(
output.as_str().into(),
appearance.monospace_font_family(),
if let Some((grid, row_count, colors, override_colors, image_metadata)) =
command_output_grid
{
let mut output_grid = TerminalGridSnapshotElement::new(
&grid,
row_count,
colors,
override_colors,
image_metadata,
appearance,
*FontSettings::as_ref(app).enforce_minimum_contrast,
get_secret_obfuscation_mode(app),
self.ai_block_view_id,
app,
)
.finish();
let content_selected_text = self.content_selected_text.clone();
let selectable_output = SelectableArea::new(
self.content_selection_handle.clone(),
#[allow(clippy::unwrap_used)]
move |selection_args, _, _| {
*content_selected_text.write().unwrap() = selection_args.selection;
},
output_text,
)
.on_selection_updated(|ctx, _| {
ctx.dispatch_typed_action(RequestedCommandViewAction::SelectText);
})
.finish();
);
if should_use_ligature_rendering(app) {
output_grid = output_grid.with_ligature_rendering();
}
let scrollable = NewScrollable::vertical(
SingleAxisConfig::Clipped {
handle: self.output_scroll_state.clone(),
child: selectable_output,
child: output_grid.finish(),
},
Fill::None,
Fill::None,
@@ -1765,18 +1763,17 @@ impl View for RequestedCommandView {
.as_ref()
.is_some_and(|status| status.is_blocked());
// If the requested command is expanded above a terminal block or
// the next exchange flows directly after, remove bottom margin for
// visual continuity.
let should_remove_bottom_margin = is_rendered_above_expanded_command_block
|| ((self.action_type.is_requested_command() || self.action_type.is_mcp_tool())
&& is_last_output_message_in_output
&& (BlocklistAIHistoryModel::as_ref(app)
.conversation(&self.client_ids.conversation_id)
.is_some_and(|conversation| {
// Prevents an issue where the bottom margin is removed when the requested command is the last message and we cancel it.
// We want to keep the margin in this case so that there's visual separation between the cancelled command and footer.
conversation.status() != &ConversationStatus::Cancelled
// If the next exchange flows directly after, remove bottom margin for visual continuity.
// The backing terminal command block remains hidden and does not participate in spacing.
let should_remove_bottom_margin = (self.action_type.is_requested_command()
|| self.action_type.is_mcp_tool())
&& is_last_output_message_in_output
&& (BlocklistAIHistoryModel::as_ref(app)
.conversation(&self.client_ids.conversation_id)
.is_some_and(|conversation| {
// Prevents an issue where the bottom margin is removed when the requested command is the last message and we cancel it.
// We want to keep the margin in this case so that there's visual separation between the cancelled command and footer.
conversation.status() != &ConversationStatus::Cancelled
// If the next exchange doesn't contain a user query, don't render bottom margin for continuity.
&& conversation
.root_task_exchanges()
@@ -1790,8 +1787,8 @@ impl View for RequestedCommandView {
.iter()
.any(|input| input.display_query().is_some())
})
}))
&& !is_input_pinned_to_top);
}))
&& !is_input_pinned_to_top;
let container = render_tool_pane_shell(
content.finish(),
@@ -1872,7 +1869,9 @@ impl TypedActionView for RequestedCommandView {
}
}
RequestedCommandViewAction::ToggleExpanded => {
self.set_is_header_expanded(!self.is_header_expanded, true, ctx)
if !self.is_user_controlling_command() {
self.set_is_header_expanded(!self.is_header_expanded, true, ctx);
}
}
RequestedCommandViewAction::OpenActiveAgentProfileEditor => {
ctx.emit(RequestedCommandViewEvent::OpenActiveAgentProfileEditor)