From 1379af71598f1dc46986452908c364ca446ddbbc Mon Sep 17 00:00:00 2001 From: Dhanji Prasanna Date: Sat, 4 Oct 2025 16:24:33 +1000 Subject: [PATCH] tool headers working --- crates/g3-cli/src/retro_tui.rs | 293 +++++++++++++++++++++------- crates/g3-cli/src/ui_writer_impl.rs | 56 +++++- 2 files changed, 274 insertions(+), 75 deletions(-) diff --git a/crates/g3-cli/src/retro_tui.rs b/crates/g3-cli/src/retro_tui.rs index 3481936..36bf6c5 100644 --- a/crates/g3-cli/src/retro_tui.rs +++ b/crates/g3-cli/src/retro_tui.rs @@ -40,6 +40,16 @@ pub enum TuiMessage { caption: String, content: String, }, + ToolDetailUpdate { + name: String, + content: String, + }, + ToolComplete { + name: String, + success: bool, + duration_ms: u128, + caption: String, + }, SystemStatus(String), ContextUpdate { used: u32, @@ -60,6 +70,10 @@ struct TerminalState { scroll_offset: usize, /// Cursor blink state cursor_blink: bool, + /// Tool activity history (left side of activity box) + tool_activity: Vec, + /// Tool activity scroll offset + tool_activity_scroll: usize, /// Last known visible height of output area last_visible_height: usize, /// User has manually scrolled (disable auto-scroll) @@ -80,6 +94,8 @@ struct TerminalState { is_processing: bool, /// Should exit should_exit: bool, + /// Track the last tool header line index for updating it + last_tool_header_index: Option, } impl TerminalState { @@ -96,6 +112,8 @@ impl TerminalState { ], scroll_offset: 0, cursor_blink: true, + tool_activity: Vec::new(), + tool_activity_scroll: 0, last_visible_height: 0, // Will be set on first draw manual_scroll: false, last_blink: Instant::now(), @@ -106,82 +124,31 @@ impl TerminalState { last_status_blink: Instant::now(), is_processing: false, should_exit: false, + last_tool_header_index: None, } } - /// Format tool call output with a box + /// Format tool call output fn format_tool_output(&mut self, tool_name: &str, caption: &str, content: &str) { - // Calculate box width (use a reasonable width, accounting for terminal size) - let box_width = 80; - let border_char = "─"; - let corner_tl = "┌"; - let corner_tr = "┐"; - let corner_bl = "└"; - let corner_br = "┘"; - let vertical = "│"; - - // Add top border - self.output_history.push(format!( - "{}{}{}", - corner_tl, - border_char.repeat(box_width - 2), - corner_tr - )); - - // Add header with tool name (will be styled with green background in draw) + // Add tool header bar to main output let header_text = format!(" {} | {}", tool_name.to_uppercase(), caption); - let padding = box_width - 2 - header_text.len(); - self.output_history.push(format!( - "{}[TOOL_HEADER]{}{}{}", - vertical, - header_text, - " ".repeat(padding), - vertical - )); - - // Add separator between header and content - self.output_history.push(format!( - "{}{}{}", - "├", - border_char.repeat(box_width - 2), - "┤" - )); - - // Add content lines + + // Add marker for special styling + self.output_history.push(format!("[TOOL_HEADER]{}", header_text)); + + // Track the index of this tool header for later updates + self.last_tool_header_index = Some(self.output_history.len() - 1); + + self.output_history.push(String::new()); // Empty line after header + + // Add the actual tool content to the tool detail panel + self.tool_activity.clear(); // Clear previous activity + self.tool_activity.push(format!("[{}] {}", tool_name.to_uppercase(), caption)); + self.tool_activity.push(String::new()); for line in content.lines() { - // Wrap long lines if needed - let max_content_width = box_width - 4; // Account for borders and padding - if line.len() <= max_content_width { - self.output_history.push(format!( - "{} {:>().chunks(max_content_width) { - let chunk_str: String = chunk.iter().collect(); - self.output_history.push(format!( - "{} {: { state.format_tool_output(&name, &caption, &content); } + TuiMessage::ToolDetailUpdate { + name, + content, + } => { + state.update_tool_detail(&name, &content); + } + TuiMessage::ToolComplete { + name, + success, + duration_ms, + caption, + } => { + state.update_tool_completion(&name, success, duration_ms, &caption); + } TuiMessage::SystemStatus(status) => { let was_processing = state.status_line == "PROCESSING"; state.status_line = status; @@ -418,7 +439,8 @@ impl RetroTui { .direction(Direction::Vertical) .constraints([ Constraint::Length(5), // Header/input area - Constraint::Min(10), // Main output area + Constraint::Min(10), // Main output area (will be further split) + Constraint::Length(8), // Activity area Constraint::Length(1), // Status bar ]) .split(size); @@ -448,11 +470,14 @@ impl RetroTui { // Draw main output area Self::draw_output_area(f, chunks[1], &state.output_history, state.scroll_offset); + + // Draw activity area (tool output) + Self::draw_activity_area(f, chunks[2], &state.tool_activity, state.tool_activity_scroll); // Draw status bar Self::draw_status_bar( f, - chunks[2], + chunks[3], &state.status_line, state.context_info, &state.provider_info, @@ -521,10 +546,21 @@ impl RetroTui { .take(visible_height) .map(|line| { // Check if this is a tool header line - if line.contains("[TOOL_HEADER]") { + if line.starts_with("[TOOL_HEADER]") { // Extract the actual header text let cleaned = line.replace("[TOOL_HEADER]", ""); - // Style with green background and black text + // Style with amber background and black text + return Line::from(Span::styled( + format!(" {}", cleaned), + Style::default() + .bg(TERMINAL_AMBER) + .fg(Color::Black) + .add_modifier(Modifier::BOLD), + )); + } else if line.starts_with("[SUCCESS]") { + // Extract the actual header text + let cleaned = line.replace("[SUCCESS]", ""); + // Style with green background for successful tool completion return Line::from(Span::styled( format!(" {}", cleaned), Style::default() @@ -532,6 +568,17 @@ impl RetroTui { .fg(Color::Black) .add_modifier(Modifier::BOLD), )); + } else if line.starts_with("[FAILED]") { + // Extract the actual header text + let cleaned = line.replace("[FAILED]", ""); + // Style with red background for failed tool completion + return Line::from(Span::styled( + format!(" {}", cleaned), + Style::default() + .bg(TERMINAL_RED) + .fg(Color::Black) + .add_modifier(Modifier::BOLD), + )); } // Check if this is a box border line @@ -610,6 +657,88 @@ impl RetroTui { } } + /// Draw the activity area with tool output + fn draw_activity_area( + f: &mut Frame, + area: Rect, + tool_activity: &[String], + scroll_offset: usize, + ) { + // Split the activity area into left and right halves + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(50), // Left half for tool output + Constraint::Percentage(50), // Right half (reserved for future use) + ]) + .split(area); + + // Draw left half - Tool Activity + let visible_height = chunks[0].height.saturating_sub(2) as usize; // Account for borders + let total_lines = tool_activity.len(); + + // Calculate scroll position + let scroll = if total_lines <= visible_height { + 0 + } else { + scroll_offset.min(total_lines.saturating_sub(visible_height)) + }; + + // Get visible lines for tool activity + let visible_lines: Vec = if tool_activity.is_empty() { + vec![Line::from(Span::styled( + " No tool activity yet", + Style::default().fg(TERMINAL_DIM_GREEN).add_modifier(Modifier::ITALIC), + ))] + } else { + tool_activity + .iter() + .skip(scroll) + .take(visible_height) + .map(|line| { + // Style the header lines differently + let style = if line.starts_with('[') && line.contains(']') { + Style::default().fg(TERMINAL_CYAN).add_modifier(Modifier::BOLD) + } else if line.is_empty() { + Style::default() + } else { + Style::default().fg(TERMINAL_GREEN) + }; + Line::from(Span::styled(format!(" {}", line), style)) + }) + .collect() + }; + + let tool_output = Paragraph::new(visible_lines) + .block( + Block::default() + .title(" TOOL DETAIL ") + .title_alignment(Alignment::Center) + .borders(Borders::ALL) + .border_style(Style::default().fg(TERMINAL_DIM_GREEN)) + .style(Style::default().bg(TERMINAL_BG)), + ) + .wrap(Wrap { trim: false }); + + f.render_widget(tool_output, chunks[0]); + + // Draw right half - Activity + let reserved = Paragraph::new(vec![Line::from(Span::styled( + " Activity log will appear here", + Style::default().fg(TERMINAL_DIM_GREEN).add_modifier(Modifier::ITALIC), + ))]) + .block( + Block::default() + .title(" ACTIVITY ") + .title_alignment(Alignment::Center) + .borders(Borders::ALL) + .border_style(Style::default().fg(TERMINAL_DIM_GREEN)) + .style(Style::default().bg(TERMINAL_BG)), + ); + + f.render_widget(reserved, chunks[1]); + } + /// Draw the status bar fn draw_status_bar( f: &mut Frame, @@ -706,6 +835,24 @@ impl RetroTui { }); } + /// Update tool detail panel without changing the header + pub fn update_tool_detail(&self, name: &str, content: &str) { + let _ = self.tx.send(TuiMessage::ToolDetailUpdate { + name: name.to_string(), + content: content.to_string(), + }); + } + + /// Send tool completion status to the terminal + pub fn tool_complete(&self, name: &str, success: bool, duration_ms: u128, caption: &str) { + let _ = self.tx.send(TuiMessage::ToolComplete { + name: name.to_string(), + success, + duration_ms, + caption: caption.to_string(), + }); + } + /// Update system status pub fn status(&self, status: &str) { let _ = self.tx.send(TuiMessage::SystemStatus(status.to_string())); diff --git a/crates/g3-cli/src/ui_writer_impl.rs b/crates/g3-cli/src/ui_writer_impl.rs index bd3720d..c49a4fb 100644 --- a/crates/g3-cli/src/ui_writer_impl.rs +++ b/crates/g3-cli/src/ui_writer_impl.rs @@ -1,6 +1,7 @@ use crate::retro_tui::RetroTui; use g3_core::ui_writer::UiWriter; use std::io::{self, Write}; +use std::time::Instant; use std::sync::Mutex; /// Console implementation of UiWriter that prints to stdout @@ -87,6 +88,8 @@ pub struct RetroTuiWriter { tui: RetroTui, current_tool_name: Mutex>, current_tool_output: Mutex>, + current_tool_start: Mutex>, + current_tool_caption: Mutex, } impl RetroTuiWriter { @@ -95,6 +98,8 @@ impl RetroTuiWriter { tui, current_tool_name: Mutex::new(None), current_tool_output: Mutex::new(Vec::new()), + current_tool_start: Mutex::new(None), + current_tool_caption: Mutex::new(String::new()), } } } @@ -129,12 +134,16 @@ impl UiWriter for RetroTuiWriter { fn print_tool_header(&self, tool_name: &str) { // Start collecting tool output + *self.current_tool_start.lock().unwrap() = Some(Instant::now()); *self.current_tool_name.lock().unwrap() = Some(tool_name.to_string()); self.current_tool_output.lock().unwrap().clear(); self.current_tool_output .lock() .unwrap() .push(format!("Tool: {}", tool_name)); + + // Initialize caption + *self.current_tool_caption.lock().unwrap() = String::new(); } fn print_tool_arg(&self, key: &str, value: &str) { @@ -142,9 +151,31 @@ impl UiWriter for RetroTuiWriter { .lock() .unwrap() .push(format!("{}: {}", key, value)); + + // Build caption from first argument (usually the most important one) + let mut caption = self.current_tool_caption.lock().unwrap(); + if caption.is_empty() && (key == "file_path" || key == "command" || key == "path") { + // Truncate long values for the caption + let truncated = if value.len() > 50 { format!("{}...", &value[..47]) } else { value.to_string() }; + *caption = truncated; + } } fn print_tool_output_header(&self) { + // This is called right before tool execution starts + // Send the initial tool header to the TUI now + if let Some(tool_name) = self.current_tool_name.lock().unwrap().as_ref() { + let caption = self.current_tool_caption.lock().unwrap().clone(); + let caption = if caption.is_empty() { "Executing...".to_string() } else { caption }; + + // Send the tool output with initial header + self.tui.tool_output( + tool_name, + &caption, + "" + ); + } + self.current_tool_output.lock().unwrap().push(String::new()); self.current_tool_output .lock() @@ -173,15 +204,36 @@ impl UiWriter for RetroTuiWriter { .unwrap() .push(format!("⚡️ {}", duration_str)); - // Now send the complete tool output as a box + // Calculate the actual duration + let duration_ms = if let Some(start) = *self.current_tool_start.lock().unwrap() { + start.elapsed().as_millis() + } else { + 0 + }; + + // Get the tool name and caption if let Some(tool_name) = self.current_tool_name.lock().unwrap().as_ref() { let content = self.current_tool_output.lock().unwrap().join("\n"); - self.tui.tool_output(tool_name, "...", &content); + let caption = self.current_tool_caption.lock().unwrap().clone(); + let caption = if caption.is_empty() { "Completed".to_string() } else { caption }; + + // Update the tool detail panel with the complete output without adding a new header + // This keeps the original header in place to be updated by tool_complete + self.tui.update_tool_detail(tool_name, &content); + + // Determine success based on whether there's an error in the output + // This is a simple heuristic - you might want to make this more sophisticated + let success = !content.contains("error") && !content.contains("Error") && !content.contains("ERROR"); + + // Send the completion status to update the header + self.tui.tool_complete(tool_name, success, duration_ms, &caption); } // Clear the buffers *self.current_tool_name.lock().unwrap() = None; self.current_tool_output.lock().unwrap().clear(); + *self.current_tool_start.lock().unwrap() = None; + *self.current_tool_caption.lock().unwrap() = String::new(); } fn print_agent_prompt(&self) {