From 6baf4f3481634eda62c82349c3c23a9cabf765f8 Mon Sep 17 00:00:00 2001 From: George Date: Fri, 30 Jan 2026 00:21:43 +0200 Subject: [PATCH 1/4] feat(view): add grep and glob parameters for content search and file filtering - Add grep parameter for regex content search in files/directories - Add glob parameter for filtering files by pattern in directories - Support combined grep+glob: filter files by glob, then search content - Use ripgrep ecosystem (grep-regex, grep-searcher, ignore, globset) for local search - Remote search uses grep -E for extended regex support - Fix shell escaping for remote commands with special characters (|, $, etc.) - Update TUI to display grep/glob parameters in view blocks - Show 'Grep', 'Glob', or 'Grep+Glob' title based on operation type Local search respects .gitignore automatically via ignore crate. Remote search uses find + grep -E for glob+grep combinations. --- Cargo.lock | 61 +++ Cargo.toml | 6 + libs/mcp/server/Cargo.toml | 6 + libs/mcp/server/src/local_tools.rs | 729 ++++++++++++++++++++++++++- libs/shared/src/remote_connection.rs | 10 +- tui/src/event_loop.rs | 10 +- tui/src/services/bash_block.rs | 91 +++- tui/src/services/handlers/tool.rs | 23 + tui/src/services/message.rs | 50 +- 9 files changed, 946 insertions(+), 40 deletions(-) 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..99218344 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( @@ -608,6 +621,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 + * glob='test_*' - Files starting with test_ + 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 +650,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 +667,31 @@ 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) - .await + self.view_remote_path( + &conn, + &remote_path, + &path, + view_range, + MAX_LINES, + tree, + grep.as_deref(), + glob.as_deref(), + ) + .await } Err(error_result) => Ok(error_result), } } else { // Handle local file/directory viewing - self.view_local_path(&path, view_range, MAX_LINES, tree) - .await + self.view_local_path( + &path, + view_range, + MAX_LINES, + tree, + grep.as_deref(), + glob.as_deref(), + ) + .await } } @@ -1269,6 +1317,8 @@ SAFETY NOTES: view_range: Option<[i32; 2]>, max_lines: usize, tree: Option, + grep: Option<&str>, + glob: Option<&str>, ) -> Result { let path_obj = Path::new(path); @@ -1280,6 +1330,28 @@ SAFETY NOTES: } if path_obj.is_dir() { + // Handle combined glob + grep: filter files by glob, then search content + if let (Some(glob_pattern), Some(grep_pattern)) = (glob, grep) { + return self + .grep_local_directory_with_glob(path, grep_pattern, glob_pattern, max_lines) + .await; + } + + // Handle glob pattern filtering for directories (list files only) + if let Some(glob_pattern) = glob { + return self + .view_local_dir_with_glob(path, glob_pattern, max_lines) + .await; + } + + // Handle grep search in directory (all files) + if let Some(grep_pattern) = grep { + return self + .grep_local_directory(path, grep_pattern, max_lines) + .await; + } + + // Default directory tree view let depth = if tree.unwrap_or(false) { 3 } else { 1 }; let provider = LocalFileSystemProvider; let path_str = path_obj.to_string_lossy(); @@ -1303,6 +1375,11 @@ SAFETY NOTES: ])), } } else { + // Handle grep search in single file + if let Some(grep_pattern) = grep { + return self.grep_local_file(path, grep_pattern, max_lines); + } + // Read file contents match fs::read_to_string(path) { Ok(content) => { @@ -1328,6 +1405,332 @@ 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):\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, @@ -1337,6 +1740,8 @@ SAFETY NOTES: view_range: Option<[i32; 2]>, max_lines: usize, tree: Option, + grep: Option<&str>, + glob: Option<&str>, ) -> Result { if !conn.exists(remote_path).await { return Ok(CallToolResult::error(vec![ @@ -1349,6 +1754,47 @@ SAFETY NOTES: } if conn.is_directory(remote_path).await { + // Handle combined glob + grep for remote directories + if let (Some(glob_pattern), Some(grep_pattern)) = (glob, grep) { + return self + .grep_remote_directory_with_glob( + conn, + remote_path, + original_path, + grep_pattern, + glob_pattern, + max_lines, + ) + .await; + } + + // Handle glob pattern filtering for remote directories + if let Some(glob_pattern) = glob { + return self + .view_remote_dir_with_glob( + conn, + remote_path, + original_path, + glob_pattern, + max_lines, + ) + .await; + } + + // Handle grep search in remote directory + if let Some(grep_pattern) = grep { + return self + .grep_remote_directory( + conn, + remote_path, + original_path, + grep_pattern, + max_lines, + ) + .await; + } + + // Default directory tree view let depth = if tree.unwrap_or(false) { 3 } else { 1 }; let provider = RemoteFileSystemProvider::new(conn.clone()); @@ -1368,6 +1814,13 @@ SAFETY NOTES: ])), } } else { + // Handle grep search in single remote file + if let Some(grep_pattern) = grep { + return self + .grep_remote_file(conn, remote_path, original_path, grep_pattern, max_lines) + .await; + } + // Read remote file contents match conn.read_file_to_string(remote_path).await { Ok(content) => { @@ -1400,6 +1853,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):\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..0e325ead 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,43 @@ 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 "..." + 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 +1762,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 +1780,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 +1805,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 +1820,43 @@ 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 "..." + .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 +1864,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 +1877,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..3e4e90ff 100644 --- a/tui/src/services/handlers/tool.rs +++ b/tui/src/services/handlers/tool.rs @@ -620,6 +620,29 @@ pub fn extract_file_path_from_tool_call(tool_call: &ToolCall) -> Option None } +/// 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) { + let path = args + .get("path") + .or(args.get("filePath")) + .or(args.get("file_path")) + .and_then(|v| v.as_str()) + .map(|s| s.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) +} + // ========== Approval Bar Handlers ========== /// Handle approve all in approval bar 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); From 35539b92ace38f4ae1b286cb69a3060747a0ebaa Mon Sep 17 00:00:00 2001 From: George Date: Fri, 30 Jan 2026 00:37:49 +0200 Subject: [PATCH 2/4] fix: resolve clippy lint warnings - Remove unused extract_file_path_from_tool_call function - Refactor view_remote_path to use ViewOptions struct (reduces args from 9 to 4) --- libs/mcp/server/src/local_tools.rs | 62 +++++++++++++++++------------- tui/src/services/bash_block.rs | 34 ++++++++-------- tui/src/services/handlers/tool.rs | 50 ++++-------------------- 3 files changed, 63 insertions(+), 83 deletions(-) diff --git a/libs/mcp/server/src/local_tools.rs b/libs/mcp/server/src/local_tools.rs index 99218344..a953c391 100644 --- a/libs/mcp/server/src/local_tools.rs +++ b/libs/mcp/server/src/local_tools.rs @@ -112,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( @@ -667,17 +677,15 @@ 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, + let opts = ViewOptions { view_range, - MAX_LINES, + max_lines: MAX_LINES, tree, - grep.as_deref(), - glob.as_deref(), - ) - .await + grep: grep.as_deref(), + glob: glob.as_deref(), + }; + self.view_remote_path(&conn, &remote_path, &path, &opts) + .await } Err(error_result) => Ok(error_result), } @@ -1737,11 +1745,7 @@ SAFETY NOTES: conn: &Arc, remote_path: &str, original_path: &str, - view_range: Option<[i32; 2]>, - max_lines: usize, - tree: Option, - grep: Option<&str>, - glob: Option<&str>, + opts: &ViewOptions<'_>, ) -> Result { if !conn.exists(remote_path).await { return Ok(CallToolResult::error(vec![ @@ -1755,7 +1759,7 @@ SAFETY NOTES: if conn.is_directory(remote_path).await { // Handle combined glob + grep for remote directories - if let (Some(glob_pattern), Some(grep_pattern)) = (glob, grep) { + if let (Some(glob_pattern), Some(grep_pattern)) = (opts.glob, opts.grep) { return self .grep_remote_directory_with_glob( conn, @@ -1763,39 +1767,39 @@ SAFETY NOTES: original_path, grep_pattern, glob_pattern, - max_lines, + opts.max_lines, ) .await; } // Handle glob pattern filtering for remote directories - if let Some(glob_pattern) = glob { + if let Some(glob_pattern) = opts.glob { return self .view_remote_dir_with_glob( conn, remote_path, original_path, glob_pattern, - max_lines, + opts.max_lines, ) .await; } // Handle grep search in remote directory - if let Some(grep_pattern) = grep { + if let Some(grep_pattern) = opts.grep { return self .grep_remote_directory( conn, remote_path, original_path, grep_pattern, - max_lines, + opts.max_lines, ) .await; } // Default directory tree view - let depth = if tree.unwrap_or(false) { 3 } else { 1 }; + 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 { @@ -1815,9 +1819,15 @@ SAFETY NOTES: } } else { // Handle grep search in single remote file - if let Some(grep_pattern) = grep { + if let Some(grep_pattern) = opts.grep { return self - .grep_remote_file(conn, remote_path, original_path, grep_pattern, max_lines) + .grep_remote_file( + conn, + remote_path, + original_path, + grep_pattern, + opts.max_lines, + ) .await; } @@ -1827,8 +1837,8 @@ SAFETY NOTES: 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, @@ -1927,7 +1937,7 @@ SAFETY NOTES: max_lines: usize, ) -> Result { // Escape for double quotes (the command will be wrapped in bash -c '...' which uses double quotes internally) - // Need to escape: \ " $ ` + // Need to escape: \ " $ ` let escaped_pattern = pattern .replace('\\', "\\\\") .replace('"', "\\\"") diff --git a/tui/src/services/bash_block.rs b/tui/src/services/bash_block.rs index 0e325ead..7bb7f51d 100644 --- a/tui/src/services/bash_block.rs +++ b/tui/src/services/bash_block.rs @@ -1717,7 +1717,7 @@ pub fn render_view_file_block( let border_color = Color::DarkGray; let icon = ""; - + // Determine title based on operation type let title = if grep.is_some() && glob.is_some() { "Grep+Glob" @@ -1728,9 +1728,9 @@ pub fn render_view_file_block( } 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), @@ -1747,13 +1747,15 @@ pub fn render_view_file_block( let suffix_width = calculate_display_width(&suffix); // 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; + 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 + suffix_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) @@ -1781,11 +1783,11 @@ pub fn render_view_file_block( 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))); } - + spans.push(Span::from(" ".repeat(padding))); spans.push(Span::styled(" │", Style::default().fg(border_color))); @@ -1820,7 +1822,7 @@ pub fn render_view_file_block_no_border( }; let icon = ""; - + // Determine title based on operation type let title = if grep.is_some() && glob.is_some() { "Grep+Glob" @@ -1831,9 +1833,9 @@ pub fn render_view_file_block_no_border( } 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), @@ -1850,13 +1852,15 @@ pub fn render_view_file_block_no_border( let suffix_width = calculate_display_width(&suffix); // 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; + 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 + suffix_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) @@ -1878,7 +1882,7 @@ pub fn render_view_file_block_no_border( 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))); } diff --git a/tui/src/services/handlers/tool.rs b/tui/src/services/handlers/tool.rs index 3e4e90ff..00df807c 100644 --- a/tui/src/services/handlers/tool.rs +++ b/tui/src/services/handlers/tool.rs @@ -580,46 +580,6 @@ 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 { - // 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") - .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()); - } - } - - // 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()); - } - } - } - } - } - - None -} - /// Extract view tool parameters (path, grep, glob) from a tool call pub fn extract_view_params_from_tool_call( tool_call: &ToolCall, @@ -633,9 +593,15 @@ pub fn extract_view_params_from_tool_call( .and_then(|v| v.as_str()) .map(|s| s.to_string()); - let grep = args.get("grep").and_then(|v| v.as_str()).map(|s| s.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()); + let glob = args + .get("glob") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); return (path, grep, glob); } From dfdb7a8a4da006c5a22ef8f77e627aaf441bdd25 Mon Sep 17 00:00:00 2001 From: George Date: Fri, 30 Jan 2026 00:39:22 +0200 Subject: [PATCH 3/4] refactor: reuse ViewOptions in view_local_path - Reduces view_local_path args from 8 to 3 - Consistent pattern with view_remote_path --- libs/mcp/server/src/local_tools.rs | 52 ++++++++++++++++-------------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/libs/mcp/server/src/local_tools.rs b/libs/mcp/server/src/local_tools.rs index a953c391..41fa2cf8 100644 --- a/libs/mcp/server/src/local_tools.rs +++ b/libs/mcp/server/src/local_tools.rs @@ -691,15 +691,14 @@ A maximum of 300 lines will be shown at a time, the rest will be truncated." } } else { // Handle local file/directory viewing - self.view_local_path( - &path, + let opts = ViewOptions { view_range, - MAX_LINES, + max_lines: MAX_LINES, tree, - grep.as_deref(), - glob.as_deref(), - ) - .await + grep: grep.as_deref(), + glob: glob.as_deref(), + }; + self.view_local_path(&path, &opts).await } } @@ -1322,11 +1321,7 @@ SAFETY NOTES: async fn view_local_path( &self, path: &str, - view_range: Option<[i32; 2]>, - max_lines: usize, - tree: Option, - grep: Option<&str>, - glob: Option<&str>, + opts: &ViewOptions<'_>, ) -> Result { let path_obj = Path::new(path); @@ -1339,28 +1334,33 @@ SAFETY NOTES: if path_obj.is_dir() { // Handle combined glob + grep: filter files by glob, then search content - if let (Some(glob_pattern), Some(grep_pattern)) = (glob, grep) { + if let (Some(glob_pattern), Some(grep_pattern)) = (opts.glob, opts.grep) { return self - .grep_local_directory_with_glob(path, grep_pattern, glob_pattern, max_lines) + .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) = glob { + if let Some(glob_pattern) = opts.glob { return self - .view_local_dir_with_glob(path, glob_pattern, max_lines) + .view_local_dir_with_glob(path, glob_pattern, opts.max_lines) .await; } // Handle grep search in directory (all files) - if let Some(grep_pattern) = grep { + if let Some(grep_pattern) = opts.grep { return self - .grep_local_directory(path, grep_pattern, max_lines) + .grep_local_directory(path, grep_pattern, opts.max_lines) .await; } // Default directory tree view - let depth = if tree.unwrap_or(false) { 3 } else { 1 }; + let depth = if opts.tree.unwrap_or(false) { 3 } else { 1 }; let provider = LocalFileSystemProvider; let path_str = path_obj.to_string_lossy(); @@ -1384,16 +1384,20 @@ SAFETY NOTES: } } else { // Handle grep search in single file - if let Some(grep_pattern) = grep { - return self.grep_local_file(path, grep_pattern, max_lines); + 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![ From 281cabfeb96c77a7678293d269746c781fa87d05 Mon Sep 17 00:00:00 2001 From: George Date: Fri, 30 Jan 2026 00:52:33 +0200 Subject: [PATCH 4/4] docs: improve glob examples and make grep match count consistent --- libs/mcp/server/src/local_tools.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/mcp/server/src/local_tools.rs b/libs/mcp/server/src/local_tools.rs index 41fa2cf8..56b4340c 100644 --- a/libs/mcp/server/src/local_tools.rs +++ b/libs/mcp/server/src/local_tools.rs @@ -645,8 +645,8 @@ GLOB (File Filtering): - Supports standard glob syntax: *, ?, [abc], ** - Examples: * glob='*.rs' - All Rust files - * glob='*.ts' - All TypeScript files - * glob='test_*' - Files starting with test_ + * 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] @@ -1547,7 +1547,7 @@ SAFETY NOTES: let total = matches.len(); let result = format!( - "Grep results for '{}' in \"{}\" ({} matches):\n\n{}", + "Grep results for '{}' in \"{}\" ({} matches in 1 file):\n\n{}", pattern, path, total, @@ -1973,7 +1973,7 @@ SAFETY NOTES: let display_lines: Vec<&str> = lines.into_iter().take(max_lines).collect(); let mut result = format!( - "Grep results for '{}' in \"{}\" ({} matches):\n\n{}", + "Grep results for '{}' in \"{}\" ({} matches in 1 file):\n\n{}", pattern, original_path, display_lines.len(),