Improve local tool execution and shell output panes
This commit is contained in:
@@ -834,7 +834,10 @@ pub(crate) fn convert_tool_call_result_to_input(
|
||||
})
|
||||
.collect();
|
||||
|
||||
GrepResult::Success { matched_files }
|
||||
GrepResult::Success {
|
||||
matched_files,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
Some(api::grep_result::Result::Error(error)) => {
|
||||
GrepResult::Error(error.message.clone())
|
||||
|
||||
+10
-1
@@ -1343,11 +1343,20 @@ impl<'a> std::fmt::Display for MarkdownActionResult<'a> {
|
||||
FileGlobV2Result::Cancelled => write!(f, "\n_File glob cancelled_"),
|
||||
},
|
||||
AIAgentActionResultType::Grep(result) => match result {
|
||||
GrepResult::Success { matched_files } => {
|
||||
GrepResult::Success {
|
||||
matched_files,
|
||||
truncated,
|
||||
} => {
|
||||
write!(f, "\n\n**Grep Results:**\n\n")?;
|
||||
for file in matched_files {
|
||||
writeln!(f, "- **{}**", file.file_path)?;
|
||||
}
|
||||
if *truncated {
|
||||
writeln!(
|
||||
f,
|
||||
"\n_Additional matches omitted; narrow the query or path._"
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
GrepResult::Error(message) => {
|
||||
|
||||
@@ -143,10 +143,16 @@ pub mod text {
|
||||
SearchCodebaseResult::Cancelled => todo!(),
|
||||
},
|
||||
AIAgentActionResultType::Grep(result) => match result {
|
||||
GrepResult::Success { matched_files } => {
|
||||
GrepResult::Success {
|
||||
matched_files,
|
||||
truncated,
|
||||
} => {
|
||||
for file in matched_files {
|
||||
writeln!(w, "- {file}")?;
|
||||
}
|
||||
if *truncated {
|
||||
writeln!(w, "Additional matches omitted; narrow the query or path.")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
GrepResult::Error(error) => writeln!(w, "grep failed: {error}"),
|
||||
@@ -923,7 +929,7 @@ pub mod json {
|
||||
SearchCodebaseResult::Cancelled => Some(JsonMessage::ToolCanceled),
|
||||
},
|
||||
AIAgentActionResultType::Grep(result) => match result {
|
||||
GrepResult::Success { matched_files } => {
|
||||
GrepResult::Success { matched_files, .. } => {
|
||||
use crate::ai::agent::GrepFileMatch;
|
||||
let files: Vec<JsonFile> = matched_files
|
||||
.iter()
|
||||
|
||||
@@ -743,7 +743,7 @@ fn tool_definition_for_name(name: &str) -> ToolDefinition {
|
||||
},
|
||||
"grep" => ToolDefinition {
|
||||
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!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1783,7 +1783,7 @@ pub fn default_tool_definitions() -> Vec<ToolDefinition> {
|
||||
},
|
||||
ToolDefinition {
|
||||
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!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -136,7 +136,7 @@ fn apply_file_diffs() -> ToolDefinition {
|
||||
fn grep() -> ToolDefinition {
|
||||
ToolDefinition {
|
||||
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!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use galaxyui::elements::{
|
||||
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext, Point,
|
||||
SizeConstraint,
|
||||
};
|
||||
use galaxyui::event::DispatchedEvent;
|
||||
use galaxyui::fonts::Properties;
|
||||
use galaxyui::geometry::rect::RectF;
|
||||
use galaxyui::units::IntoPixels;
|
||||
use galaxyui::EntityId;
|
||||
use pathfinder_geometry::vector::{vec2f, Vector2F};
|
||||
|
||||
use super::blockgrid_renderer::GridRenderParams;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::settings::EnforceMinimumContrast;
|
||||
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::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::{color, SizeInfo};
|
||||
|
||||
@@ -119,3 +128,206 @@ impl Element for BlockGridElement {
|
||||
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,
|
||||
conversation_id: AIConversationId,
|
||||
) -> Result<Self, UpdateInteractionModeError> {
|
||||
let requested_command_action_id = match self {
|
||||
InteractionMode::User(_) => None,
|
||||
InteractionMode::Agent(metadata) => {
|
||||
if metadata.conversation_id != conversation_id {
|
||||
return Err(UpdateInteractionModeError::UnexpectedConversationId);
|
||||
let (requested_command_action_id, has_agent_written_to_block, should_hide_block) =
|
||||
match self {
|
||||
InteractionMode::User(_) => (None, false, false),
|
||||
InteractionMode::Agent(metadata) => {
|
||||
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 {
|
||||
requested_command_action_id,
|
||||
@@ -364,8 +369,8 @@ impl InteractionMode {
|
||||
is_blocked: false,
|
||||
should_hide_responses: false,
|
||||
}),
|
||||
has_agent_written_to_block: false,
|
||||
should_hide_block: false,
|
||||
has_agent_written_to_block,
|
||||
should_hide_block,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -6791,7 +6791,18 @@ impl TerminalView {
|
||||
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(
|
||||
*agent_has_control,
|
||||
block_id.clone(),
|
||||
@@ -9744,6 +9755,7 @@ impl TerminalView {
|
||||
model.block_list_mut().update_active_block_height();
|
||||
}
|
||||
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| {
|
||||
footer.notify_and_notify_children(ctx);
|
||||
});
|
||||
@@ -9752,6 +9764,47 @@ impl TerminalView {
|
||||
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,
|
||||
/// in which case completions will not work as expected.
|
||||
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,
|
||||
// then clear just that block and leave the rest of the blocklist in tact.
|
||||
self.model.lock().clear_screen(ClearMode::ActiveBlock);
|
||||
// In the agent-driving-but-not-monitoring state the terminal block is hidden. Make
|
||||
// it visible and expand the header immediately so the user sees the cleared state
|
||||
// right away, rather than waiting for the auto-expand timer (~3 s).
|
||||
// In the agent-driving-but-not-monitoring state the terminal block is hidden.
|
||||
// Expand its inline tool pane immediately so the user sees the cleared state there,
|
||||
// rather than revealing a duplicate terminal block below the conversation.
|
||||
if is_agent_driving_command && !is_agent_monitoring {
|
||||
let requested_command_action_id = self
|
||||
.model
|
||||
@@ -18963,10 +19016,6 @@ impl TerminalView {
|
||||
.requested_command_action_id()
|
||||
.cloned();
|
||||
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() {
|
||||
ai_block_handle.update(ctx, |ai_block, ctx| {
|
||||
ai_block.expand_requested_command_view(action_id, ctx);
|
||||
|
||||
Reference in New Issue
Block a user