Improve local tool execution and shell output panes
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
¤t_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,
|
||||
¤t_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 =
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user