From 2c411c058a4e5bd433cb758e28a7074ec399202e Mon Sep 17 00:00:00 2001 From: "Dhanji R. Prasanna" Date: Mon, 12 Jan 2026 14:37:47 +0530 Subject: [PATCH] Compact single-line tool output for file operations and shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement compact display format for read_file, write_file, str_replace, and shell: - read_file/write_file/str_replace: Single line with dimmed summary and timing Format: ● tool_name | path [range] | summary | tokens ◉ time - shell: Two-line format with command header and dimmed output Format: ● shell | command └─ output (N lines) | tokens ◉ time Changes: - Add print_tool_compact() method to UiWriter trait - Add is_shell_compact state tracking in ConsoleUiWriter - Add format_write_file_summary() and format_str_replace_summary() helpers - Fix duplicate response output by checking if response is empty before printing - Add finish_streaming_markdown() call before return to flush markdown buffer --- crates/g3-cli/src/autonomous.rs | 5 +- crates/g3-cli/src/lib.rs | 5 +- crates/g3-cli/src/task_execution.rs | 5 +- crates/g3-cli/src/ui_writer_impl.rs | 140 ++++++++++++++++++++++++++-- crates/g3-core/src/lib.rs | 78 +++++++++++++--- crates/g3-core/src/streaming.rs | 27 +++++- crates/g3-core/src/ui_writer.rs | 8 ++ 7 files changed, 242 insertions(+), 26 deletions(-) diff --git a/crates/g3-cli/src/autonomous.rs b/crates/g3-cli/src/autonomous.rs index f534bc6..b735eae 100644 --- a/crates/g3-cli/src/autonomous.rs +++ b/crates/g3-cli/src/autonomous.rs @@ -418,7 +418,10 @@ async fn execute_player_turn( { Ok(result) => { output.print("📝 Player implementation completed:"); - output.print_smart(&result.response); + // Only print response if it's not empty (streaming already displayed it) + if !result.response.trim().is_empty() { + output.print_smart(&result.response); + } return PlayerTurnResult::Success; } Err(e) => { diff --git a/crates/g3-cli/src/lib.rs b/crates/g3-cli/src/lib.rs index 0aa639a..87559f8 100644 --- a/crates/g3-cli/src/lib.rs +++ b/crates/g3-cli/src/lib.rs @@ -220,7 +220,10 @@ async fn run_console_mode( let result = agent .execute_task_with_timing(&task, None, false, cli.show_prompt, cli.show_code, true, None) .await?; - output.print_smart(&result.response); + // Only print response if it's not empty (streaming already displayed it) + if !result.response.trim().is_empty() { + output.print_smart(&result.response); + } if let Err(e) = agent.send_auto_memory_reminder().await { debug!("Auto-memory reminder failed: {}", e); diff --git a/crates/g3-cli/src/task_execution.rs b/crates/g3-cli/src/task_execution.rs index 9ad01f3..86cae2d 100644 --- a/crates/g3-cli/src/task_execution.rs +++ b/crates/g3-cli/src/task_execution.rs @@ -49,7 +49,10 @@ pub async fn execute_task_with_retry( if attempt > 1 { output.print(&format!("✅ Request succeeded after {} attempts", attempt)); } - output.print_smart(&result.response); + // Only print response if it's not empty (streaming already displayed it) + if !result.response.trim().is_empty() { + output.print_smart(&result.response); + } return; } Err(e) => { diff --git a/crates/g3-cli/src/ui_writer_impl.rs b/crates/g3-cli/src/ui_writer_impl.rs index c2fe358..0f0b572 100644 --- a/crates/g3-cli/src/ui_writer_impl.rs +++ b/crates/g3-cli/src/ui_writer_impl.rs @@ -12,6 +12,8 @@ pub struct ConsoleUiWriter { current_output_line: std::sync::Mutex>, output_line_printed: std::sync::Mutex, is_agent_mode: std::sync::Mutex, + /// Track if we're in shell compact mode (for appending timing to output line) + is_shell_compact: std::sync::Mutex, /// Streaming markdown formatter for agent responses markdown_formatter: Mutex>, } @@ -24,6 +26,7 @@ impl ConsoleUiWriter { current_output_line: std::sync::Mutex::new(None), output_line_printed: std::sync::Mutex::new(false), is_agent_mode: std::sync::Mutex::new(false), + is_shell_compact: std::sync::Mutex::new(false), markdown_formatter: Mutex::new(None), } } @@ -116,6 +119,8 @@ impl UiWriter for ConsoleUiWriter { // Reset output_line_printed at the start of a new tool output // This ensures the header isn't cleared by update_tool_output_line *self.output_line_printed.lock().unwrap() = false; + // Reset shell compact mode + *self.is_shell_compact.lock().unwrap() = false; // Now print the tool header with the most important arg // Use light gray/silver in agent mode, bold green otherwise let is_agent_mode = *self.is_agent_mode.lock().unwrap(); @@ -173,6 +178,17 @@ impl UiWriter for ConsoleUiWriter { String::new() }; + // Check if this is a shell command - use compact format + if tool_name == "shell" { + *self.is_shell_compact.lock().unwrap() = true; + // Print compact shell header: "● shell | command" + println!( + " \x1b[2m●\x1b[0m {}{} \x1b[2m|\x1b[0m \x1b[35m{}\x1b[0m", + tool_color, tool_name, display_value + ); + return; + } + // Print with tool name in color (royal blue for agent mode, green otherwise) println!( "┌─{} {}\x1b[0m\x1b[35m | {}{}\x1b[0m", @@ -191,11 +207,17 @@ impl UiWriter for ConsoleUiWriter { const MAX_LINE_WIDTH: usize = 120; let mut current_line = self.current_output_line.lock().unwrap(); let mut line_printed = self.output_line_printed.lock().unwrap(); + let is_shell = *self.is_shell_compact.lock().unwrap(); // If we've already printed a line, clear it first if *line_printed { - // Move cursor up one line and clear it - print!("\x1b[1A\x1b[2K"); + if is_shell { + // For shell, we printed without newline, so just clear the line + print!("\r\x1b[2K"); + } else { + // Move cursor up one line and clear it + print!("\x1b[1A\x1b[2K"); + } } // Truncate line if needed to prevent wrapping @@ -206,7 +228,13 @@ impl UiWriter for ConsoleUiWriter { line.to_string() }; - println!("│ \x1b[2m{}\x1b[0m", display_line); + // Use different prefix for shell (└─) vs other tools (│) + if is_shell { + // For shell, print without newline so timing can be appended + print!(" \x1b[2m└─ {}\x1b[0m", display_line); + } else { + println!("│ \x1b[2m{}\x1b[0m", display_line); + } let _ = io::stdout().flush(); // Update state @@ -223,11 +251,96 @@ impl UiWriter for ConsoleUiWriter { } fn print_tool_output_summary(&self, count: usize) { + let is_shell = *self.is_shell_compact.lock().unwrap(); + if is_shell { + // For shell, append to the same line (no newline) + print!(" \x1b[2m({} line{})\x1b[0m", count, if count == 1 { "" } else { "s" }); + let _ = io::stdout().flush(); + } else { + println!( + "│ \x1b[2m({} line{})\x1b[0m", + count, + if count == 1 { "" } else { "s" } + ); + } + } + + fn print_tool_compact(&self, tool_name: &str, summary: &str, duration_str: &str, tokens_delta: u32, _context_percentage: f32) -> bool { + // Only handle file operation tools in compact format + let is_compact_tool = matches!(tool_name, "read_file" | "write_file" | "str_replace"); + if !is_compact_tool { + return false; + } + + let args = self.current_tool_args.lock().unwrap(); + let is_agent_mode = *self.is_agent_mode.lock().unwrap(); + + // Get file path + let file_path = args + .iter() + .find(|(k, _)| k == "file_path") + .map(|(_, v)| v.as_str()) + .unwrap_or("?"); + + // Truncate long paths + let display_path = if file_path.len() > 60 { + let truncate_at = file_path + .char_indices() + .nth(57) + .map(|(i, _)| i) + .unwrap_or(file_path.len()); + format!("{}...", &file_path[..truncate_at]) + } else { + file_path.to_string() + }; + + // Build range suffix for read_file + let range_suffix = if tool_name == "read_file" { + let has_start = args.iter().any(|(k, _)| k == "start"); + let has_end = args.iter().any(|(k, _)| k == "end"); + if has_start || has_end { + let start_val = args + .iter() + .find(|(k, _)| k == "start") + .map(|(_, v)| v.as_str()) + .unwrap_or("0"); + let end_val = args + .iter() + .find(|(k, _)| k == "end") + .map(|(_, v)| v.as_str()) + .unwrap_or("end"); + format!(" [{}..{}]", start_val, end_val) + } else { + String::new() + } + } else { + String::new() + }; + + // Color for tool name + let tool_color = if is_agent_mode { "\x1b[38;5;250m" } else { "\x1b[32m" }; + + // Print compact single line: + // " ● read_file | path [range] | summary | tokens ◉ time" println!( - "│ \x1b[2m({} line{})\x1b[0m", - count, - if count == 1 { "" } else { "s" } + " \x1b[2m●\x1b[0m {}{} \x1b[2m|\x1b[0m \x1b[35m{}{}\x1b[0m \x1b[2m| {}\x1b[0m \x1b[2m| {} ◉ {}\x1b[0m", + tool_color, + tool_name, + display_path, + range_suffix, + summary, + tokens_delta, + duration_str ); + + // Clear the stored tool info + drop(args); // Release the lock before clearing + *self.current_tool_name.lock().unwrap() = None; + self.current_tool_args.lock().unwrap().clear(); + *self.current_output_line.lock().unwrap() = None; + *self.output_line_printed.lock().unwrap() = false; + + true } fn print_tool_timing(&self, duration_str: &str, tokens_delta: u32, context_percentage: f32) { @@ -278,13 +391,24 @@ impl UiWriter for ConsoleUiWriter { println!(); } } - println!("└─ ⚡️ {}{}\x1b[0m \x1b[2m{} ◉ | {:.0}%\x1b[0m", color_code, duration_str, tokens_delta, context_percentage); - println!(); + + // Check if we're in shell compact mode - append timing to the output line + let is_shell = *self.is_shell_compact.lock().unwrap(); + if is_shell { + // Append timing to the same line as shell output + println!(" \x1b[2m| {} ◉ {}{}\x1b[0m", tokens_delta, color_code, duration_str); + println!(); + } else { + println!("└─ ⚡️ {}{}\x1b[0m \x1b[2m{} ◉ | {:.0}%\x1b[0m", color_code, duration_str, tokens_delta, context_percentage); + println!(); + } + // Clear the stored tool info *self.current_tool_name.lock().unwrap() = None; self.current_tool_args.lock().unwrap().clear(); *self.current_output_line.lock().unwrap() = None; *self.output_line_printed.lock().unwrap() = false; + *self.is_shell_compact.lock().unwrap() = false; } fn print_agent_prompt(&self) { diff --git a/crates/g3-core/src/lib.rs b/crates/g3-core/src/lib.rs index 5844073..ead45ba 100644 --- a/crates/g3-core/src/lib.rs +++ b/crates/g3-core/src/lib.rs @@ -2142,7 +2142,14 @@ impl Agent { self.ui_writer.print_tool_arg(key, &value_str); } } - self.ui_writer.print_tool_output_header(); + + // Check if this is a compact tool (file operations) + let is_compact_tool = matches!(tool_call.tool.as_str(), "read_file" | "write_file" | "str_replace"); + + // Only print output header for non-compact tools + if !is_compact_tool { + self.ui_writer.print_tool_output_header(); + } // Clone working_dir to avoid borrow checker issues let working_dir = self.working_dir.clone(); @@ -2172,7 +2179,7 @@ impl Agent { )); // Display tool execution result with proper indentation - { + let compact_summary = { let output_lines: Vec<&str> = tool_result.lines().collect(); // Check if UI wants full output (machine mode) or truncated (human mode) @@ -2186,14 +2193,26 @@ impl Agent { let is_todo_tool = tool_call.tool == "todo_read" || tool_call.tool == "todo_write"; - // For read_file, show a summary instead of file contents - let is_read_file = tool_call.tool == "read_file"; - - if is_read_file && tool_success { - let summary = streaming::format_read_file_summary(output_len, tool_result.len()); - self.ui_writer.update_tool_output_line(&summary); + if is_compact_tool && tool_success { + // Generate appropriate summary based on tool type + match tool_call.tool.as_str() { + "read_file" => Some(streaming::format_read_file_summary(output_len, tool_result.len())), + "write_file" => { + // Parse the result to get line/char counts + // Result format: "✅ +N insertions | -M deletions" or similar + Some(streaming::format_write_file_summary(output_len, tool_result.len())) + } + "str_replace" => { + // Parse insertions/deletions from result + // Result format: "✅ +N insertions | -M deletions" + let (ins, del) = parse_diff_stats(&tool_result); + Some(streaming::format_str_replace_summary(ins, del)) + } + _ => Some(streaming::format_read_file_summary(output_len, tool_result.len())) + } } else if is_todo_tool { // Skip - todo tools print their own content + None } else { let max_lines_to_show = if wants_full { output_len } else { MAX_LINES }; @@ -2208,8 +2227,9 @@ impl Agent { if !wants_full && output_len > MAX_LINES { self.ui_writer.print_tool_output_summary(output_len); } + None } - } + }; // Add the tool call and result to the context window using RAW unfiltered content // This ensures the log file contains the true raw content including JSON tool calls @@ -2272,10 +2292,22 @@ impl Agent { // Closure marker with timing let tokens_delta = self.context_window.used_tokens.saturating_sub(tokens_before); - self.ui_writer - .print_tool_timing(&streaming::format_duration(exec_duration), + + // Use compact format for file operations, normal format for others + if let Some(summary) = compact_summary { + self.ui_writer.print_tool_compact( + &tool_call.tool, + &summary, + &streaming::format_duration(exec_duration), tokens_delta, - self.context_window.percentage_used()); + self.context_window.percentage_used(), + ); + } else { + self.ui_writer + .print_tool_timing(&streaming::format_duration(exec_duration), + tokens_delta, + self.context_window.percentage_used()); + } self.ui_writer.print_agent_prompt(); // Update the request with the new context for next iteration @@ -2751,6 +2783,9 @@ impl Agent { full_response }; + // Finish streaming markdown before returning + self.ui_writer.finish_streaming_markdown(); + // Dehydrate context - the function extracts the summary from context itself self.dehydrate_context(); @@ -2863,6 +2898,25 @@ impl Agent { pub use utils::apply_unified_diff_to_string; use utils::truncate_to_word_boundary; +/// Parse insertions and deletions from a str_replace result. +/// Result format: "✅ +N insertions | -M deletions" +fn parse_diff_stats(result: &str) -> (i32, i32) { + let mut insertions = 0i32; + let mut deletions = 0i32; + + // Look for "+N insertions" pattern + if let Some(pos) = result.find("+") { + let after_plus = &result[pos + 1..]; + insertions = after_plus.split_whitespace().next().and_then(|s| s.parse().ok()).unwrap_or(0); + } + // Look for "-M deletions" pattern + if let Some(pos) = result.find("-") { + let after_minus = &result[pos + 1..]; + deletions = after_minus.split_whitespace().next().and_then(|s| s.parse().ok()).unwrap_or(0); + } + (insertions, deletions) +} + // Implement Drop to clean up safaridriver process impl Drop for Agent { fn drop(&mut self) { diff --git a/crates/g3-core/src/streaming.rs b/crates/g3-core/src/streaming.rs index b4abc7d..d12c083 100644 --- a/crates/g3-core/src/streaming.rs +++ b/crates/g3-core/src/streaming.rs @@ -289,7 +289,28 @@ pub fn format_read_file_summary(line_count: usize, char_count: usize) -> String } else { format!("{}", char_count) }; - format!("🔍 {} lines read ({} chars)", line_count, char_display) + format!("{} lines ({} chars)", line_count, char_display) +} + +/// Format a write_file result summary. +pub fn format_write_file_summary(line_count: usize, char_count: usize) -> String { + let char_display = if char_count >= 1000 { + format!("{:.1}k", char_count as f64 / 1000.0) + } else { + format!("{}", char_count) + }; + format!("✏️ {} lines ({} chars)", line_count, char_display) +} + +/// Format a str_replace result summary. +pub fn format_str_replace_summary(insertions: i32, deletions: i32) -> String { + if insertions > 0 && deletions > 0 { + format!("\x1b[32m+{}\x1b[0m \x1b[2m|\x1b[0m \x1b[31m-{}\x1b[0m", insertions, deletions) + } else if insertions > 0 { + format!("\x1b[32m+{}\x1b[0m", insertions) + } else { + format!("\x1b[31m-{}\x1b[0m", deletions) + } } /// Determine if a response is essentially empty (whitespace or timing only) @@ -367,8 +388,8 @@ mod tests { #[test] fn test_format_read_file_summary() { - assert_eq!(format_read_file_summary(42, 500), "🔍 42 lines read (500 chars)"); - assert_eq!(format_read_file_summary(100, 1500), "🔍 100 lines read (1.5k chars)"); + assert_eq!(format_read_file_summary(42, 500), "42 lines (500 chars)"); + assert_eq!(format_read_file_summary(100, 1500), "100 lines (1.5k chars)"); } #[test] diff --git a/crates/g3-core/src/ui_writer.rs b/crates/g3-core/src/ui_writer.rs index 1cb7bab..015659c 100644 --- a/crates/g3-core/src/ui_writer.rs +++ b/crates/g3-core/src/ui_writer.rs @@ -38,6 +38,14 @@ pub trait UiWriter: Send + Sync { /// Print tool output summary (when output is truncated) fn print_tool_output_summary(&self, hidden_count: usize); + /// Print a compact single-line tool output (for file operations) + /// Format: " ● tool_name | path [range] | summary | tokens ◉ time" + /// Returns true if the tool was handled in compact format, false to use normal format + fn print_tool_compact(&self, _tool_name: &str, _summary: &str, _duration_str: &str, _tokens_delta: u32, _context_percentage: f32) -> bool { + // Default: don't use compact format + false + } + /// Print tool execution timing fn print_tool_timing(&self, duration_str: &str, tokens_delta: u32, context_percentage: f32);