Fix research tool UI: remove duplicate header, add footer spacing, remove spinner, widen command display
- Remove duplicate tool header (lib.rs already prints it) - Add newline before timing footer for visual separation - Remove spinner animation (incompatible with update_tool_output_line) - Change shell command format to " > `cmd` ..." with 60 char width
This commit is contained in:
@@ -14,12 +14,134 @@ use super::executor::ToolContext;
|
||||
const REPORT_START_MARKER: &str = "---SCOUT_REPORT_START---";
|
||||
const REPORT_END_MARKER: &str = "---SCOUT_REPORT_END---";
|
||||
|
||||
/// Execute the research tool by spawning a scout agent.
|
||||
/// Translate scout agent output lines into friendly progress messages.
|
||||
///
|
||||
/// This tool:
|
||||
/// 1. Spawns `g3 --agent scout` with the query
|
||||
/// 2. Captures stdout and extracts the report between delimiter markers
|
||||
/// 3. Returns the report content directly
|
||||
/// Parses tool call headers from the scout output and returns human-readable
|
||||
/// progress messages. Returns None for lines that should be suppressed.
|
||||
fn translate_progress(line: &str) -> Option<String> {
|
||||
// Strip ANSI codes first for pattern matching
|
||||
let clean_line = strip_ansi_codes(line);
|
||||
let trimmed = clean_line.trim();
|
||||
|
||||
// Tool call header pattern: "┌─ tool_name" or "┌─ tool_name | args"
|
||||
if !trimmed.starts_with("┌─") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Extract tool name and optional args after the box drawing char
|
||||
let after_prefix = trimmed.trim_start_matches("┌─").trim();
|
||||
|
||||
// Split on " | " to separate tool name from args
|
||||
let (tool_name, args) = if let Some(pipe_pos) = after_prefix.find(" | ") {
|
||||
let name = after_prefix[..pipe_pos].trim();
|
||||
let arg = after_prefix[pipe_pos + 3..].trim();
|
||||
(name, Some(arg))
|
||||
} else {
|
||||
(after_prefix.trim(), None)
|
||||
};
|
||||
|
||||
// Translate tool names to friendly messages
|
||||
match tool_name {
|
||||
"webdriver_start" => Some("🌐 Launching browser...".to_string()),
|
||||
|
||||
"webdriver_navigate" => {
|
||||
if let Some(url) = args {
|
||||
// Extract domain from URL for cleaner display
|
||||
let display_url = extract_domain(url).unwrap_or(url);
|
||||
Some(format!("🔗 Navigating to {}...", display_url))
|
||||
} else {
|
||||
Some("🔗 Navigating...".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
"webdriver_get_page_source" => {
|
||||
if let Some(arg) = args {
|
||||
// arg might be max_length or file path
|
||||
if arg.contains('/') || arg.ends_with(".html") || arg.ends_with(".md") {
|
||||
let filename = arg.rsplit('/').next().unwrap_or(arg);
|
||||
Some(format!("📥 Downloading {}...", filename))
|
||||
} else {
|
||||
Some("📄 Reading page content...".to_string())
|
||||
}
|
||||
} else {
|
||||
Some("📄 Reading page content...".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
"webdriver_find_element" | "webdriver_find_elements" => {
|
||||
Some("🔍 Searching page...".to_string())
|
||||
}
|
||||
|
||||
"webdriver_click" => Some("👆 Clicking element...".to_string()),
|
||||
|
||||
"webdriver_quit" => Some("✅ Closing browser...".to_string()),
|
||||
|
||||
"read_file" => {
|
||||
if let Some(path) = args {
|
||||
// Check if there's a range specified (format: "filename [start..end]")
|
||||
if let Some(bracket_pos) = path.find(" [") {
|
||||
let filename = path[..bracket_pos].rsplit('/').next().unwrap_or(&path[..bracket_pos]);
|
||||
let range = &path[bracket_pos + 1..]; // includes "[start..end]"
|
||||
Some(format!("📖 Reading {} slice {}...", filename, range.trim_end_matches(']').trim_start_matches('[')))
|
||||
} else {
|
||||
let filename = path.rsplit('/').next().unwrap_or(path);
|
||||
Some(format!("📖 Reading {}...", filename))
|
||||
}
|
||||
} else {
|
||||
Some("📖 Reading file...".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
"write_file" => {
|
||||
if let Some(path) = args {
|
||||
let filename = path.rsplit('/').next().unwrap_or(path);
|
||||
Some(format!("💾 Writing {}...", filename))
|
||||
} else {
|
||||
Some("💾 Writing file...".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
"shell" => {
|
||||
if let Some(cmd) = args {
|
||||
// Show a truncated snippet of the command with wider display
|
||||
let snippet = truncate_command_snippet(cmd, 60);
|
||||
Some(format!(" > `{}` ...", snippet))
|
||||
} else {
|
||||
Some("⚙️ Running command...".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// Suppress unknown tools - don't show raw output
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract domain from a URL for cleaner display.
|
||||
fn extract_domain(url: &str) -> Option<&str> {
|
||||
// Remove protocol
|
||||
let without_protocol = url
|
||||
.strip_prefix("https://")
|
||||
.or_else(|| url.strip_prefix("http://"))
|
||||
.unwrap_or(url);
|
||||
|
||||
// Get just the domain (before any path)
|
||||
without_protocol.split('/').next()
|
||||
}
|
||||
|
||||
/// Truncate a command to a maximum length for display.
|
||||
/// Preserves the beginning of the command and adds "..." if truncated.
|
||||
fn truncate_command_snippet(cmd: &str, max_len: usize) -> String {
|
||||
// Take just the first line if multi-line
|
||||
let first_line = cmd.lines().next().unwrap_or(cmd);
|
||||
|
||||
if first_line.chars().count() <= max_len {
|
||||
first_line.to_string()
|
||||
} else {
|
||||
let truncated: String = first_line.chars().take(max_len - 3).collect();
|
||||
format!("{}...", truncated)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute_research<W: UiWriter>(
|
||||
tool_call: &ToolCall,
|
||||
ctx: &mut ToolContext<'_, W>,
|
||||
@@ -30,9 +152,6 @@ pub async fn execute_research<W: UiWriter>(
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required 'query' parameter"))?;
|
||||
|
||||
ctx.ui_writer.print_tool_header("research", None);
|
||||
ctx.ui_writer.print_tool_arg("query", query);
|
||||
|
||||
// Find the g3 executable path
|
||||
let g3_path = std::env::current_exe()
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from("g3"));
|
||||
@@ -57,13 +176,15 @@ pub async fn execute_research<W: UiWriter>(
|
||||
let mut reader = BufReader::new(stdout).lines();
|
||||
let mut all_output = Vec::new();
|
||||
|
||||
// Print a header for the scout output
|
||||
ctx.ui_writer.println("\n📡 Scout agent researching...");
|
||||
|
||||
// Collect all lines
|
||||
// Collect all lines, showing only translated progress messages
|
||||
while let Some(line) = reader.next_line().await? {
|
||||
ctx.ui_writer.println(&format!(" {}", line));
|
||||
all_output.push(line);
|
||||
all_output.push(line.clone());
|
||||
|
||||
// Show translated progress for tool calls
|
||||
if let Some(progress_msg) = translate_progress(&line) {
|
||||
// Update the status line in-place (no spinner)
|
||||
ctx.ui_writer.update_tool_output_line(&progress_msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the process to complete
|
||||
@@ -77,15 +198,23 @@ pub async fn execute_research<W: UiWriter>(
|
||||
// Join all output and extract the report between markers
|
||||
let full_output = all_output.join("\n");
|
||||
|
||||
extract_report(&full_output)
|
||||
let report = extract_report(&full_output)?;
|
||||
|
||||
// Print the research brief to the console for scrollback reference
|
||||
// The report is printed without stripping ANSI codes to preserve formatting
|
||||
ctx.ui_writer.println("");
|
||||
ctx.ui_writer.println(&report);
|
||||
ctx.ui_writer.println("");
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Extract the research report from scout output.
|
||||
///
|
||||
/// Looks for content between SCOUT_REPORT_START and SCOUT_REPORT_END markers.
|
||||
/// Strips ANSI escape codes from the extracted content.
|
||||
/// Preserves ANSI escape codes in the extracted content for terminal formatting.
|
||||
fn extract_report(output: &str) -> Result<String> {
|
||||
// Strip ANSI codes from the entire output first
|
||||
// Strip ANSI codes only for finding markers, but preserve them in the output
|
||||
let clean_output = strip_ansi_codes(output);
|
||||
|
||||
// Find the start marker
|
||||
@@ -106,9 +235,16 @@ fn extract_report(output: &str) -> Result<String> {
|
||||
return Err(anyhow::anyhow!("Invalid report format: end marker before start marker"));
|
||||
}
|
||||
|
||||
// Extract content between markers
|
||||
let report_start = start_pos + REPORT_START_MARKER.len();
|
||||
let report_content = clean_output[report_start..end_pos].trim();
|
||||
// Now find the same markers in the original output to preserve ANSI codes
|
||||
// We need to find the marker positions accounting for ANSI codes
|
||||
let original_start = find_marker_position(output, REPORT_START_MARKER)
|
||||
.ok_or_else(|| anyhow::anyhow!("Could not find start marker in original output"))?;
|
||||
let original_end = find_marker_position(output, REPORT_END_MARKER)
|
||||
.ok_or_else(|| anyhow::anyhow!("Could not find end marker in original output"))?;
|
||||
|
||||
// Extract content between markers from original (with ANSI codes)
|
||||
let report_start = original_start + REPORT_START_MARKER.len();
|
||||
let report_content = output[report_start..original_end].trim();
|
||||
|
||||
if report_content.is_empty() {
|
||||
return Ok("❌ Scout agent returned an empty report.".to_string());
|
||||
@@ -117,6 +253,20 @@ fn extract_report(output: &str) -> Result<String> {
|
||||
Ok(format!("📋 Research Report:\n\n{}", report_content))
|
||||
}
|
||||
|
||||
/// Find the position of a marker in text that may contain ANSI codes.
|
||||
/// Searches by stripping ANSI codes character by character to find the true position.
|
||||
fn find_marker_position(text: &str, marker: &str) -> Option<usize> {
|
||||
// Simple approach: search for the marker directly first
|
||||
// The markers themselves shouldn't contain ANSI codes
|
||||
if let Some(pos) = text.find(marker) {
|
||||
return Some(pos);
|
||||
}
|
||||
|
||||
// If not found directly, the marker might be split by ANSI codes
|
||||
// This is unlikely for our use case, but handle it gracefully
|
||||
None
|
||||
}
|
||||
|
||||
/// Strip ANSI escape codes from a string.
|
||||
///
|
||||
/// Handles common ANSI sequences like:
|
||||
@@ -237,4 +387,112 @@ Some trailing text"#;
|
||||
let result = extract_report(output).unwrap();
|
||||
assert!(result.contains("empty report"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_domain() {
|
||||
assert_eq!(extract_domain("https://www.rust-lang.org/"), Some("www.rust-lang.org"));
|
||||
assert_eq!(extract_domain("https://python.org/downloads"), Some("python.org"));
|
||||
assert_eq!(extract_domain("http://example.com"), Some("example.com"));
|
||||
assert_eq!(extract_domain("example.com/path"), Some("example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_progress_webdriver_start() {
|
||||
let line = "┌─ webdriver_start";
|
||||
assert_eq!(translate_progress(line), Some("🌐 Launching browser...".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_progress_webdriver_navigate() {
|
||||
let line = "┌─ webdriver_navigate | https://www.rust-lang.org/";
|
||||
assert_eq!(translate_progress(line), Some("🔗 Navigating to www.rust-lang.org...".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_progress_webdriver_get_page_source() {
|
||||
// With max_length arg (number)
|
||||
let line = "┌─ webdriver_get_page_source | 15000";
|
||||
assert_eq!(translate_progress(line), Some("📄 Reading page content...".to_string()));
|
||||
|
||||
// With file path
|
||||
let line = "┌─ webdriver_get_page_source | tmp/rust_release.html";
|
||||
assert_eq!(translate_progress(line), Some("📥 Downloading rust_release.html...".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_progress_webdriver_find_elements() {
|
||||
let line = "┌─ webdriver_find_elements | .download-os-source, .download-for-current-os";
|
||||
assert_eq!(translate_progress(line), Some("🔍 Searching page...".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_progress_webdriver_quit() {
|
||||
let line = "┌─ webdriver_quit";
|
||||
assert_eq!(translate_progress(line), Some("✅ Closing browser...".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_progress_read_file() {
|
||||
// Without range
|
||||
let line = "┌─ read_file | /path/to/file.rs";
|
||||
assert_eq!(translate_progress(line), Some("📖 Reading file.rs...".to_string()));
|
||||
|
||||
// With range (file slice)
|
||||
let line = "┌─ read_file | /path/to/file.rs [1000..2000]";
|
||||
assert_eq!(translate_progress(line), Some("📖 Reading file.rs slice 1000..2000...".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_progress_write_file() {
|
||||
let line = "┌─ write_file | output.md";
|
||||
assert_eq!(translate_progress(line), Some("💾 Writing output.md...".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_progress_shell() {
|
||||
let line = "┌─ shell | ls -la";
|
||||
assert_eq!(translate_progress(line), Some(" > `ls -la` ...".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_progress_with_ansi_codes() {
|
||||
// Real output from scout agent has ANSI codes
|
||||
let line = "\x1b[1;38;5;69m┌─ webdriver_start\x1b[0m";
|
||||
assert_eq!(translate_progress(line), Some("🌐 Launching browser...".to_string()));
|
||||
|
||||
let line = "\x1b[1;38;5;69m┌─ webdriver_navigate\x1b[0m\x1b[35m | https://www.python.org/\x1b[0m";
|
||||
assert_eq!(translate_progress(line), Some("🔗 Navigating to www.python.org...".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_progress_suppresses_non_tool_lines() {
|
||||
assert_eq!(translate_progress("Some random output"), None);
|
||||
assert_eq!(translate_progress("│ Page source (59851 chars)"), None);
|
||||
assert_eq!(translate_progress("└─ ⚡️ 1.5s"), None);
|
||||
assert_eq!(translate_progress(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_command_snippet() {
|
||||
// Short command - no truncation
|
||||
assert_eq!(truncate_command_snippet("ls -la", 40), "ls -la");
|
||||
|
||||
// Long command - truncated
|
||||
let long_cmd = "grep -r 'some very long search pattern' --include='*.rs' /path/to/directory";
|
||||
let result = truncate_command_snippet(long_cmd, 40);
|
||||
assert!(result.len() <= 40);
|
||||
assert!(result.ends_with("..."));
|
||||
|
||||
// Multi-line command - only first line
|
||||
let multi_line = "echo 'line1'\necho 'line2'";
|
||||
assert_eq!(truncate_command_snippet(multi_line, 40), "echo 'line1'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_progress_shell_long_command() {
|
||||
let line = "┌─ shell | grep -r 'some very long search pattern that exceeds the limit' --include='*.rs'";
|
||||
let result = translate_progress(line).unwrap();
|
||||
assert!(result.starts_with(" > `grep"));
|
||||
assert!(result.contains("..."));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user