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:
Dhanji R. Prasanna
2026-01-10 15:20:40 +11:00
parent 0aa1287ca6
commit 68c9135913
22 changed files with 20843 additions and 39 deletions

View File

@@ -231,6 +231,8 @@ impl UiWriter for ConsoleUiWriter {
}
fn print_tool_timing(&self, duration_str: &str, tokens_delta: u32, context_percentage: f32) {
// Add blank line before footer for visual separation
println!();
// Parse the duration string to determine color
// Format is like "1.5s", "500ms", "2m 30.0s"
let color_code = if duration_str.ends_with("ms") {

View File

@@ -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("..."));
}
}