Skip to content

Commit 486ab21

Browse files
committed
Summarize search_code tool output
1 parent f3c88ba commit 486ab21

4 files changed

Lines changed: 98 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ All notable changes to Sofos are documented in this file.
1414
- GitHub Actions release workflow for prebuilt binaries (macOS, Linux, Windows)
1515

1616
### Changed
17+
- `search_code` tool display now shows a one-line summary (`Found N matches in M files for <pattern>`) instead of dumping full ripgrep output to the terminal; the LLM still receives the full results
1718
- Morph edit falls back to `edit_file` on timeout instead of failing; added truncation marker guards for `edit_file` and `morph_edit_file`
1819
- `Read(/path/**)` glob now also matches the base directory itself (for `list_directory`)
1920
- `morph_edit_file` tool schema now matches the official Morph Fast Apply schema (`target_filepath`, `instructions`, `code_edit`); legacy `path`/`instruction`/`file_path`/`file` names are still accepted as fallbacks

src/tools/codesearch.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ use crate::error::{Result, SofosError};
22
use std::path::PathBuf;
33
use std::process::Command;
44

5+
/// Shared so the UI display layer can strip it without duplicating the literal.
6+
pub const SEARCH_RESULTS_PREFIX: &str = "Code search results:\n\n";
7+
58
#[derive(Clone)]
69
pub struct CodeSearchTool {
710
workspace: PathBuf,

src/tools/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -529,7 +529,7 @@ impl ToolExecutor {
529529
let max_results = input["max_results"].as_u64().map(|n| n as usize);
530530

531531
let results = code_search.search(pattern, file_type, max_results)?;
532-
Ok(format!("Code search results:\n\n{}", results))
532+
Ok(format!("{}{}", codesearch::SEARCH_RESULTS_PREFIX, results))
533533
}
534534
ToolName::GlobFiles => {
535535
let pattern = input["pattern"].as_str().ok_or_else(|| {

src/ui/mod.rs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,52 @@ impl UI {
609609
format!("Fetched {} ({} chars)", url.bright_cyan(), char_count)
610610
}
611611
"morph_edit_file" => output.to_string(),
612+
"search_code" => {
613+
let pattern = tool_input
614+
.get("pattern")
615+
.and_then(|v| v.as_str())
616+
.unwrap_or("");
617+
618+
let body = output
619+
.strip_prefix(crate::tools::codesearch::SEARCH_RESULTS_PREFIX)
620+
.unwrap_or(output);
621+
622+
// ripgrep --heading output groups matches under file headings
623+
// separated by blank lines. Lines starting with `<digits>:` are
624+
// matches; non-empty lines without that prefix are file
625+
// headings.
626+
let mut files = 0usize;
627+
let mut matches = 0usize;
628+
for line in body.lines() {
629+
if line.is_empty() {
630+
continue;
631+
}
632+
if line.starts_with("No matches found") {
633+
continue;
634+
}
635+
let is_match_line = line.split_once(':').is_some_and(|(prefix, _)| {
636+
!prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit())
637+
});
638+
if is_match_line {
639+
matches += 1;
640+
} else {
641+
files += 1;
642+
}
643+
}
644+
645+
if matches == 0 {
646+
format!("No matches for {}", pattern.bright_cyan())
647+
} else {
648+
format!(
649+
"Found {} match{} in {} file{} for {}",
650+
matches,
651+
if matches == 1 { "" } else { "es" },
652+
files,
653+
if files == 1 { "" } else { "s" },
654+
pattern.bright_cyan()
655+
)
656+
}
657+
}
612658
_ => output.to_string(),
613659
}
614660
}
@@ -702,3 +748,50 @@ pub fn set_safe_mode_cursor_style() -> io::Result<()> {
702748
pub fn set_normal_mode_cursor_style() -> io::Result<()> {
703749
set_cursor_style(SetCursorStyle::DefaultUserShape)
704750
}
751+
752+
#[cfg(test)]
753+
mod tool_display_tests {
754+
use super::*;
755+
use serde_json::json;
756+
757+
fn strip_ansi(s: &str) -> String {
758+
let mut out = String::with_capacity(s.len());
759+
let mut chars = s.chars();
760+
while let Some(c) = chars.next() {
761+
if c == '\x1b' {
762+
for cc in chars.by_ref() {
763+
if cc.is_ascii_alphabetic() {
764+
break;
765+
}
766+
}
767+
} else {
768+
out.push(c);
769+
}
770+
}
771+
out
772+
}
773+
774+
#[test]
775+
fn search_code_summarizes_matches_and_files() {
776+
let output = "Code search results:\n\nsrc/foo.rs\n12: let x = 1;\n34: let y = 2;\n\nsrc/bar.rs\n7: let z = 3;\n";
777+
let msg =
778+
UI::create_tool_display_message("search_code", &json!({"pattern": "let"}), output);
779+
assert_eq!(strip_ansi(&msg), "Found 3 matches in 2 files for let");
780+
}
781+
782+
#[test]
783+
fn search_code_handles_single_match_singular() {
784+
let output = "Code search results:\n\nsrc/foo.rs\n12: let x = 1;\n";
785+
let msg =
786+
UI::create_tool_display_message("search_code", &json!({"pattern": "let"}), output);
787+
assert_eq!(strip_ansi(&msg), "Found 1 match in 1 file for let");
788+
}
789+
790+
#[test]
791+
fn search_code_handles_no_matches() {
792+
let output = "Code search results:\n\nNo matches found for pattern: 'foo'";
793+
let msg =
794+
UI::create_tool_display_message("search_code", &json!({"pattern": "foo"}), output);
795+
assert_eq!(strip_ansi(&msg), "No matches for foo");
796+
}
797+
}

0 commit comments

Comments
 (0)