diff --git a/crates/g3-cli/src/machine_ui_writer.rs b/crates/g3-cli/src/machine_ui_writer.rs index f3cc288..37b367c 100644 --- a/crates/g3-cli/src/machine_ui_writer.rs +++ b/crates/g3-cli/src/machine_ui_writer.rs @@ -105,4 +105,9 @@ impl UiWriter for MachineUiWriter { // Default to first option (index 0) for automation 0 } + + fn print_final_output(&self, summary: &str) { + println!("FINAL_OUTPUT:"); + println!("{}", summary); + } } diff --git a/crates/g3-cli/src/ui_writer_impl.rs b/crates/g3-cli/src/ui_writer_impl.rs index 1a6b83e..788c69f 100644 --- a/crates/g3-cli/src/ui_writer_impl.rs +++ b/crates/g3-cli/src/ui_writer_impl.rs @@ -1,5 +1,6 @@ use g3_core::ui_writer::UiWriter; use std::io::{self, Write}; +use termimad::MadSkin; /// Console implementation of UiWriter that prints to stdout pub struct ConsoleUiWriter { @@ -104,6 +105,9 @@ impl UiWriter for ConsoleUiWriter { fn print_tool_output_header(&self) { println!(); + // 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; // Now print the tool header with the most important arg in bold green if let Some(tool_name) = self.current_tool_name.lock().unwrap().as_ref() { let args = self.current_tool_args.lock().unwrap(); @@ -306,4 +310,44 @@ impl UiWriter for ConsoleUiWriter { let _ = io::stdout().flush(); } } + + fn print_final_output(&self, summary: &str) { + // Show spinner while "formatting" + let spinner_frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + let message = "summarizing work done..."; + + // Brief spinner animation (about 0.5 seconds) + for i in 0..5 { + let frame = spinner_frames[i % spinner_frames.len()]; + print!("\r\x1b[36m{} {}\x1b[0m", frame, message); + let _ = io::stdout().flush(); + std::thread::sleep(std::time::Duration::from_millis(100)); + } + + // Clear the spinner line + print!("\r\x1b[2K"); + let _ = io::stdout().flush(); + + // Create a styled markdown skin + let mut skin = MadSkin::default(); + // Customize colors for better terminal appearance + skin.bold.set_fg(termimad::crossterm::style::Color::Green); + skin.italic.set_fg(termimad::crossterm::style::Color::Cyan); + skin.headers[0].set_fg(termimad::crossterm::style::Color::Magenta); + skin.headers[1].set_fg(termimad::crossterm::style::Color::Magenta); + skin.code_block.set_fg(termimad::crossterm::style::Color::Yellow); + skin.inline_code.set_fg(termimad::crossterm::style::Color::Yellow); + + // Print a header separator + println!("\x1b[1;35m━━━ Summary ━━━\x1b[0m"); + println!(); + + // Render the markdown + let rendered = skin.term_text(summary); + print!("{}", rendered); + + // Print a footer separator + println!(); + println!("\x1b[1;35m━━━━━━━━━━━━━━━\x1b[0m"); + } } diff --git a/crates/g3-core/src/lib.rs b/crates/g3-core/src/lib.rs index cadf921..c300a8b 100644 --- a/crates/g3-core/src/lib.rs +++ b/crates/g3-core/src/lib.rs @@ -3529,11 +3529,9 @@ impl Agent { // Display tool execution result with proper indentation if tool_call.tool == "final_output" { - // For final_output, display the summary without truncation - for line in tool_result.lines() { - self.ui_writer.update_tool_output_line(line); - } - self.ui_writer.println(""); + // For final_output, use the dedicated method that renders markdown + // with a spinner animation + self.ui_writer.print_final_output(&tool_result); } else { let output_lines: Vec<&str> = tool_result.lines().collect(); @@ -3563,44 +3561,32 @@ impl Agent { const MAX_LINE_WIDTH: usize = 80; let output_len = output_lines.len(); - // For todo tools, show all lines without truncation + // Skip printing for todo tools - they already print their content let is_todo_tool = tool_call.tool == "todo_read" || tool_call.tool == "todo_write"; - let max_lines_to_show = if is_todo_tool || wants_full { - output_len - } else { - MAX_LINES - }; - for (idx, line) in output_lines.iter().enumerate() { - if !is_todo_tool && !wants_full && idx >= max_lines_to_show { - break; - } - // Clip line to max width (but not for todo tools) - let clipped_line = truncate_line( - line, - MAX_LINE_WIDTH, - !wants_full && !is_todo_tool, - ); + if !is_todo_tool { + let max_lines_to_show = if wants_full { output_len } else { MAX_LINES }; - // Use print_tool_output_line for todo tools to get special formatting - if is_todo_tool { - self.ui_writer.print_tool_output_line(&clipped_line); - } else { + for (idx, line) in output_lines.iter().enumerate() { + if !wants_full && idx >= max_lines_to_show { + break; + } + let clipped_line = truncate_line(line, MAX_LINE_WIDTH, !wants_full); self.ui_writer.update_tool_output_line(&clipped_line); } - } - if !is_todo_tool && !wants_full && output_len > MAX_LINES { - self.ui_writer.print_tool_output_summary(output_len); + if !wants_full && output_len > MAX_LINES { + self.ui_writer.print_tool_output_summary(output_len); + } } } // Check if this was a final_output tool call if tool_call.tool == "final_output" { - // The summary was displayed above when we printed the tool result - // Add it to full_response so it's included in the TaskResult - full_response.push_str(&tool_result); + // The summary was already displayed via print_final_output + // Don't add it to full_response to avoid duplicate printing + // full_response is intentionally left empty/unchanged self.ui_writer.println(""); let _ttft = first_token_time.unwrap_or_else(|| stream_start.elapsed()); @@ -3608,13 +3594,13 @@ impl Agent { // Add timing if needed let final_response = if show_timing { format!( - "{}\n\n🕝 {} | 💭 {}", - full_response, + "🕝 {} | 💭 {}", Self::format_duration(stream_start.elapsed()), Self::format_duration(_ttft) ) } else { - full_response + // Return empty string since content was already displayed + String::new() }; return Ok(TaskResult::new( diff --git a/crates/g3-core/src/ui_writer.rs b/crates/g3-core/src/ui_writer.rs index 3d66d37..c5f1ca8 100644 --- a/crates/g3-core/src/ui_writer.rs +++ b/crates/g3-core/src/ui_writer.rs @@ -65,6 +65,10 @@ pub trait UiWriter: Send + Sync { /// Prompt the user to choose from a list of options /// Returns the index of the selected option fn prompt_user_choice(&self, message: &str, options: &[&str]) -> usize; + + /// Print the final output summary with markdown formatting + /// Shows a spinner while formatting, then renders the markdown + fn print_final_output(&self, summary: &str); } /// A no-op implementation for when UI output is not needed @@ -97,4 +101,7 @@ impl UiWriter for NullUiWriter { fn prompt_user_choice(&self, _message: &str, _options: &[&str]) -> usize { 0 } + fn print_final_output(&self, _summary: &str) { + // No-op for null writer + } } diff --git a/crates/g3-core/tests/todo_staleness_test.rs b/crates/g3-core/tests/todo_staleness_test.rs index 1ce9ddf..1491dd9 100644 --- a/crates/g3-core/tests/todo_staleness_test.rs +++ b/crates/g3-core/tests/todo_staleness_test.rs @@ -81,6 +81,9 @@ impl UiWriter for MockUiWriter { .push(format!("CHOICE: {} Options: {:?}", message, options)); self.choice_responses.lock().unwrap().pop().unwrap_or(0) } + fn print_final_output(&self, summary: &str) { + self.output.lock().unwrap().push(format!("FINAL: {}", summary)); + } } #[tokio::test]