diff --git a/Cargo.lock b/Cargo.lock index cf324eba..07cb7d1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -619,6 +619,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", + "regex-automata", "serde", ] @@ -1542,6 +1543,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "encoding_rs_io" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83" +dependencies = [ + "encoding_rs", +] + [[package]] name = "enum_dispatch" version = "0.3.13" @@ -1983,6 +1993,43 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "grep-matcher" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36d7b71093325ab22d780b40d7df3066ae4aebb518ba719d38c697a8228a8023" +dependencies = [ + "memchr", +] + +[[package]] +name = "grep-regex" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce0c256c3ad82bcc07b812c15a45ec1d398122e8e15124f96695234db7112ef" +dependencies = [ + "bstr", + "grep-matcher", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "grep-searcher" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac63295322dc48ebb20a25348147905d816318888e64f531bfc2a2bc0577dc34" +dependencies = [ + "bstr", + "encoding_rs", + "encoding_rs_io", + "grep-matcher", + "log", + "memchr", + "memmap2", +] + [[package]] name = "group" version = "0.13.0" @@ -3128,6 +3175,15 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "memmap2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +dependencies = [ + "libc", +] + [[package]] name = "memoffset" version = "0.6.5" @@ -5580,6 +5636,11 @@ dependencies = [ "axum-server", "chrono", "fast_html2md", + "globset", + "grep-matcher", + "grep-regex", + "grep-searcher", + "ignore", "rand 0.9.2", "reqwest", "rmcp", diff --git a/Cargo.toml b/Cargo.toml index 295074c5..011a503a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,6 +82,12 @@ crossterm = "0.29" tempfile = "3.0" similar = { version = "2.7.0", features = ["inline"] } schemars = { version = "1.1.0", features = ["chrono"] } +# Ripgrep ecosystem for grep/glob in view tool +grep-matcher = "0.1" +grep-regex = "0.1" +grep-searcher = "0.1" +ignore = "0.4" +globset = "0.4" async-trait = "0.1" open = "5.3.2" log = "0.4" diff --git a/libs/mcp/server/Cargo.toml b/libs/mcp/server/Cargo.toml index f89082de..9e1b5938 100644 --- a/libs/mcp/server/Cargo.toml +++ b/libs/mcp/server/Cargo.toml @@ -27,6 +27,12 @@ fast_html2md = "=0.0.48" walkdir = { workspace = true } toml = "0.8" similar = { workspace = true } +# Ripgrep ecosystem for grep/glob +grep-matcher = { workspace = true } +grep-regex = { workspace = true } +grep-searcher = { workspace = true } +ignore = { workspace = true } +globset = { workspace = true } [dev-dependencies] tempfile = "3.8" diff --git a/libs/mcp/server/src/local_tools.rs b/libs/mcp/server/src/local_tools.rs index b05e1129..56b4340c 100644 --- a/libs/mcp/server/src/local_tools.rs +++ b/libs/mcp/server/src/local_tools.rs @@ -8,7 +8,12 @@ use stakpak_shared::remote_connection::{ PathLocation, RemoteConnection, RemoteConnectionInfo, RemoteFileSystemProvider, }; +use globset::Glob; +use grep_regex::RegexMatcher; +use grep_searcher::Searcher; +use grep_searcher::sinks::UTF8; use html2md; +use ignore::WalkBuilder; use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT}; use serde_json::json; use similar::TextDiff; @@ -89,6 +94,14 @@ pub struct ViewRequest { description = "Optional line range to view [start_line, end_line]. Line numbers are 1-indexed. Use -1 for end_line to read to end of file." )] pub view_range: Option<[i32; 2]>, + #[schemars( + description = "Regex pattern to search for in file contents. Returns matching lines with line numbers. For directories, searches all files recursively (respects .gitignore)." + )] + pub grep: Option, + #[schemars( + description = "Glob pattern to filter files when viewing directories (e.g., '*.rs', '**/*.ts', 'src/**/*.go'). Only applies to directory views." + )] + pub glob: Option, #[schemars(description = "Optional password for remote connection (if path is remote)")] pub password: Option, #[schemars( @@ -99,6 +112,16 @@ pub struct ViewRequest { pub tree: Option, } +/// Options for viewing files/directories (used internally to reduce function arguments) +#[derive(Debug, Clone)] +pub struct ViewOptions<'a> { + pub view_range: Option<[i32; 2]>, + pub max_lines: usize, + pub tree: Option, + pub grep: Option<&'a str>, + pub glob: Option<&'a str>, +} + #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct StrReplaceRequest { #[schemars( @@ -608,6 +631,23 @@ For directories: - Default behavior: Lists immediate directory contents - With tree=true: Displays nested directory structure as a tree (limited to 3 levels deep) +GREP (Content Search): +- Use 'grep' parameter with a regex pattern to search file contents +- For files: Returns matching lines with line numbers (format: line_num:content) +- For directories: Recursively searches all files, respects .gitignore (format: file:line_num:content) +- Examples: + * grep='TODO|FIXME' - Find all TODO/FIXME comments + * grep='fn\\s+\\w+' - Find Rust function definitions + * grep='error' - Simple text search + +GLOB (File Filtering): +- Use 'glob' parameter to filter files in directories by pattern +- Supports standard glob syntax: *, ?, [abc], ** +- Examples: + * glob='*.rs' - All Rust files + * glob='**/*.ts' - All TypeScript files (recursive) + * glob='test_*.py' - Python test files + SECRET HANDLING: - File contents containing secrets will be redacted and shown as placeholders like [REDACTED_SECRET:rule-id:hash] - These placeholders represent actual secret values that are safely stored for later use @@ -620,6 +660,8 @@ A maximum of 300 lines will be shown at a time, the rest will be truncated." Parameters(ViewRequest { path, view_range, + grep, + glob, password, private_key_path, tree, @@ -635,15 +677,28 @@ A maximum of 300 lines will be shown at a time, the rest will be truncated." .await { Ok((conn, remote_path)) => { - self.view_remote_path(&conn, &remote_path, &path, view_range, MAX_LINES, tree) + let opts = ViewOptions { + view_range, + max_lines: MAX_LINES, + tree, + grep: grep.as_deref(), + glob: glob.as_deref(), + }; + self.view_remote_path(&conn, &remote_path, &path, &opts) .await } Err(error_result) => Ok(error_result), } } else { // Handle local file/directory viewing - self.view_local_path(&path, view_range, MAX_LINES, tree) - .await + let opts = ViewOptions { + view_range, + max_lines: MAX_LINES, + tree, + grep: grep.as_deref(), + glob: glob.as_deref(), + }; + self.view_local_path(&path, &opts).await } } @@ -1266,9 +1321,7 @@ SAFETY NOTES: async fn view_local_path( &self, path: &str, - view_range: Option<[i32; 2]>, - max_lines: usize, - tree: Option, + opts: &ViewOptions<'_>, ) -> Result { let path_obj = Path::new(path); @@ -1280,7 +1333,34 @@ SAFETY NOTES: } if path_obj.is_dir() { - let depth = if tree.unwrap_or(false) { 3 } else { 1 }; + // Handle combined glob + grep: filter files by glob, then search content + if let (Some(glob_pattern), Some(grep_pattern)) = (opts.glob, opts.grep) { + return self + .grep_local_directory_with_glob( + path, + grep_pattern, + glob_pattern, + opts.max_lines, + ) + .await; + } + + // Handle glob pattern filtering for directories (list files only) + if let Some(glob_pattern) = opts.glob { + return self + .view_local_dir_with_glob(path, glob_pattern, opts.max_lines) + .await; + } + + // Handle grep search in directory (all files) + if let Some(grep_pattern) = opts.grep { + return self + .grep_local_directory(path, grep_pattern, opts.max_lines) + .await; + } + + // Default directory tree view + let depth = if opts.tree.unwrap_or(false) { 3 } else { 1 }; let provider = LocalFileSystemProvider; let path_str = path_obj.to_string_lossy(); @@ -1303,12 +1383,21 @@ SAFETY NOTES: ])), } } else { + // Handle grep search in single file + if let Some(grep_pattern) = opts.grep { + return self.grep_local_file(path, grep_pattern, opts.max_lines); + } + // Read file contents match fs::read_to_string(path) { Ok(content) => { - let result = match self - .format_file_content(&content, path, view_range, max_lines, "File") - { + let result = match self.format_file_content( + &content, + path, + opts.view_range, + opts.max_lines, + "File", + ) { Ok(result) => result, Err(e) => { return Ok(CallToolResult::error(vec![ @@ -1328,15 +1417,339 @@ SAFETY NOTES: } } + /// View directory contents filtered by glob pattern + async fn view_local_dir_with_glob( + &self, + path: &str, + glob_pattern: &str, + max_lines: usize, + ) -> Result { + // Build the glob matcher + let glob = match Glob::new(glob_pattern) { + Ok(g) => g.compile_matcher(), + Err(e) => { + return Ok(CallToolResult::error(vec![ + Content::text("INVALID_GLOB"), + Content::text(format!("Invalid glob pattern '{}': {}", glob_pattern, e)), + ])); + } + }; + + // Use ignore crate's WalkBuilder for gitignore-aware traversal + let walker = WalkBuilder::new(path) + .hidden(false) // Show hidden files + .git_ignore(true) // Respect .gitignore + .build(); + + let mut matches: Vec = Vec::new(); + let base_path = Path::new(path); + + for entry in walker.flatten() { + let entry_path = entry.path(); + + // Get relative path for glob matching + let relative = match entry_path.strip_prefix(base_path) { + Ok(r) => r.to_string_lossy().to_string(), + Err(_) => continue, + }; + + // Skip the root directory itself + if relative.is_empty() { + continue; + } + + // Check if the path matches the glob pattern + if glob.is_match(&relative) || glob.is_match(entry_path.file_name().unwrap_or_default()) + { + let prefix = if entry_path.is_dir() { + "📁 " + } else { + "📄 " + }; + matches.push(format!("{}{}", prefix, relative)); + } + } + + if matches.is_empty() { + return Ok(CallToolResult::success(vec![Content::text(format!( + "No files matching '{}' found in {}", + glob_pattern, path + ))])); + } + + // Sort and truncate + matches.sort(); + let total = matches.len(); + let truncated = matches.len() > max_lines; + if truncated { + matches.truncate(max_lines); + } + + let mut result = format!( + "Files matching '{}' in \"{}\" ({} matches):\n\n{}", + glob_pattern, + path, + total, + matches.join("\n") + ); + + if truncated { + result.push_str(&format!("\n\n... and {} more files", total - max_lines)); + } + + Ok(CallToolResult::success(vec![Content::text(result)])) + } + + /// Grep search in a single local file + fn grep_local_file( + &self, + path: &str, + pattern: &str, + max_lines: usize, + ) -> Result { + let matcher = match RegexMatcher::new(pattern) { + Ok(m) => m, + Err(e) => { + return Ok(CallToolResult::error(vec![ + Content::text("INVALID_REGEX"), + Content::text(format!("Invalid regex pattern '{}': {}", pattern, e)), + ])); + } + }; + + let mut matches: Vec = Vec::new(); + let mut searcher = Searcher::new(); + + let sink_result = searcher.search_path( + &matcher, + path, + UTF8(|line_num, line| { + if matches.len() < max_lines { + matches.push(format!("{}:{}", line_num, line.trim_end())); + } + Ok(true) + }), + ); + + if let Err(e) = sink_result { + return Ok(CallToolResult::error(vec![ + Content::text("GREP_ERROR"), + Content::text(format!("Error searching file: {}", e)), + ])); + } + + if matches.is_empty() { + return Ok(CallToolResult::success(vec![Content::text(format!( + "No matches for '{}' in {}", + pattern, path + ))])); + } + + let total = matches.len(); + let result = format!( + "Grep results for '{}' in \"{}\" ({} matches in 1 file):\n\n{}", + pattern, + path, + total, + matches.join("\n") + ); + + Ok(CallToolResult::success(vec![Content::text(result)])) + } + + /// Grep search across a directory (recursive, respects .gitignore) + async fn grep_local_directory( + &self, + path: &str, + pattern: &str, + max_lines: usize, + ) -> Result { + let matcher = match RegexMatcher::new(pattern) { + Ok(m) => m, + Err(e) => { + return Ok(CallToolResult::error(vec![ + Content::text("INVALID_REGEX"), + Content::text(format!("Invalid regex pattern '{}': {}", pattern, e)), + ])); + } + }; + + // Use ignore crate for gitignore-aware traversal + let walker = WalkBuilder::new(path) + .hidden(false) + .git_ignore(true) + .build(); + + let mut all_matches: Vec = Vec::new(); + let mut files_with_matches = 0; + let base_path = Path::new(path); + + for entry in walker.flatten() { + if all_matches.len() >= max_lines { + break; + } + + let entry_path = entry.path(); + if !entry_path.is_file() { + continue; + } + + let relative = entry_path + .strip_prefix(base_path) + .map(|r| r.to_string_lossy().to_string()) + .unwrap_or_else(|_| entry_path.to_string_lossy().to_string()); + + let mut file_matches: Vec = Vec::new(); + let mut searcher = Searcher::new(); + + let _ = searcher.search_path( + &matcher, + entry_path, + UTF8(|line_num, line| { + if all_matches.len() + file_matches.len() < max_lines { + file_matches.push(format!("{}:{}:{}", relative, line_num, line.trim_end())); + } + Ok(true) + }), + ); + + if !file_matches.is_empty() { + files_with_matches += 1; + all_matches.extend(file_matches); + } + } + + if all_matches.is_empty() { + return Ok(CallToolResult::success(vec![Content::text(format!( + "No matches for '{}' in {}", + pattern, path + ))])); + } + + let truncated = all_matches.len() >= max_lines; + let result = format!( + "Grep results for '{}' in \"{}\" ({} matches in {} files):\n\n{}{}", + pattern, + path, + all_matches.len(), + files_with_matches, + all_matches.join("\n"), + if truncated { "\n\n... (truncated)" } else { "" } + ); + + Ok(CallToolResult::success(vec![Content::text(result)])) + } + + /// Grep search across a directory filtered by glob pattern + async fn grep_local_directory_with_glob( + &self, + path: &str, + pattern: &str, + glob_pattern: &str, + max_lines: usize, + ) -> Result { + let matcher = match RegexMatcher::new(pattern) { + Ok(m) => m, + Err(e) => { + return Ok(CallToolResult::error(vec![ + Content::text("INVALID_REGEX"), + Content::text(format!("Invalid regex pattern '{}': {}", pattern, e)), + ])); + } + }; + + let glob = match Glob::new(glob_pattern) { + Ok(g) => g.compile_matcher(), + Err(e) => { + return Ok(CallToolResult::error(vec![ + Content::text("INVALID_GLOB"), + Content::text(format!("Invalid glob pattern '{}': {}", glob_pattern, e)), + ])); + } + }; + + // Use ignore crate for gitignore-aware traversal + let walker = WalkBuilder::new(path) + .hidden(false) + .git_ignore(true) + .build(); + + let mut all_matches: Vec = Vec::new(); + let mut files_with_matches = 0; + let base_path = Path::new(path); + + for entry in walker.flatten() { + if all_matches.len() >= max_lines { + break; + } + + let entry_path = entry.path(); + if !entry_path.is_file() { + continue; + } + + // Check if file matches glob pattern + let relative = entry_path + .strip_prefix(base_path) + .map(|r| r.to_string_lossy().to_string()) + .unwrap_or_else(|_| entry_path.to_string_lossy().to_string()); + + let matches_glob = glob.is_match(&relative) + || glob.is_match(entry_path.file_name().unwrap_or_default()); + + if !matches_glob { + continue; + } + + let mut file_matches: Vec = Vec::new(); + let mut searcher = Searcher::new(); + + let _ = searcher.search_path( + &matcher, + entry_path, + UTF8(|line_num, line| { + if all_matches.len() + file_matches.len() < max_lines { + file_matches.push(format!("{}:{}:{}", relative, line_num, line.trim_end())); + } + Ok(true) + }), + ); + + if !file_matches.is_empty() { + files_with_matches += 1; + all_matches.extend(file_matches); + } + } + + if all_matches.is_empty() { + return Ok(CallToolResult::success(vec![Content::text(format!( + "No matches for '{}' in {} (filtered by glob '{}')", + pattern, path, glob_pattern + ))])); + } + + let truncated = all_matches.len() >= max_lines; + let result = format!( + "Grep results for '{}' in \"{}\" (glob: '{}') ({} matches in {} files):\n\n{}{}", + pattern, + path, + glob_pattern, + all_matches.len(), + files_with_matches, + all_matches.join("\n"), + if truncated { "\n\n... (truncated)" } else { "" } + ); + + Ok(CallToolResult::success(vec![Content::text(result)])) + } + /// View the contents of a remote file or directory async fn view_remote_path( &self, conn: &Arc, remote_path: &str, original_path: &str, - view_range: Option<[i32; 2]>, - max_lines: usize, - tree: Option, + opts: &ViewOptions<'_>, ) -> Result { if !conn.exists(remote_path).await { return Ok(CallToolResult::error(vec![ @@ -1349,7 +1762,48 @@ SAFETY NOTES: } if conn.is_directory(remote_path).await { - let depth = if tree.unwrap_or(false) { 3 } else { 1 }; + // Handle combined glob + grep for remote directories + if let (Some(glob_pattern), Some(grep_pattern)) = (opts.glob, opts.grep) { + return self + .grep_remote_directory_with_glob( + conn, + remote_path, + original_path, + grep_pattern, + glob_pattern, + opts.max_lines, + ) + .await; + } + + // Handle glob pattern filtering for remote directories + if let Some(glob_pattern) = opts.glob { + return self + .view_remote_dir_with_glob( + conn, + remote_path, + original_path, + glob_pattern, + opts.max_lines, + ) + .await; + } + + // Handle grep search in remote directory + if let Some(grep_pattern) = opts.grep { + return self + .grep_remote_directory( + conn, + remote_path, + original_path, + grep_pattern, + opts.max_lines, + ) + .await; + } + + // Default directory tree view + let depth = if opts.tree.unwrap_or(false) { 3 } else { 1 }; let provider = RemoteFileSystemProvider::new(conn.clone()); match generate_directory_tree(&provider, remote_path, "", depth, 0).await { @@ -1368,14 +1822,27 @@ SAFETY NOTES: ])), } } else { + // Handle grep search in single remote file + if let Some(grep_pattern) = opts.grep { + return self + .grep_remote_file( + conn, + remote_path, + original_path, + grep_pattern, + opts.max_lines, + ) + .await; + } + // Read remote file contents match conn.read_file_to_string(remote_path).await { Ok(content) => { let result = match self.format_file_content( &content, original_path, - view_range, - max_lines, + opts.view_range, + opts.max_lines, "Remote file", ) { Ok(result) => result, @@ -1400,6 +1867,274 @@ SAFETY NOTES: } } + /// View remote directory contents filtered by glob pattern using find command + async fn view_remote_dir_with_glob( + &self, + conn: &Arc, + remote_path: &str, + original_path: &str, + glob_pattern: &str, + max_lines: usize, + ) -> Result { + // Escape for double quotes (the command will be wrapped in bash -c '...' which uses double quotes internally) + let escaped_pattern = glob_pattern + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('$', "\\$") + .replace('`', "\\`"); + + // Use double quotes for pattern since single quotes conflict with bash -c wrapper + let command = format!( + "find {} -name \"{}\" 2>/dev/null | head -n {}", + remote_path, + escaped_pattern, + max_lines + 1 // +1 to detect truncation + ); + + match conn.execute_command(&command, None, None).await { + Ok((output, exit_code)) => { + if exit_code != 0 && output.trim().is_empty() { + return Ok(CallToolResult::success(vec![Content::text(format!( + "No files matching '{}' found in {}", + glob_pattern, original_path + ))])); + } + + let lines: Vec<&str> = output.lines().collect(); + let truncated = lines.len() > max_lines; + let display_lines: Vec<&str> = lines.into_iter().take(max_lines).collect(); + + if display_lines.is_empty() { + return Ok(CallToolResult::success(vec![Content::text(format!( + "No files matching '{}' found in {}", + glob_pattern, original_path + ))])); + } + + let mut result = format!( + "Remote files matching '{}' in \"{}\":\n\n{}", + glob_pattern, + original_path, + display_lines.join("\n") + ); + + if truncated { + result.push_str("\n\n... (truncated)"); + } + + Ok(CallToolResult::success(vec![Content::text(result)])) + } + Err(e) => Ok(CallToolResult::error(vec![ + Content::text("REMOTE_GLOB_ERROR"), + Content::text(format!("Failed to search remote directory: {}", e)), + ])), + } + } + + /// Grep search in a single remote file + async fn grep_remote_file( + &self, + conn: &Arc, + remote_path: &str, + original_path: &str, + pattern: &str, + max_lines: usize, + ) -> Result { + // Escape for double quotes (the command will be wrapped in bash -c '...' which uses double quotes internally) + // Need to escape: \ " $ ` + let escaped_pattern = pattern + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('$', "\\$") + .replace('`', "\\`"); + + // Use grep -E for extended regex (supports |, +, ?, etc.) + // Use double quotes for pattern since single quotes conflict with bash -c wrapper + let command = format!( + "grep -En \"{}\" {} 2>/dev/null | head -n {}", + escaped_pattern, + remote_path, + max_lines + 1 + ); + + match conn.execute_command(&command, None, None).await { + Ok((output, _exit_code)) => { + // grep returns exit code 1 for no matches, which is fine + let lines: Vec<&str> = output.lines().collect(); + + if lines.is_empty() { + return Ok(CallToolResult::success(vec![Content::text(format!( + "No matches for '{}' in {}", + pattern, original_path + ))])); + } + + let truncated = lines.len() > max_lines; + let display_lines: Vec<&str> = lines.into_iter().take(max_lines).collect(); + + let mut result = format!( + "Grep results for '{}' in \"{}\" ({} matches in 1 file):\n\n{}", + pattern, + original_path, + display_lines.len(), + display_lines.join("\n") + ); + + if truncated { + result.push_str("\n\n... (truncated)"); + } + + Ok(CallToolResult::success(vec![Content::text(result)])) + } + Err(e) => Ok(CallToolResult::error(vec![ + Content::text("REMOTE_GREP_ERROR"), + Content::text(format!("Failed to grep remote file: {}", e)), + ])), + } + } + + /// Grep search across a remote directory using grep -rE + async fn grep_remote_directory( + &self, + conn: &Arc, + remote_path: &str, + original_path: &str, + pattern: &str, + max_lines: usize, + ) -> Result { + // Escape for double quotes (the command will be wrapped in bash -c '...' which uses double quotes internally) + let escaped_pattern = pattern + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('$', "\\$") + .replace('`', "\\`"); + + // Use grep -rEn for recursive search with extended regex and line numbers + // Use double quotes for pattern since single quotes conflict with bash -c wrapper + let command = format!( + "grep -rEn --include=\"*\" \"{}\" {} 2>/dev/null | head -n {}", + escaped_pattern, + remote_path, + max_lines + 1 + ); + + match conn.execute_command(&command, None, None).await { + Ok((output, _exit_code)) => { + let lines: Vec<&str> = output.lines().collect(); + + if lines.is_empty() { + return Ok(CallToolResult::success(vec![Content::text(format!( + "No matches for '{}' in {}", + pattern, original_path + ))])); + } + + let truncated = lines.len() > max_lines; + let display_lines: Vec<&str> = lines.into_iter().take(max_lines).collect(); + + // Count unique files + let files_with_matches: std::collections::HashSet<&str> = display_lines + .iter() + .filter_map(|line| line.split(':').next()) + .collect(); + + let mut result = format!( + "Grep results for '{}' in \"{}\" ({} matches in {} files):\n\n{}", + pattern, + original_path, + display_lines.len(), + files_with_matches.len(), + display_lines.join("\n") + ); + + if truncated { + result.push_str("\n\n... (truncated)"); + } + + Ok(CallToolResult::success(vec![Content::text(result)])) + } + Err(e) => Ok(CallToolResult::error(vec![ + Content::text("REMOTE_GREP_ERROR"), + Content::text(format!("Failed to grep remote directory: {}", e)), + ])), + } + } + + /// Grep search across a remote directory filtered by glob pattern + async fn grep_remote_directory_with_glob( + &self, + conn: &Arc, + remote_path: &str, + original_path: &str, + pattern: &str, + glob_pattern: &str, + max_lines: usize, + ) -> Result { + // Escape for double quotes (the command will be wrapped in bash -c '...' which uses double quotes internally) + let escaped_pattern = pattern + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('$', "\\$") + .replace('`', "\\`"); + let escaped_glob = glob_pattern + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('$', "\\$") + .replace('`', "\\`"); + + // Use find with -name to filter files, then grep -E for extended regex + // Use double quotes for patterns since single quotes conflict with bash -c wrapper + let command = format!( + "find {} -name \"{}\" -type f -exec grep -EHn \"{}\" {{}} \\; 2>/dev/null | head -n {}", + remote_path, + escaped_glob, + escaped_pattern, + max_lines + 1 + ); + + match conn.execute_command(&command, None, None).await { + Ok((output, _exit_code)) => { + let lines: Vec<&str> = output.lines().collect(); + + if lines.is_empty() { + return Ok(CallToolResult::success(vec![Content::text(format!( + "No matches for '{}' in {} (filtered by glob '{}')", + pattern, original_path, glob_pattern + ))])); + } + + let truncated = lines.len() > max_lines; + let display_lines: Vec<&str> = lines.into_iter().take(max_lines).collect(); + + // Count unique files + let files_with_matches: std::collections::HashSet<&str> = display_lines + .iter() + .filter_map(|line| line.split(':').next()) + .collect(); + + let mut result = format!( + "Grep results for '{}' in \"{}\" (glob: '{}') ({} matches in {} files):\n\n{}", + pattern, + original_path, + glob_pattern, + display_lines.len(), + files_with_matches.len(), + display_lines.join("\n") + ); + + if truncated { + result.push_str("\n\n... (truncated)"); + } + + Ok(CallToolResult::success(vec![Content::text(result)])) + } + Err(e) => Ok(CallToolResult::error(vec![ + Content::text("REMOTE_GREP_ERROR"), + Content::text(format!("Failed to grep remote directory: {}", e)), + ])), + } + } + /// Format file content with line numbers and truncation - shared logic fn format_file_content( &self, diff --git a/libs/shared/src/remote_connection.rs b/libs/shared/src/remote_connection.rs index 5bc35c1d..6f435b64 100644 --- a/libs/shared/src/remote_connection.rs +++ b/libs/shared/src/remote_connection.rs @@ -440,9 +440,17 @@ impl RemoteConnection { let wrapped_command = if options.simple { command.to_string() } else { + // Escape characters that have special meaning inside double quotes in bash: + // \ " $ ` ! need escaping, and | needs escaping to prevent pipe interpretation + let escaped_command = command + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('$', "\\$") + .replace('`', "\\`") + .replace('!', "\\!"); format!( "bash -c 'echo \"PID:$$\"; exec bash -c \"{}\"'", - command.replace('\\', "\\\\").replace('"', "\\\"") + escaped_command ) }; diff --git a/tui/src/event_loop.rs b/tui/src/event_loop.rs index bc3d3a0c..18066973 100644 --- a/tui/src/event_loop.rs +++ b/tui/src/event_loop.rs @@ -284,13 +284,13 @@ pub async fn run_tui( } "read" | "view" | "read_file" => { // View file tool - show compact view with file icon and line count - // Extract file path from tool call arguments - let file_path = crate::services::handlers::tool::extract_file_path_from_tool_call(&tool_call_result.call) - .unwrap_or_else(|| "file".to_string()); + // Extract file path and optional grep/glob from tool call arguments + let (file_path, grep, glob) = crate::services::handlers::tool::extract_view_params_from_tool_call(&tool_call_result.call); + let file_path = file_path.unwrap_or_else(|| "file".to_string()); let total_lines = tool_call_result.result.lines().count(); - state.messages.push(Message::render_view_file_block(file_path.clone(), total_lines)); + state.messages.push(Message::render_view_file_block(file_path.clone(), total_lines, grep.clone(), glob.clone())); // Full screen popup: same compact view without borders - state.messages.push(Message::render_view_file_block_popup(file_path, total_lines)); + state.messages.push(Message::render_view_file_block_popup(file_path, total_lines, grep, glob)); } _ => { // TUI: collapsed command message - last 3 lines (is_collapsed: None) diff --git a/tui/src/services/bash_block.rs b/tui/src/services/bash_block.rs index 16284c5a..7bb7f51d 100644 --- a/tui/src/services/bash_block.rs +++ b/tui/src/services/bash_block.rs @@ -1700,11 +1700,13 @@ pub fn render_collapsed_command_message( } /// Renders a compact view file result block with borders -/// Format: View path/to/file.rs - 123 lines +/// Format: View path/to/file.rs - 123 lines [grep: pattern] [glob: pattern] pub fn render_view_file_block( file_path: &str, total_lines: usize, terminal_width: usize, + grep: Option<&str>, + glob: Option<&str>, ) -> Vec> { let content_width = if terminal_width > 4 { terminal_width - 4 @@ -1715,23 +1717,45 @@ pub fn render_view_file_block( let border_color = Color::DarkGray; let icon = ""; - let title = "View"; + + // Determine title based on operation type + let title = if grep.is_some() && glob.is_some() { + "Grep+Glob" + } else if grep.is_some() { + "Grep" + } else if glob.is_some() { + "Glob" + } else { + "View" + }; + let lines_text = format!("- {} lines", total_lines); + // Build optional grep/glob suffix + let suffix = match (grep, glob) { + (Some(g), Some(gl)) => format!(" [grep: {} | glob: {}]", g, gl), + (Some(g), None) => format!(" [grep: {}]", g), + (None, Some(g)) => format!(" [glob: {}]", g), + _ => String::new(), + }; + // Calculate display widths let icon_width = calculate_display_width(icon); let title_width = calculate_display_width(title); let path_width = calculate_display_width(file_path); let lines_text_width = calculate_display_width(&lines_text); + let suffix_width = calculate_display_width(&suffix); - // Total content: icon + " " + title + " " + path + " " + lines_text - let total_content_width = icon_width + 1 + title_width + 1 + path_width + 1 + lines_text_width; + // Total content: icon + " " + title + " " + path + " " + lines_text + suffix + let total_content_width = + icon_width + 1 + title_width + 1 + path_width + 1 + lines_text_width + suffix_width; // Check if we need to truncate the path let (display_path, display_path_width) = if total_content_width > inner_width { // Need to truncate path - let available_for_path = - inner_width.saturating_sub(icon_width + 1 + title_width + 1 + 1 + lines_text_width + 3); // 3 for "..." + let available_for_path = inner_width.saturating_sub( + icon_width + 1 + title_width + 1 + 1 + lines_text_width + suffix_width + 3, + ); // 3 for "..." let truncated = truncate_path_to_width(file_path, available_for_path); let w = calculate_display_width(&truncated); (truncated, w) @@ -1740,10 +1764,10 @@ pub fn render_view_file_block( }; let actual_content_width = - icon_width + 1 + title_width + 1 + display_path_width + 1 + lines_text_width; + icon_width + 1 + title_width + 1 + display_path_width + 1 + lines_text_width + suffix_width; let padding = inner_width.saturating_sub(actual_content_width); - let content_line = Line::from(vec![ + let mut spans = vec![ Span::styled("│", Style::default().fg(border_color)), Span::from(" "), Span::styled(icon.to_string(), Style::default().fg(Color::DarkGray)), @@ -1758,9 +1782,16 @@ pub fn render_view_file_block( Span::styled(display_path, Style::default().fg(AdaptiveColors::text())), Span::from(" "), Span::styled(lines_text, Style::default().fg(Color::DarkGray)), - Span::from(" ".repeat(padding)), - Span::styled(" │", Style::default().fg(border_color)), - ]); + ]; + + if !suffix.is_empty() { + spans.push(Span::styled(suffix, Style::default().fg(Color::Cyan))); + } + + spans.push(Span::from(" ".repeat(padding))); + spans.push(Span::styled(" │", Style::default().fg(border_color))); + + let content_line = Line::from(spans); let horizontal_line = "─".repeat(inner_width + 2); let top_border = Line::from(vec![Span::styled( @@ -1776,11 +1807,13 @@ pub fn render_view_file_block( } /// Renders a compact view file block without borders (for full screen popup) -/// Format: Stack View path/to/file.rs - 123 lines +/// Format: Stack View path/to/file.rs - 123 lines [grep: pattern] [glob: pattern] pub fn render_view_file_block_no_border( file_path: &str, total_lines: usize, terminal_width: usize, + grep: Option<&str>, + glob: Option<&str>, ) -> Vec> { let content_width = if terminal_width > 2 { terminal_width - 2 @@ -1789,23 +1822,45 @@ pub fn render_view_file_block_no_border( }; let icon = ""; - let title = "View"; + + // Determine title based on operation type + let title = if grep.is_some() && glob.is_some() { + "Grep+Glob" + } else if grep.is_some() { + "Grep" + } else if glob.is_some() { + "Glob" + } else { + "View" + }; + let lines_text = format!("- {} lines", total_lines); + // Build optional grep/glob suffix + let suffix = match (grep, glob) { + (Some(g), Some(gl)) => format!(" [grep: {} | glob: {}]", g, gl), + (Some(g), None) => format!(" [grep: {}]", g), + (None, Some(g)) => format!(" [glob: {}]", g), + _ => String::new(), + }; + // Calculate display widths let icon_width = calculate_display_width(icon); let title_width = calculate_display_width(title); let path_width = calculate_display_width(file_path); let lines_text_width = calculate_display_width(&lines_text); + let suffix_width = calculate_display_width(&suffix); - // Total content: icon + " " + title + " " + path + " " + lines_text - let total_content_width = icon_width + 1 + title_width + 1 + path_width + 1 + lines_text_width; + // Total content: icon + " " + title + " " + path + " " + lines_text + suffix + let total_content_width = + icon_width + 1 + title_width + 1 + path_width + 1 + lines_text_width + suffix_width; // Check if we need to truncate the path let (display_path, _display_path_width) = if total_content_width > content_width { // Need to truncate path - let available_for_path = content_width - .saturating_sub(icon_width + 1 + title_width + 1 + 1 + lines_text_width + 3); // 3 for "..." + let available_for_path = content_width.saturating_sub( + icon_width + 1 + title_width + 1 + 1 + lines_text_width + suffix_width + 3, + ); // 3 for "..." let truncated = truncate_path_to_width(file_path, available_for_path); let w = calculate_display_width(&truncated); (truncated, w) @@ -1813,7 +1868,7 @@ pub fn render_view_file_block_no_border( (file_path.to_string(), path_width) }; - let content_line = Line::from(vec![ + let mut spans = vec![ Span::styled(icon.to_string(), Style::default().fg(Color::DarkGray)), Span::from(" "), Span::styled( @@ -1826,7 +1881,13 @@ pub fn render_view_file_block_no_border( Span::styled(display_path, Style::default().fg(AdaptiveColors::text())), Span::from(" "), Span::styled(lines_text, Style::default().fg(Color::DarkGray)), - ]); + ]; + + if !suffix.is_empty() { + spans.push(Span::styled(suffix, Style::default().fg(Color::Cyan))); + } + + let content_line = Line::from(spans); vec![content_line] } diff --git a/tui/src/services/handlers/tool.rs b/tui/src/services/handlers/tool.rs index 9e37a36b..00df807c 100644 --- a/tui/src/services/handlers/tool.rs +++ b/tui/src/services/handlers/tool.rs @@ -580,44 +580,33 @@ fn extract_diff_preview(message: &str) -> Option { } } -/// Extract file path from a view/read tool call -pub fn extract_file_path_from_tool_call(tool_call: &ToolCall) -> Option { +/// Extract view tool parameters (path, grep, glob) from a tool call +pub fn extract_view_params_from_tool_call( + tool_call: &ToolCall, +) -> (Option, Option, Option) { // Try to parse arguments as JSON if let Ok(args) = serde_json::from_str::(&tool_call.function.arguments) { - // Common field names for file path in read/view tools - if let Some(path) = args - .get("filePath") + let path = args + .get("path") + .or(args.get("filePath")) .or(args.get("file_path")) - .or(args.get("path")) - .or(args.get("file")) - .or(args.get("target")) .and_then(|v| v.as_str()) - { - return Some(path.to_string()); - } - } + .map(|s| s.to_string()); - // Fallback: try to extract from raw arguments string - let args_str = &tool_call.function.arguments; - - // Try simple patterns like filePath:"..." or path:"..." - for pattern in &["filePath", "file_path", "path", "file"] { - if let Some(start) = args_str.find(&format!("\"{}\"", pattern)) { - let after_key = &args_str[start + pattern.len() + 2..]; - // Skip to the value (after colon and possible whitespace/quotes) - if let Some(colon_pos) = after_key.find(':') { - let after_colon = after_key[colon_pos + 1..].trim_start(); - if after_colon.starts_with('"') { - let value_start = 1; - if let Some(end_quote) = after_colon[value_start..].find('"') { - return Some(after_colon[value_start..value_start + end_quote].to_string()); - } - } - } - } + let grep = args + .get("grep") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let glob = args + .get("glob") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + return (path, grep, glob); } - None + (None, None, None) } // ========== Approval Bar Handlers ========== diff --git a/tui/src/services/message.rs b/tui/src/services/message.rs index 43f8462f..af7c2e05 100644 --- a/tui/src/services/message.rs +++ b/tui/src/services/message.rs @@ -67,9 +67,9 @@ pub enum MessageContent { Option, crate::services::bash_block::RunCommandState, ), - /// View file block - compact display showing file path and line count - /// (file_path: String, total_lines: usize) - RenderViewFileBlock(String, usize), + /// View file block - compact display showing file path, line count, and optional grep/glob + /// (file_path: String, total_lines: usize, grep: Option, glob: Option) + RenderViewFileBlock(String, usize, Option, Option), } /// Compute a hash of the MessageContent for cache invalidation. @@ -194,10 +194,12 @@ pub fn hash_message_content(content: &MessageContent) -> u64 { msg.hash(&mut hasher); } } - MessageContent::RenderViewFileBlock(file_path, total_lines) => { + MessageContent::RenderViewFileBlock(file_path, total_lines, grep, glob) => { 17u8.hash(&mut hasher); file_path.hash(&mut hasher); total_lines.hash(&mut hasher); + grep.hash(&mut hasher); + glob.hash(&mut hasher); } MessageContent::UserMessage(text) => { 18u8.hash(&mut hasher); @@ -504,21 +506,31 @@ impl Message { } /// Create a view file block message - /// Shows a compact display with file icon, "View", file path, and line count - pub fn render_view_file_block(file_path: String, total_lines: usize) -> Self { + /// Shows a compact display with file icon, \"View\", file path, line count, and optional grep/glob + pub fn render_view_file_block( + file_path: String, + total_lines: usize, + grep: Option, + glob: Option, + ) -> Self { Message { id: Uuid::new_v4(), - content: MessageContent::RenderViewFileBlock(file_path, total_lines), + content: MessageContent::RenderViewFileBlock(file_path, total_lines, grep, glob), is_collapsed: None, } } /// Create a view file block message for the full screen popup (no borders) - /// Shows a compact display with file icon, "View", file path, and line count - pub fn render_view_file_block_popup(file_path: String, total_lines: usize) -> Self { + /// Shows a compact display with file icon, \"View\", file path, line count, and optional grep/glob + pub fn render_view_file_block_popup( + file_path: String, + total_lines: usize, + grep: Option, + glob: Option, + ) -> Self { Message { id: Uuid::new_v4(), - content: MessageContent::RenderViewFileBlock(file_path, total_lines), + content: MessageContent::RenderViewFileBlock(file_path, total_lines, grep, glob), // is_collapsed: Some(true) means it shows in full screen popup only is_collapsed: Some(true), } @@ -1319,16 +1331,24 @@ fn render_single_message_internal(msg: &Message, width: usize) -> Vec<(Line<'sta Style::default(), )); } - MessageContent::RenderViewFileBlock(file_path, total_lines) => { + MessageContent::RenderViewFileBlock(file_path, total_lines, grep, glob) => { // Use no-border version for popup (is_collapsed: Some(true)) let rendered = if msg.is_collapsed == Some(true) { crate::services::bash_block::render_view_file_block_no_border( file_path, *total_lines, width, + grep.as_deref(), + glob.as_deref(), ) } else { - crate::services::bash_block::render_view_file_block(file_path, *total_lines, width) + crate::services::bash_block::render_view_file_block( + file_path, + *total_lines, + width, + grep.as_deref(), + glob.as_deref(), + ) }; let borrowed = get_wrapped_styled_block_lines(&rendered, width); lines.extend(convert_to_owned_lines(borrowed)); @@ -1922,19 +1942,23 @@ fn get_wrapped_message_lines_internal( let owned_lines = convert_to_owned_lines(borrowed_lines); all_lines.extend(owned_lines); } - MessageContent::RenderViewFileBlock(file_path, total_lines) => { + MessageContent::RenderViewFileBlock(file_path, total_lines, grep, glob) => { // Use no-border version for popup (is_collapsed: Some(true)) let rendered_lines = if msg.is_collapsed == Some(true) { crate::services::bash_block::render_view_file_block_no_border( file_path, *total_lines, width, + grep.as_deref(), + glob.as_deref(), ) } else { crate::services::bash_block::render_view_file_block( file_path, *total_lines, width, + grep.as_deref(), + glob.as_deref(), ) }; let borrowed_lines = get_wrapped_styled_block_lines(&rendered_lines, width);