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
+4 -1
View File
@@ -834,7 +834,10 @@ pub(crate) fn convert_tool_call_result_to_input(
}) })
.collect(); .collect();
GrepResult::Success { matched_files } GrepResult::Success {
matched_files,
truncated: false,
}
} }
Some(api::grep_result::Result::Error(error)) => { Some(api::grep_result::Result::Error(error)) => {
GrepResult::Error(error.message.clone()) GrepResult::Error(error.message.clone())
+10 -1
View File
@@ -1343,11 +1343,20 @@ impl<'a> std::fmt::Display for MarkdownActionResult<'a> {
FileGlobV2Result::Cancelled => write!(f, "\n_File glob cancelled_"), FileGlobV2Result::Cancelled => write!(f, "\n_File glob cancelled_"),
}, },
AIAgentActionResultType::Grep(result) => match result { AIAgentActionResultType::Grep(result) => match result {
GrepResult::Success { matched_files } => { GrepResult::Success {
matched_files,
truncated,
} => {
write!(f, "\n\n**Grep Results:**\n\n")?; write!(f, "\n\n**Grep Results:**\n\n")?;
for file in matched_files { for file in matched_files {
writeln!(f, "- **{}**", file.file_path)?; writeln!(f, "- **{}**", file.file_path)?;
} }
if *truncated {
writeln!(
f,
"\n_Additional matches omitted; narrow the query or path._"
)?;
}
Ok(()) Ok(())
} }
GrepResult::Error(message) => { GrepResult::Error(message) => {
+8 -2
View File
@@ -143,10 +143,16 @@ pub mod text {
SearchCodebaseResult::Cancelled => todo!(), SearchCodebaseResult::Cancelled => todo!(),
}, },
AIAgentActionResultType::Grep(result) => match result { AIAgentActionResultType::Grep(result) => match result {
GrepResult::Success { matched_files } => { GrepResult::Success {
matched_files,
truncated,
} => {
for file in matched_files { for file in matched_files {
writeln!(w, "- {file}")?; writeln!(w, "- {file}")?;
} }
if *truncated {
writeln!(w, "Additional matches omitted; narrow the query or path.")?;
}
Ok(()) Ok(())
} }
GrepResult::Error(error) => writeln!(w, "grep failed: {error}"), GrepResult::Error(error) => writeln!(w, "grep failed: {error}"),
@@ -923,7 +929,7 @@ pub mod json {
SearchCodebaseResult::Cancelled => Some(JsonMessage::ToolCanceled), SearchCodebaseResult::Cancelled => Some(JsonMessage::ToolCanceled),
}, },
AIAgentActionResultType::Grep(result) => match result { AIAgentActionResultType::Grep(result) => match result {
GrepResult::Success { matched_files } => { GrepResult::Success { matched_files, .. } => {
use crate::ai::agent::GrepFileMatch; use crate::ai::agent::GrepFileMatch;
let files: Vec<JsonFile> = matched_files let files: Vec<JsonFile> = matched_files
.iter() .iter()
+1 -1
View File
@@ -743,7 +743,7 @@ fn tool_definition_for_name(name: &str) -> ToolDefinition {
}, },
"grep" => ToolDefinition { "grep" => ToolDefinition {
name: "grep".to_string(), name: "grep".to_string(),
description: "Search for patterns in files. Pass all search patterns in one call.".to_string(), description: "Search for patterns in files. Returns up to 1,000 matching line locations and reports when additional matches were omitted. Pass all search patterns in one call.".to_string(),
input_schema: serde_json::json!({ input_schema: serde_json::json!({
"type": "object", "type": "object",
"properties": { "properties": {
+1 -1
View File
@@ -1783,7 +1783,7 @@ pub fn default_tool_definitions() -> Vec<ToolDefinition> {
}, },
ToolDefinition { ToolDefinition {
name: "grep".to_string(), name: "grep".to_string(),
description: "Search for regex patterns in files. Uses git grep in git repos (respects .gitignore) or ripgrep otherwise. Returns file paths and matching line numbers. Use read_files afterward to see context around matches. Pass ALL patterns you need in one call.".to_string(), description: "Search for regex patterns in files. Uses git grep in git repos (respects .gitignore) or ripgrep otherwise. Returns up to 1,000 matching line locations and reports when additional matches were omitted. Use read_files afterward to see context around matches. Pass ALL patterns you need in one call.".to_string(),
input_schema: serde_json::json!({ input_schema: serde_json::json!({
"type": "object", "type": "object",
"properties": { "properties": {
+14 -2
View File
@@ -274,6 +274,18 @@ enum StartedAction {
Async { phase: RunningActionPhase }, 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. /// Returns whether another action may join the currently running phase.
/// ///
/// Parallel phases only admit additional actions that classify into the same group and /// Parallel phases only admit additional actions that classify into the same group and
@@ -1421,7 +1433,7 @@ impl BlocklistAIActionModel {
ActionExecutionInitiator::User, ActionExecutionInitiator::User,
ctx, 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); self.try_to_execute_available_actions(conversation_id, ctx);
} }
@@ -1441,7 +1453,7 @@ impl BlocklistAIActionModel {
ActionExecutionInitiator::User, ActionExecutionInitiator::User,
ctx, 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); 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; pub(super) mod wait_for_events;
use std::any::Any; use std::any::Any;
use std::collections::HashSet; use std::collections::{HashMap, HashSet};
use std::path::PathBuf; use std::path::PathBuf;
use std::pin::Pin; use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use ai::agent::action_result::{InsertReviewCommentsResult, RequestCommandOutputResult}; use ai::agent::action_result::{InsertReviewCommentsResult, RequestCommandOutputResult};
pub use ask_user_question::AskUserQuestionExecutor; 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::r#async::{Spawnable, SpawnableOutput};
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity}; use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use grep::GrepExecutor; use grep::GrepExecutor;
use instant::Instant;
#[cfg(feature = "local_fs")] #[cfg(feature = "local_fs")]
use mime_guess::from_path; use mime_guess::from_path;
use notebooks::NotebookExecutor; use notebooks::NotebookExecutor;
use parking_lot::FairMutex; use parking_lot::{FairMutex, Mutex};
use read_documents::ReadDocumentsExecutor; use read_documents::ReadDocumentsExecutor;
pub(super) use read_files::ReadFilesExecutor; pub(super) use read_files::ReadFilesExecutor;
use read_mcp_resource::ReadMCPResourceExecutor; 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::ai::{agent::AnyFileContent, paths::host_native_absolute_path};
use crate::terminal::model::session::active_session::ActiveSession; use crate::terminal::model::session::active_session::ActiveSession;
use crate::terminal::model::session::command_executor::shell_quote_arg; 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::model_events::ModelEventDispatcher;
use crate::terminal::shell::ShellType; use crate::terminal::shell::ShellType;
use crate::terminal::{ShellLaunchData, TerminalModel}; use crate::terminal::{ShellLaunchData, TerminalModel};
@@ -146,6 +148,34 @@ pub(super) enum RunningActionPhase {
Parallel(ParallelExecutionPolicy), 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)] #[derive(Debug, Clone, Copy)]
struct ExecuteActionInput<'a> { struct ExecuteActionInput<'a> {
action: &'a AIAgentAction, action: &'a AIAgentAction,
@@ -355,10 +385,21 @@ impl BlocklistAIActionExecutor {
let request_file_edits_executor = ctx.add_model(|ctx| { let request_file_edits_executor = ctx.add_model(|ctx| {
RequestFileEditsExecutor::new(active_session.clone(), terminal_view_id, ctx) RequestFileEditsExecutor::new(active_session.clone(), terminal_view_id, ctx)
}); });
let grep_executor = let git_repository_cache = GitRepositoryCache::default();
ctx.add_model(|_| GrepExecutor::new(active_session.clone(), terminal_view_id)); let grep_executor = ctx.add_model(|_| {
let file_glob_executor = GrepExecutor::new(
ctx.add_model(|_| FileGlobExecutor::new(active_session.clone(), terminal_view_id)); 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 let read_mcp_resource_executor = ctx
.add_model(|_| ReadMCPResourceExecutor::new(active_session.clone(), terminal_view_id)); .add_model(|_| ReadMCPResourceExecutor::new(active_session.clone(), terminal_view_id));
let call_mcp_tool_executor = 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. /// 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 { 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()); let command = build_is_file_path_command(path, session.shell().shell_type());
session session
.execute_command(&command, None, None, ExecuteCommandOptions::default()) .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()) 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( fn get_server_output_id(
conversation_id: AIConversationId, conversation_id: AIConversationId,
ctx: &mut AppContext, ctx: &mut AppContext,
@@ -26,13 +26,14 @@ use crate::{send_telemetry_from_app_ctx, TelemetryEvent};
const FILE_GLOB_TIMEOUT: Duration = Duration::from_secs(10); const FILE_GLOB_TIMEOUT: Duration = Duration::from_secs(10);
use super::{ use super::{
get_server_output_id, is_git_repository, ActionExecution, AnyActionExecution, get_server_output_id, is_git_repository_cached, ActionExecution, AnyActionExecution,
ExecuteActionInput, PreprocessActionInput, ExecuteActionInput, GitRepositoryCache, PreprocessActionInput,
}; };
pub struct FileGlobExecutor { pub struct FileGlobExecutor {
active_session: ModelHandle<ActiveSession>, active_session: ModelHandle<ActiveSession>,
terminal_view_id: EntityId, terminal_view_id: EntityId,
git_repository_cache: GitRepositoryCache,
} }
fn log_file_glob_error(conversation_id: AIConversationId, ctx: &mut AppContext) { 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 { 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 { Self {
active_session, active_session,
terminal_view_id, terminal_view_id,
git_repository_cache,
} }
} }
@@ -134,11 +140,18 @@ impl FileGlobExecutor {
let patterns_clone = patterns.clone(); let patterns_clone = patterns.clone();
let conversation_id_clone = input.conversation_id; let conversation_id_clone = input.conversation_id;
let is_file_glob_v2 = is_file_glob_v2(&input); let is_file_glob_v2 = is_file_glob_v2(&input);
let git_repository_cache = self.git_repository_cache.clone();
ActionExecution::new_async( ActionExecution::new_async(
async move { async move {
match run_file_glob(patterns_clone, absolute_path, session, shell_launch_data) match run_file_glob(
.with_timeout(FILE_GLOB_TIMEOUT) patterns_clone,
.await absolute_path,
session,
shell_launch_data,
git_repository_cache,
)
.with_timeout(FILE_GLOB_TIMEOUT)
.await
{ {
Ok(result) => result, Ok(result) => result,
Err(_) => Err(anyhow::anyhow!("File glob operation timed out")), Err(_) => Err(anyhow::anyhow!("File glob operation timed out")),
@@ -204,6 +217,7 @@ async fn run_file_glob(
absolute_path: String, absolute_path: String,
session: Option<Arc<Session>>, session: Option<Arc<Session>>,
shell_launch_data: Option<ShellLaunchData>, shell_launch_data: Option<ShellLaunchData>,
git_repository_cache: GitRepositoryCache,
) -> anyhow::Result<FileGlobV2Result> { ) -> anyhow::Result<FileGlobV2Result> {
if patterns.is_empty() { if patterns.is_empty() {
return Err(anyhow::anyhow!("No patterns provided to file_glob")); 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 shell_type = session.shell().shell_type();
let is_in_git_repo = is_git_repository(&absolute_path, session.as_ref()) let is_in_git_repo =
.await is_git_repository_cached(&absolute_path, session.as_ref(), &git_repository_cache)
.unwrap_or_else(|e| { .await
log::error!("Failed to run command to check if in git repository: {e:?}"); .unwrap_or_else(|e| {
false log::error!("Failed to run command to check if in git repository: {e:?}");
}); false
});
if is_in_git_repo { if is_in_git_repo {
run_git_ls_files_command( run_git_ls_files_command(
@@ -5,14 +5,14 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use futures::future::BoxFuture; use futures::future::BoxFuture;
use futures::FutureExt; use futures::{FutureExt, StreamExt};
use galaxy_util::standardized_path::StandardizedPath; use galaxy_util::standardized_path::StandardizedPath;
use galaxyui::r#async::FutureExt as AsyncFutureExt; use galaxyui::r#async::FutureExt as AsyncFutureExt;
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity}; use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use super::{ use super::{
get_server_output_id, is_file_path, is_git_repository, ActionExecution, AnyActionExecution, get_server_output_id, is_file_path, is_git_repository_cached, ActionExecution,
ExecuteActionInput, PreprocessActionInput, AnyActionExecution, ExecuteActionInput, GitRepositoryCache, PreprocessActionInput,
}; };
use crate::ai::agent::conversation::AIConversationId; use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::redaction::redact_secrets; use crate::ai::agent::redaction::redact_secrets;
@@ -30,6 +30,8 @@ use crate::terminal::ShellLaunchData;
use crate::{send_telemetry_from_app_ctx, PrivacySettings, TelemetryEvent}; use crate::{send_telemetry_from_app_ctx, PrivacySettings, TelemetryEvent};
const GREP_TIMEOUT: Duration = Duration::from_secs(10); 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"; 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 /// Information about the Grep call that resulted in an error, used to send
@@ -179,13 +181,19 @@ fn log_grep_error(
pub struct GrepExecutor { pub struct GrepExecutor {
active_session: ModelHandle<ActiveSession>, active_session: ModelHandle<ActiveSession>,
terminal_view_id: EntityId, terminal_view_id: EntityId,
git_repository_cache: GitRepositoryCache,
} }
impl GrepExecutor { 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 { Self {
active_session, active_session,
terminal_view_id, terminal_view_id,
git_repository_cache,
} }
} }
@@ -264,11 +272,18 @@ impl GrepExecutor {
let absolute_path_clone = absolute_path.clone(); let absolute_path_clone = absolute_path.clone();
let working_directory_clone = current_working_directory.clone(); let working_directory_clone = current_working_directory.clone();
let conversation_id_clone = input.conversation_id; let conversation_id_clone = input.conversation_id;
let git_repository_cache = self.git_repository_cache.clone();
ActionExecution::new_async( ActionExecution::new_async(
async move { async move {
match run_grep(queries_clone, absolute_path, session, shell_launch_data) match run_grep(
.with_timeout(GREP_TIMEOUT) queries_clone,
.await absolute_path,
session,
shell_launch_data,
git_repository_cache,
)
.with_timeout(GREP_TIMEOUT)
.await
{ {
Ok(result) => result, Ok(result) => result,
Err(_) => Err(GrepError::new("Grep operation timed out".to_string())), Err(_) => Err(GrepError::new("Grep operation timed out".to_string())),
@@ -345,6 +360,7 @@ async fn run_grep(
absolute_path: String, absolute_path: String,
session: Option<Arc<Session>>, session: Option<Arc<Session>>,
shell_launch_data: Option<ShellLaunchData>, shell_launch_data: Option<ShellLaunchData>,
git_repository_cache: GitRepositoryCache,
) -> Result<GrepResult, GrepError> { ) -> Result<GrepResult, GrepError> {
if queries.is_empty() { if queries.is_empty() {
return Err(GrepError::new("No queries provided to grep".to_string())); 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()) Cow::Borrowed(absolute_path.as_str())
}; };
// TODO(CODE-239): Cache the result of this check. let is_grep_in_git_repo =
let is_grep_in_git_repo = is_git_repository(&execute_directory, &session) is_git_repository_cached(&execute_directory, &session, &git_repository_cache)
.await .await
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
log::error!("Failed to run command to check if in git repository: {e:?}"); log::error!("Failed to run command to check if in git repository: {e:?}");
false false
}); });
let shell_type = session.shell().shell_type(); let shell_type = session.shell().shell_type();
// The most optimized tool to perform the search is `git grep`; // The most optimized tool to perform the search is `git grep`;
@@ -428,10 +444,13 @@ async fn run_grep(
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
async fn run_ripgrep(queries: &[String], absolute_path: String) -> Result<GrepResult, GrepError> { async fn run_ripgrep(queries: &[String], absolute_path: String) -> Result<GrepResult, GrepError> {
let path = PathBuf::from(absolute_path); 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 { match result {
Ok(matches) => { 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(); let mut files_map: HashMap<PathBuf, Vec<GrepLineMatch>> = HashMap::new();
for m in matches { for m in matches {
files_map files_map
@@ -448,7 +467,10 @@ async fn run_ripgrep(queries: &[String], absolute_path: String) -> Result<GrepRe
matched_lines, matched_lines,
}) })
.collect(); .collect();
Ok(GrepResult::Success { matched_files }) Ok(GrepResult::Success {
matched_files,
truncated,
})
} }
Err(e) => Err(GrepError::new(format!("Ripgrep search failed: {e}"))), Err(e) => Err(GrepError::new(format!("Ripgrep search failed: {e}"))),
} }
@@ -482,7 +504,10 @@ async fn run_git_grep_command(
shell_launch_data, shell_launch_data,
Some(execute_directory.to_string()), Some(execute_directory.to_string()),
) )
.map(|matched_files| GrepResult::Success { matched_files }) .map(|(matched_files, truncated)| GrepResult::Success {
matched_files,
truncated,
})
.map_err(|e| { .map_err(|e| {
GrepError::new(e.to_string()) GrepError::new(e.to_string())
.with_command(grep_command) .with_command(grep_command)
@@ -496,6 +521,7 @@ async fn run_git_grep_command(
// matches. // matches.
Ok(GrepResult::Success { Ok(GrepResult::Success {
matched_files: vec![], matched_files: vec![],
truncated: false,
}) })
} else { } else {
Err(GrepError::new_for_non_zero_exit_code() Err(GrepError::new_for_non_zero_exit_code()
@@ -531,7 +557,10 @@ async fn run_grep_command(
shell_launch_data, shell_launch_data,
Some(execute_directory.to_string()), Some(execute_directory.to_string()),
) )
.map(|matched_files| GrepResult::Success { matched_files }) .map(|(matched_files, truncated)| GrepResult::Success {
matched_files,
truncated,
})
.map_err(|e| { .map_err(|e| {
GrepError::new(e.to_string()) GrepError::new(e.to_string())
.with_command(grep_command) .with_command(grep_command)
@@ -545,6 +574,7 @@ async fn run_grep_command(
// matches. // matches.
Ok(GrepResult::Success { Ok(GrepResult::Success {
matched_files: vec![], matched_files: vec![],
truncated: false,
}) })
} else { } else {
Err(GrepError::new_for_non_zero_exit_code() Err(GrepError::new_for_non_zero_exit_code()
@@ -580,7 +610,10 @@ async fn run_select_string_command(
shell_launch_data, shell_launch_data,
Some(execute_directory.to_string()), Some(execute_directory.to_string()),
) )
.map(|matched_files| GrepResult::Success { matched_files }) .map(|(matched_files, truncated)| GrepResult::Success {
matched_files,
truncated,
})
.map_err(|e| { .map_err(|e| {
GrepError::new(e.to_string()) GrepError::new(e.to_string())
.with_command(select_string_command) .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 { 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). // 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 { for query in queries {
// Queries can originate from model output and project instructions. Keep // Queries can originate from model output and project instructions. Keep
// them as grep arguments so shell substitutions like $() are inert. // 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 // * "-I" ignores binary files
// * "-H" prints file name headers // * "-H" prints file name headers
// * "-E" uses extended regex expressions // * "-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 { for query in queries {
// Queries can originate from model output and project instructions. Keep // Queries can originate from model output and project instructions. Keep
// them as grep arguments so shell substitutions like $() are inert. // 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. // We enable the `-CaseSensitive` flag to match the default behavior of grep.
// TODO(CODE-239): Make this command more efficient when searching a file. // TODO(CODE-239): Make this command more efficient when searching a file.
format!( 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), shell_quote_arg(target_path, ShellType::PowerShell),
queries queries
.iter() .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. // strings, so patterns must be single-quoted data arguments.
.map(|q| shell_quote_arg(q, ShellType::PowerShell)) .map(|q| shell_quote_arg(q, ShellType::PowerShell))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(",") .join(","),
GREP_MAX_MATCHES + 1,
) )
} }
@@ -648,10 +685,11 @@ fn parse_grep_output(
output: &str, output: &str,
shell_launch_data: Option<ShellLaunchData>, shell_launch_data: Option<ShellLaunchData>,
current_working_directory: Option<String>, current_working_directory: Option<String>,
) -> anyhow::Result<Vec<GrepFileMatch>> { ) -> anyhow::Result<(Vec<GrepFileMatch>, bool)> {
let mut matched_files = HashMap::new(); 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 mut parts = line.split(":");
let file = parts.next(); let file = parts.next();
let line_number = parts.next(); let line_number = parts.next();
@@ -677,17 +715,24 @@ fn parse_grep_output(
.push(GrepLineMatch { line_number }); .push(GrepLineMatch { line_number });
} }
Ok(matched_files let truncated = lines.next().is_some()
.into_iter() || matched_files
.map(|(file, matched_lines)| GrepFileMatch { .values()
file_path: host_native_absolute_path( .any(|matched_lines| matched_lines.len() >= GREP_MAX_MATCHES_PER_FILE);
file, Ok((
&shell_launch_data, matched_files
&current_working_directory, .into_iter()
), .map(|(file, matched_lines)| GrepFileMatch {
matched_lines, file_path: host_native_absolute_path(
}) file,
.collect()) &shell_launch_data,
&current_working_directory,
),
matched_lines,
})
.collect(),
truncated,
))
} }
impl Entity for GrepExecutor { impl Entity for GrepExecutor {
@@ -81,7 +81,7 @@ fn build_git_grep_command_single_quotes_shell_substitution() {
assert_eq!( assert_eq!(
command, 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!( assert_eq!(
command, 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!( assert_eq!(
command, 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 std::path::{Path, PathBuf};
use futures::future::BoxFuture; use futures::future::{join_all, BoxFuture};
use futures::FutureExt; use futures::FutureExt;
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity}; use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
@@ -236,14 +236,24 @@ impl ReadFilesExecutor {
// Local path. // Local path.
ActionExecution::Async { ActionExecution::Async {
execute_future: Box::pin(async move { execute_future: Box::pin(async move {
let result = read_local_file_context( let reads = locations.iter().map(|location| {
&locations, read_local_file_context(
current_working_directory, std::slice::from_ref(location),
shell, current_working_directory.clone(),
None, shell.clone(),
None, None,
) None,
.await?; )
});
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() { if result.missing_files.is_empty() {
Ok(ReadFilesResult::Success { Ok(ReadFilesResult::Success {
files: result.file_contexts, 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] #[test]
fn phased_scheduling_stops_at_serial_barrier_and_resumes_afterward() { fn phased_scheduling_stops_at_serial_barrier_and_resumes_afterward() {
let read_only_phase = let read_only_phase =
+78 -27
View File
@@ -28,7 +28,7 @@ use std::sync::Arc;
use ai::agent::action::{AskUserQuestionItem, InsertReviewComment, RunAgentsRequest}; use ai::agent::action::{AskUserQuestionItem, InsertReviewComment, RunAgentsRequest};
use base64::Engine as _; use base64::Engine as _;
use chrono::Duration; use chrono::Duration;
use cli_controller::{CLISubagentController, CLISubagentEvent}; use cli_controller::{CLISubagentController, CLISubagentEvent, LongRunningCommandControlState};
use find::FindState; use find::FindState;
use galaxy_agent_core::RuntimeActivityStatus; use galaxy_agent_core::RuntimeActivityStatus;
use galaxy_core::features::FeatureFlag; use galaxy_core::features::FeatureFlag;
@@ -1445,18 +1445,38 @@ impl AIBlock {
initial_requested_command_action_id: Some(initial_requested_command_action_id), 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 { CLISubagentEvent::UpdatedControl {
requested_command_action_id: Some(requested_command_action_id), requested_command_action_id: Some(requested_command_action_id),
agent_has_control,
.. ..
} => { } => {
if let Some(requested_command_view) = if *agent_has_control {
me.requested_commands.get(requested_command_action_id) me.expand_requested_command_view(requested_command_action_id, ctx);
{ } else {
requested_command_view me.requested_commands_to_auto_collapse
.view .remove(requested_command_action_id);
.update(ctx, |_, ctx| ctx.notify()); 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| { ctx.subscribe_to_model(model_event_dispatcher, |me, _, event, ctx| {
if let ModelEvent::BlockCompleted(block_completed_event) = event { if let ModelEvent::BlockCompleted(block_completed_event) = event {
let terminal_model = me.terminal_model.lock(); let (requested_action_id, should_restore_takeover) = {
if terminal_model let mut terminal_model = me.terminal_model.lock();
.block_list() let command_metadata = terminal_model
.block_with_id(&block_completed_event.block_id) .block_list()
.and_then(|block| block.agent_interaction_metadata()) .block_with_id(&block_completed_event.block_id)
.is_some_and(|metadata| { .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 metadata
.requested_command_action_id() .long_running_control_state()
.is_some_and(|id| me.requested_action_ids.contains(id)) .is_some_and(LongRunningCommandControlState::is_user_in_control)
}) });
{ if should_restore {
ctx.notify(); 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 .action_model
.as_ref(ctx) .as_ref(ctx)
.get_action_status(self.client_ids.conversation_id, action_id); .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(); let terminal_model = self.terminal_model.lock();
terminal_model let command_block = terminal_model
.block_list() .block_list()
.block_for_ai_action_id(action_id) .block_for_ai_action_id(action_id);
.is_some_and(|block| block.finished()) (
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 if !has_finished_command_block
&& !action_status.is_some_and(|a| a.is_running() || a.is_done()) && !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); self.requested_commands_to_auto_collapse.remove(action_id);
} }
// Requested-command output is rendered by the inline pane whenever it is // Agent-owned command output stays contained in the inline pane. During user
// expanded. Keep the backing terminal block hidden in both states so collapsing // takeover, the same live backing terminal block becomes the interactive surface.
// the pane does not move the output to the bottom of the conversation.
ctx.emit(AIBlockEvent::UpdateInlineActionVisibility { ctx.emit(AIBlockEvent::UpdateInlineActionVisibility {
action_id: action_id.clone(), action_id: action_id.clone(),
is_visible: false, is_visible: user_controls_command && !has_finished_command_block,
}); });
ctx.notify(); ctx.notify();
} }
@@ -6105,6 +6147,15 @@ impl AIBlock {
self.requested_commands.iter() 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 /// Collects all imported review comments stored in this block
/// and the base branch (if any). Returns `None` when this block has no imported comments. /// and the base branch (if any). Returns `None` when this block has no imported comments.
pub(crate) fn collect_imported_comments(&self) -> Option<ImportedBlockComments> { 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 action_id = active_block.requested_command_action_id().cloned();
let conversation_id = active_block.ai_conversation_id(); let conversation_id = active_block.ai_conversation_id();
let agent_has_control = active_block.is_agent_in_control(); 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 // 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 // cancelled action is a shell command action, so we have to drop the terminal
// model lock before actually cancelling the conversation. // model lock before actually cancelling the conversation.
@@ -775,6 +780,11 @@ impl CLISubagentController {
); );
let action_id = active_block.requested_command_action_id().cloned(); let action_id = active_block.requested_command_action_id().cloned();
let agent_has_control = active_block.is_agent_in_control(); 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); drop(terminal_model);
if let Some(agent_view_controller) = &self.agent_view_controller { if let Some(agent_view_controller) = &self.agent_view_controller {
agent_view_controller.update(ctx, |controller, ctx| { agent_view_controller.update(ctx, |controller, ctx| {
@@ -17,7 +17,6 @@ use galaxyui::elements::{
}; };
use galaxyui::keymap::{Context, EditableBinding, FixedBinding, Keystroke}; use galaxyui::keymap::{Context, EditableBinding, FixedBinding, Keystroke};
use galaxyui::ui_components::components::UiComponent as _; use galaxyui::ui_components::components::UiComponent as _;
use galaxyui::units::IntoPixels;
use galaxyui::{ use galaxyui::{
AppContext, Element, Entity, EntityId, EventContext, ModelHandle, SingletonEntity, AppContext, Element, Entity, EntityId, EventContext, ModelHandle, SingletonEntity,
TypedActionView, UpdateView, View, ViewContext, ViewHandle, TypedActionView, UpdateView, View, ViewContext, ViewHandle,
@@ -46,7 +45,6 @@ use crate::ai::blocklist::inline_action::inline_action_header::{
ExpandedConfig, HeaderConfig, InteractionMode, RightClickConfig, ExpandedConfig, HeaderConfig, InteractionMode, RightClickConfig,
INLINE_ACTION_HORIZONTAL_PADDING, 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::inline_action::tool_pane::render_tool_pane_shell;
use crate::ai::blocklist::model::{AIBlockModel, AIBlockModelHelper}; use crate::ai::blocklist::model::{AIBlockModel, AIBlockModelHelper};
use crate::ai::blocklist::{ use crate::ai::blocklist::{
@@ -57,9 +55,13 @@ use crate::cmd_or_ctrl_shift;
use crate::code::editor::view::{CodeEditorEvent, CodeEditorRenderOptions, CodeEditorView}; use crate::code::editor::view::{CodeEditorEvent, CodeEditorRenderOptions, CodeEditorView};
use crate::editor::InteractionState; use crate::editor::InteractionState;
use crate::menu::{Event as MenuEvent, Menu, MenuItemFields, MenuVariant}; 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::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::block::Block;
use crate::terminal::model::grid::Dimensions;
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
use crate::terminal::TerminalModel; use crate::terminal::TerminalModel;
use crate::ui_components::blended_colors; use crate::ui_components::blended_colors;
use crate::ui_components::json_tree::{JsonTreeState, PathSegment}; use crate::ui_components::json_tree::{JsonTreeState, PathSegment};
@@ -335,8 +337,6 @@ pub struct RequestedCommandView {
header_mouse_state: MouseStateHandle, header_mouse_state: MouseStateHandle,
output_scroll_state: ClippedScrollStateHandle, output_scroll_state: ClippedScrollStateHandle,
follow_command_output: Cell<bool>, follow_command_output: Cell<bool>,
last_command_output_len: Cell<usize>,
last_output_scroll_start: Cell<f32>,
is_editing: bool, is_editing: bool,
// A requested command can either be copied directly off of one citation (such as a Warp Drive // 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(), header_mouse_state: Default::default(),
output_scroll_state: ClippedScrollStateHandle::new(), output_scroll_state: ClippedScrollStateHandle::new(),
follow_command_output: Cell::new(true), follow_command_output: Cell::new(true),
last_command_output_len: Cell::new(0),
last_output_scroll_start: Cell::new(0.),
copied_from_citation: None, copied_from_citation: None,
derived_from_citations: Default::default(), derived_from_citations: Default::default(),
citation_state_handles: Default::default(), citation_state_handles: Default::default(),
@@ -714,9 +712,7 @@ impl RequestedCommandView {
self.is_header_expanded = value; self.is_header_expanded = value;
if value && self.action_type.is_requested_command() { if value && self.action_type.is_requested_command() {
self.follow_command_output.set(true); self.follow_command_output.set(true);
self.last_command_output_len.set(0); self.output_scroll_state.set_follow_end(true);
self.last_output_scroll_start
.set(self.output_scroll_state.scroll_start().as_f32());
} }
if is_user_initiated { if is_user_initiated {
self.is_user_expanded = value; self.is_user_expanded = value;
@@ -781,6 +777,21 @@ impl RequestedCommandView {
self.is_header_expanded 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. /// We use the requested command footer to show citations.
fn maybe_render_footer(&self, app: &AppContext) -> Option<Box<dyn Element>> { fn maybe_render_footer(&self, app: &AppContext) -> Option<Box<dyn Element>> {
let appearance = Appearance::as_ref(app); let appearance = Appearance::as_ref(app);
@@ -1548,26 +1559,6 @@ impl View for RequestedCommandView {
let is_input_pinned_to_top = let is_input_pinned_to_top =
*InputModeSettings::as_ref(app).input_mode.value() == InputMode::PinnedToTop; *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 // When the requested command is expanded but there is no subsequent block containing
// command details beneath, then the command details must be rendered inline. // command details beneath, then the command details must be rendered inline.
let should_render_editor = self.is_header_expanded let should_render_editor = self.is_header_expanded
@@ -1583,47 +1574,56 @@ impl View for RequestedCommandView {
&& self.action_type.is_mcp_tool() && self.action_type.is_mcp_tool()
&& !self.command_text.is_empty(); && !self.command_text.is_empty();
// Requested command blocks are hidden terminal blocks. Keep their output in this pane // Requested command blocks stay hidden terminal blocks. Snapshot their real terminal grid
// instead of revealing the terminal block below the pane when the header is expanded. // so ANSI styling, cursor state, alternate-screen TUIs, and images render inside this pane.
let command_output = if self.is_header_expanded && self.action_type.is_requested_command() { let command_output_grid =
let terminal_model = self.terminal_model.lock(); if self.is_header_expanded && self.action_type.is_requested_command() {
terminal_model let terminal_model = self.terminal_model.lock();
.block_list() let requested_block = terminal_model
.block_for_ai_action_id(&self.action_id) .block_list()
.map(|block| { .block_for_ai_action_id(&self.action_id);
let (command, output) = block.command_and_output_with_secret_obfuscated(false); let active_block_owns_alt_screen = terminal_model.is_alt_screen_active()
if output.is_empty() { && terminal_model
command .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 { } 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 {
} else { None
None };
};
if let Some(output) = command_output.as_ref() { if command_output_grid.is_some() {
let scroll_start = self.output_scroll_state.scroll_start().as_f32(); let is_at_bottom = self.output_scroll_state.is_scrolled_to_end();
let last_scroll_start = self.last_output_scroll_start.get(); if self.follow_command_output.get() && !is_at_bottom {
// 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 {
self.follow_command_output.set(false); 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() self.output_scroll_state
&& output.len() != self.last_command_output_len.get() .set_follow_end(self.follow_command_output.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);
} }
let should_render_command_output = command_output.is_some(); let should_render_command_output = command_output_grid.is_some();
let has_citations_footer = let has_citations_footer =
!self.derived_from_citations.is_empty() && !self.block_model.status(app).is_streaming(); !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 { if let Some((grid, row_count, colors, override_colors, image_metadata)) =
let output_text = render_requested_action_body_text( command_output_grid
output.as_str().into(), {
appearance.monospace_font_family(), 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, app,
) );
.finish(); if should_use_ligature_rendering(app) {
let content_selected_text = self.content_selected_text.clone(); output_grid = output_grid.with_ligature_rendering();
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();
let scrollable = NewScrollable::vertical( let scrollable = NewScrollable::vertical(
SingleAxisConfig::Clipped { SingleAxisConfig::Clipped {
handle: self.output_scroll_state.clone(), handle: self.output_scroll_state.clone(),
child: selectable_output, child: output_grid.finish(),
}, },
Fill::None, Fill::None,
Fill::None, Fill::None,
@@ -1765,18 +1763,17 @@ impl View for RequestedCommandView {
.as_ref() .as_ref()
.is_some_and(|status| status.is_blocked()); .is_some_and(|status| status.is_blocked());
// If the requested command is expanded above a terminal block or // If the next exchange flows directly after, remove bottom margin for visual continuity.
// the next exchange flows directly after, remove bottom margin for // The backing terminal command block remains hidden and does not participate in spacing.
// visual continuity. let should_remove_bottom_margin = (self.action_type.is_requested_command()
let should_remove_bottom_margin = is_rendered_above_expanded_command_block || self.action_type.is_mcp_tool())
|| ((self.action_type.is_requested_command() || self.action_type.is_mcp_tool()) && is_last_output_message_in_output
&& is_last_output_message_in_output && (BlocklistAIHistoryModel::as_ref(app)
&& (BlocklistAIHistoryModel::as_ref(app) .conversation(&self.client_ids.conversation_id)
.conversation(&self.client_ids.conversation_id) .is_some_and(|conversation| {
.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.
// 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.
// 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
conversation.status() != &ConversationStatus::Cancelled
// If the next exchange doesn't contain a user query, don't render bottom margin for continuity. // If the next exchange doesn't contain a user query, don't render bottom margin for continuity.
&& conversation && conversation
.root_task_exchanges() .root_task_exchanges()
@@ -1790,8 +1787,8 @@ impl View for RequestedCommandView {
.iter() .iter()
.any(|input| input.display_query().is_some()) .any(|input| input.display_query().is_some())
}) })
})) }))
&& !is_input_pinned_to_top); && !is_input_pinned_to_top;
let container = render_tool_pane_shell( let container = render_tool_pane_shell(
content.finish(), content.finish(),
@@ -1872,7 +1869,9 @@ impl TypedActionView for RequestedCommandView {
} }
} }
RequestedCommandViewAction::ToggleExpanded => { 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 => { RequestedCommandViewAction::OpenActiveAgentProfileEditor => {
ctx.emit(RequestedCommandViewEvent::OpenActiveAgentProfileEditor) ctx.emit(RequestedCommandViewEvent::OpenActiveAgentProfileEditor)
+1 -1
View File
@@ -136,7 +136,7 @@ fn apply_file_diffs() -> ToolDefinition {
fn grep() -> ToolDefinition { fn grep() -> ToolDefinition {
ToolDefinition { ToolDefinition {
name: "grep".to_string(), name: "grep".to_string(),
description: "Search for up to 3 focused regex patterns per call. Uses git grep in git repos (respects .gitignore) or ripgrep otherwise. Returns file paths and matching line numbers. Use read_files afterward to see context around matches.".to_string(), description: "Search for up to 3 focused regex patterns per call. Uses git grep in git repos (respects .gitignore) or ripgrep otherwise. Returns up to 1,000 matching line locations and reports when additional matches were omitted. Use read_files afterward to see context around matches.".to_string(),
input_schema: serde_json::json!({ input_schema: serde_json::json!({
"type": "object", "type": "object",
"properties": { "properties": {
+213 -1
View File
@@ -1,17 +1,26 @@
use std::collections::HashMap;
use galaxyui::elements::{ use galaxyui::elements::{
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext, Point, AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext, Point,
SizeConstraint, SizeConstraint,
}; };
use galaxyui::event::DispatchedEvent; use galaxyui::event::DispatchedEvent;
use galaxyui::fonts::Properties;
use galaxyui::geometry::rect::RectF; use galaxyui::geometry::rect::RectF;
use galaxyui::units::IntoPixels;
use galaxyui::EntityId;
use pathfinder_geometry::vector::{vec2f, Vector2F}; use pathfinder_geometry::vector::{vec2f, Vector2F};
use super::blockgrid_renderer::GridRenderParams; use super::blockgrid_renderer::GridRenderParams;
use crate::appearance::Appearance; use crate::appearance::Appearance;
use crate::settings::EnforceMinimumContrast; use crate::settings::EnforceMinimumContrast;
use crate::terminal::blockgrid_renderer::BlockGridParams; use crate::terminal::blockgrid_renderer::BlockGridParams;
use crate::terminal::grid_renderer::{self, CellGlyphCache};
use crate::terminal::grid_size_util::grid_cell_dimensions;
use crate::terminal::model::blockgrid::BlockGrid; use crate::terminal::model::blockgrid::BlockGrid;
use crate::terminal::model::grid::Dimensions; use crate::terminal::model::grid::grid_handler::{GridHandler, TermMode};
use crate::terminal::model::grid::{Dimensions, RespectDisplayedOutput};
use crate::terminal::model::image_map::StoredImageMetadata;
use crate::terminal::model::ObfuscateSecrets; use crate::terminal::model::ObfuscateSecrets;
use crate::terminal::{color, SizeInfo}; use crate::terminal::{color, SizeInfo};
@@ -119,3 +128,206 @@ impl Element for BlockGridElement {
self.origin self.origin
} }
} }
/// A paint-only snapshot of a live terminal grid.
///
/// Unlike [`BlockGridElement`], this can render both normal command output and an alternate-screen
/// grid. It deliberately owns a snapshot so the terminal model lock never spans layout or paint.
pub struct TerminalGridSnapshotElement {
grid: GridHandler,
row_count: usize,
colors: color::List,
override_colors: color::OverrideList,
image_metadata: HashMap<u32, StoredImageMetadata>,
grid_render_params: GridRenderParams,
terminal_view_id: EntityId,
natural_size: Vector2F,
size: Vector2F,
origin: Option<Point>,
bounds: Option<RectF>,
}
impl TerminalGridSnapshotElement {
#[allow(clippy::too_many_arguments)]
pub fn new(
grid: &GridHandler,
row_count: usize,
colors: color::List,
override_colors: color::OverrideList,
image_metadata: HashMap<u32, StoredImageMetadata>,
appearance: &Appearance,
enforce_minimum_contrast: EnforceMinimumContrast,
obfuscate_secrets: ObfuscateSecrets,
terminal_view_id: EntityId,
app: &AppContext,
) -> Self {
let cell_size = grid_cell_dimensions(
app.font_cache(),
appearance.monospace_font_family(),
appearance.monospace_font_size(),
appearance.line_height_ratio(),
);
let size = vec2f(
grid.columns() as f32 * cell_size.x(),
row_count as f32 * cell_size.y(),
);
let size_info = SizeInfo::new(
size,
cell_size.x().into_pixels(),
cell_size.y().into_pixels(),
0.0.into_pixels(),
0.0.into_pixels(),
)
.with_rows_and_columns(row_count, grid.columns());
Self {
grid: grid.clone(),
row_count,
colors,
override_colors,
image_metadata,
grid_render_params: GridRenderParams {
warp_theme: appearance.theme().clone(),
font_family: appearance.monospace_font_family(),
font_size: appearance.monospace_font_size(),
font_weight: appearance.monospace_font_weight(),
line_height_ratio: appearance.line_height_ratio(),
enforce_minimum_contrast,
obfuscate_secrets,
size_info,
cell_size,
use_ligature_rendering: false,
hide_cursor_cell: false,
},
terminal_view_id,
natural_size: size,
size,
origin: None,
bounds: None,
}
}
pub fn with_ligature_rendering(mut self) -> Self {
self.grid_render_params.use_ligature_rendering = true;
self
}
}
impl Element for TerminalGridSnapshotElement {
fn layout(
&mut self,
constraint: SizeConstraint,
_ctx: &mut LayoutContext,
_app: &AppContext,
) -> Vector2F {
// The parent clips and scrolls this element vertically, so preserve the full terminal
// height here. Clamping it to the viewport makes the scroll container believe rows below
// the fold do not exist, which also prevents its bottom-follow behavior from engaging.
self.size = vec2f(
self.natural_size
.x()
.min(constraint.max.x())
.max(constraint.min.x()),
self.natural_size.y().max(constraint.min.y()),
);
self.size
}
fn after_layout(&mut self, _ctx: &mut AfterLayoutContext, _app: &AppContext) {}
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
self.bounds = Some(RectF::new(origin, self.size));
let Some(visible_bounds) = ctx.scene.visible_rect(
self.origin.expect("origin was set immediately above"),
self.size,
) else {
return;
};
let row_height = self.grid_render_params.cell_size.y();
let start_row = ((visible_bounds.min_y() - origin.y()) / row_height)
.floor()
.max(0.) as usize;
let end_row = ((visible_bounds.max_y() - origin.y()) / row_height)
.ceil()
.max(0.) as usize;
let start_row = start_row.min(self.row_count);
let end_row = end_row.min(self.row_count);
let mut glyphs = CellGlyphCache::default();
let cursor_visible = self.grid.is_mode_set(TermMode::SHOW_CURSOR)
&& (start_row..end_row).contains(&self.grid.cursor_render_point().row);
let cursor_style = self.grid.cursor_style();
let obfuscate_secrets = self
.grid_render_params
.obfuscate_secrets
.and(&self.grid.get_secret_obfuscation());
grid_renderer::render_grid(
&self.grid,
start_row,
end_row,
&self.colors,
&self.override_colors,
&self.grid_render_params.warp_theme,
Properties::default().weight(self.grid_render_params.font_weight),
self.grid_render_params.font_family,
self.grid_render_params.font_size,
self.grid_render_params.line_height_ratio,
self.grid_render_params.cell_size,
self.grid_render_params.size_info.padding_x_px(),
origin,
&mut glyphs,
255,
None,
None,
None::<std::iter::Empty<&std::ops::RangeInclusive<crate::terminal::model::index::Point>>>,
None,
self.grid_render_params.enforce_minimum_contrast,
obfuscate_secrets,
None,
self.grid_render_params.use_ligature_rendering,
cursor_visible.then_some(cursor_style.shape),
RespectDisplayedOutput::Yes,
&self.image_metadata,
None,
false,
ctx,
app,
);
if cursor_visible {
grid_renderer::render_cursor(
&self.grid_render_params,
self.grid.cursor_render_point(),
self.grid.is_cursor_on_wide_char(),
cursor_style,
self.grid_render_params.size_info.padding_x_px(),
origin,
self.grid_render_params.warp_theme.cursor().into(),
ctx,
self.terminal_view_id,
None,
app,
);
}
}
fn dispatch_event(
&mut self,
_event: &DispatchedEvent,
_ctx: &mut EventContext,
_app: &AppContext,
) -> bool {
false
}
fn size(&self) -> Option<Vector2F> {
Some(self.size)
}
fn origin(&self) -> Option<Point> {
self.origin
}
}
@@ -346,15 +346,20 @@ impl InteractionMode {
task_id: &TaskId, task_id: &TaskId,
conversation_id: AIConversationId, conversation_id: AIConversationId,
) -> Result<Self, UpdateInteractionModeError> { ) -> Result<Self, UpdateInteractionModeError> {
let requested_command_action_id = match self { let (requested_command_action_id, has_agent_written_to_block, should_hide_block) =
InteractionMode::User(_) => None, match self {
InteractionMode::Agent(metadata) => { InteractionMode::User(_) => (None, false, false),
if metadata.conversation_id != conversation_id { InteractionMode::Agent(metadata) => {
return Err(UpdateInteractionModeError::UnexpectedConversationId); if metadata.conversation_id != conversation_id {
return Err(UpdateInteractionModeError::UnexpectedConversationId);
}
(
metadata.requested_command_action_id.clone(),
metadata.has_agent_written_to_block,
metadata.should_hide_block,
)
} }
metadata.requested_command_action_id.clone() };
}
};
Ok(Self::Agent(AgentInteractionMetadata { Ok(Self::Agent(AgentInteractionMetadata {
requested_command_action_id, requested_command_action_id,
@@ -364,8 +369,8 @@ impl InteractionMode {
is_blocked: false, is_blocked: false,
should_hide_responses: false, should_hide_responses: false,
}), }),
has_agent_written_to_block: false, has_agent_written_to_block,
should_hide_block: false, should_hide_block,
})) }))
} }
+57 -8
View File
@@ -6791,7 +6791,18 @@ impl TerminalView {
agent_has_control, agent_has_control,
.. ..
} => { } => {
self.redetermine_terminal_focus(ctx); if !*agent_has_control && ctx.is_self_or_child_focused() {
let block_index = self.model.lock().block_list().block_index_for_id(block_id);
if let Some(block_index) = block_index {
self.update_scroll_position_locking(
ScrollPositionUpdate::ScrollToBottomOfBlock { block_index },
ctx,
);
}
self.focus_terminal(ctx);
} else {
self.redetermine_terminal_focus(ctx);
}
self.emit_long_running_command_agent_interaction_state_changed( self.emit_long_running_command_agent_interaction_state_changed(
*agent_has_control, *agent_has_control,
block_id.clone(), block_id.clone(),
@@ -9744,6 +9755,7 @@ impl TerminalView {
model.block_list_mut().update_active_block_height(); model.block_list_mut().update_active_block_height();
} }
self.maybe_emit_terminal_view_state_changed_for_long_running_block(ctx); self.maybe_emit_terminal_view_state_changed_for_long_running_block(ctx);
self.notify_active_requested_command_output(ctx);
self.use_agent_footer.update(ctx, |footer, ctx| { self.use_agent_footer.update(ctx, |footer, ctx| {
footer.notify_and_notify_children(ctx); footer.notify_and_notify_children(ctx);
}); });
@@ -9752,6 +9764,47 @@ impl TerminalView {
ctx.notify(); ctx.notify();
} }
/// Invalidates the nested tool pane that owns the active requested-command block.
///
/// `TerminalView` and `RequestedCommandView` are independently cached views. A PTY wakeup
/// invalidates the former, but without this targeted notification the inline pane only sees
/// new output when a slower action or conversation event happens to invalidate it.
fn notify_active_requested_command_output(&self, ctx: &mut ViewContext<Self>) {
let active_command = {
let model = self.model.lock();
model
.block_list()
.active_block()
.agent_interaction_metadata()
.and_then(|metadata| {
metadata
.requested_command_action_id()
.cloned()
.map(|action_id| (action_id, *metadata.conversation_id()))
})
};
let Some((action_id, conversation_id)) = active_command else {
return;
};
let requested_command_view = self
.rich_content_views
.iter()
.rev()
.filter_map(|rich_content| rich_content.ai_block_metadata())
.filter(|metadata| metadata.conversation_id == conversation_id)
.find_map(|metadata| {
metadata
.ai_block_handle
.as_ref(ctx)
.requested_command_view(&action_id)
});
if let Some(requested_command_view) = requested_command_view {
requested_command_view.update(ctx, |_, ctx| ctx.notify());
}
}
/// This function is invoked whenever we detect an SSH ControlMaster error, /// This function is invoked whenever we detect an SSH ControlMaster error,
/// in which case completions will not work as expected. /// in which case completions will not work as expected.
fn handle_control_master_error(&mut self, ctx: &mut ViewContext<Self>) { fn handle_control_master_error(&mut self, ctx: &mut ViewContext<Self>) {
@@ -18951,9 +19004,9 @@ impl TerminalView {
// Otherwise, if the agent is monitoring this long-running block, // Otherwise, if the agent is monitoring this long-running block,
// then clear just that block and leave the rest of the blocklist in tact. // then clear just that block and leave the rest of the blocklist in tact.
self.model.lock().clear_screen(ClearMode::ActiveBlock); self.model.lock().clear_screen(ClearMode::ActiveBlock);
// In the agent-driving-but-not-monitoring state the terminal block is hidden. Make // In the agent-driving-but-not-monitoring state the terminal block is hidden.
// it visible and expand the header immediately so the user sees the cleared state // Expand its inline tool pane immediately so the user sees the cleared state there,
// right away, rather than waiting for the auto-expand timer (~3 s). // rather than revealing a duplicate terminal block below the conversation.
if is_agent_driving_command && !is_agent_monitoring { if is_agent_driving_command && !is_agent_monitoring {
let requested_command_action_id = self let requested_command_action_id = self
.model .model
@@ -18963,10 +19016,6 @@ impl TerminalView {
.requested_command_action_id() .requested_command_action_id()
.cloned(); .cloned();
if let Some(action_id) = &requested_command_action_id { if let Some(action_id) = &requested_command_action_id {
self.model
.lock()
.block_list_mut()
.set_visibility_of_block_for_ai_action(action_id, true);
if let Some(ai_block_handle) = self.active_ai_block(ctx).cloned() { if let Some(ai_block_handle) = self.active_ai_block(ctx).cloned() {
ai_block_handle.update(ctx, |ai_block, ctx| { ai_block_handle.update(ctx, |ai_block, ctx| {
ai_block.expand_requested_command_view(action_id, ctx); ai_block.expand_requested_command_view(action_id, ctx);
+1 -1
View File
@@ -448,7 +448,7 @@ impl TryFrom<GrepResult> for api::request::input::tool_call_result::Result {
fn try_from(result: GrepResult) -> Result<Self, Self::Error> { fn try_from(result: GrepResult) -> Result<Self, Self::Error> {
match result { match result {
GrepResult::Success { matched_files } => Ok( GrepResult::Success { matched_files, .. } => Ok(
api::request::input::tool_call_result::Result::Grep(api::GrepResult { api::request::input::tool_call_result::Result::Grep(api::GrepResult {
result: Some(api::grep_result::Result::Success( result: Some(api::grep_result::Result::Success(
api::grep_result::Success { api::grep_result::Success {
+26 -6
View File
@@ -1166,15 +1166,22 @@ impl AIAgentActionResultType {
Self::RequestFileEdits(RequestFileEditsResult::Cancelled) => { Self::RequestFileEdits(RequestFileEditsResult::Cancelled) => {
"apply_file_diffs: cancelled".to_string() "apply_file_diffs: cancelled".to_string()
} }
Self::Grep(GrepResult::Success { matched_files }) => { Self::Grep(GrepResult::Success {
format!( matched_files,
truncated,
}) => {
let mut summary = format!(
"grep: [{}]", "grep: [{}]",
matched_files matched_files
.iter() .iter()
.map(|f| f.file_path.as_str()) .map(|f| f.file_path.as_str())
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", ") .join(", ")
) );
if *truncated {
summary.push_str(" (additional matches omitted)");
}
summary
} }
Self::Grep(GrepResult::Error(error)) => format!("grep: error={error}"), Self::Grep(GrepResult::Error(error)) => format!("grep: error={error}"),
Self::Grep(GrepResult::Cancelled) => "grep: cancelled".to_string(), Self::Grep(GrepResult::Cancelled) => "grep: cancelled".to_string(),
@@ -1294,7 +1301,10 @@ impl AIAgentActionResultType {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum GrepResult { pub enum GrepResult {
Success { matched_files: Vec<GrepFileMatch> }, Success {
matched_files: Vec<GrepFileMatch>,
truncated: bool,
},
Error(String), Error(String),
Cancelled, Cancelled,
} }
@@ -1302,12 +1312,22 @@ pub enum GrepResult {
impl Display for GrepResult { impl Display for GrepResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
GrepResult::Success { matched_files } => { GrepResult::Success {
matched_files,
truncated,
} => {
write!( write!(
f, f,
"Grep found matches in: [{}]", "Grep found matches in: [{}]",
matched_files.iter().format(", ") matched_files.iter().format(", ")
) )?;
if *truncated {
write!(
f,
". Additional matches were omitted; narrow the query or path to retrieve them"
)?;
}
Ok(())
} }
GrepResult::Error(error) => write!(f, "Grep error: {error}"), GrepResult::Error(error) => write!(f, "Grep error: {error}"),
GrepResult::Cancelled => write!(f, "Grep cancelled"), GrepResult::Cancelled => write!(f, "Grep cancelled"),
@@ -38,6 +38,8 @@ pub struct ScrollTarget {
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub struct ClippedScrollData { pub struct ClippedScrollData {
scroll_start_px: Pixels, scroll_start_px: Pixels,
max_scroll_start_px: Pixels,
follow_end: bool,
pub(super) scroll_to_position: Option<ScrollTarget>, pub(super) scroll_to_position: Option<ScrollTarget>,
selection_scroll_anchor: Option<ClippedSelectionScrollAnchor>, selection_scroll_anchor: Option<ClippedSelectionScrollAnchor>,
} }
@@ -77,6 +79,36 @@ impl ClippedScrollStateHandle {
self.clipped_scroll_data.lock().scroll_start_px self.clipped_scroll_data.lock().scroll_start_px
} }
/// Returns whether the scrollable is currently at its maximum scroll position.
///
/// The maximum is captured during layout, so consumers can implement follow-output
/// behavior without estimating content or viewport sizes themselves.
pub fn is_scrolled_to_end(&self) -> bool {
let data = self.clipped_scroll_data.lock();
data.scroll_start_px.as_f32() + 0.5 >= data.max_scroll_start_px.as_f32()
}
/// Controls whether layout should keep the scroll position docked to the content's end.
pub fn set_follow_end(&self, follow_end: bool) {
self.clipped_scroll_data.lock().follow_end = follow_end;
}
pub(in crate::elements) fn update_scroll_extent(
&self,
visible_px: Pixels,
total_size: Pixels,
) -> Pixels {
let max_scroll_start_px = (total_size - visible_px).max(Pixels::zero());
let mut data = self.clipped_scroll_data.lock();
data.max_scroll_start_px = max_scroll_start_px;
data.scroll_start_px = if data.follow_end {
max_scroll_start_px
} else {
data.scroll_start_px.min(max_scroll_start_px)
};
data.scroll_start_px
}
pub fn scroll_by(&self, delta: Pixels) { pub fn scroll_by(&self, delta: Pixels) {
self.scroll_to(self.scroll_start() + delta); self.scroll_to(self.scroll_start() + delta);
} }
@@ -417,12 +449,8 @@ impl Element for ClippedScrollable {
// Make sure that the new layout doesn't put the scroll bar in an invalid // Make sure that the new layout doesn't put the scroll bar in an invalid
// location. // location.
if let Some(scroll_data) = self.scroll_data(app) { if let Some(scroll_data) = self.scroll_data(app) {
let max_scroll_top = self.state
(scroll_data.total_size - scroll_data.visible_px).max(Pixels::zero()); .update_scroll_extent(scroll_data.visible_px, scroll_data.total_size);
let scroll_top = scroll_data.scroll_start;
if scroll_top > max_scroll_top {
self.state.scroll_to(max_scroll_top);
}
} }
} }
} }
@@ -1577,10 +1577,12 @@ impl SelectableElement for NewScrollable {
impl ClippedScrollStateHandle { impl ClippedScrollStateHandle {
fn scroll_data(&self, viewport_size: Vector2F, child_size: Vector2F, axis: Axis) -> ScrollData { fn scroll_data(&self, viewport_size: Vector2F, child_size: Vector2F, axis: Axis) -> ScrollData {
let visible_px = viewport_size.along(axis).into_pixels();
let total_size = child_size.along(axis).into_pixels();
ScrollData { ScrollData {
scroll_start: self.scroll_start(), scroll_start: self.update_scroll_extent(visible_px, total_size),
visible_px: (viewport_size.along(axis)).into_pixels(), visible_px,
total_size: child_size.along(axis).into_pixels(), total_size,
} }
} }
} }