tui: running state for custom + MCP tools with live timers; replace-in-place on completion

- Add RunningToolCallCell with live elapsed time
- Browser_*: present-participle titles (Clicking…, Typing…, etc.) + formatted args
- Custom tools: generic running titles; MCP: Working… with provider.tool(args)
- Track running entries by call_id and replace with completed cell on end
- Keep bottom status updates consistent
This commit is contained in:
James Peter
2025-08-20 10:49:56 +10:00
parent f7ff602cb5
commit f24446beab
2 changed files with 170 additions and 35 deletions
+54 -34
View File
@@ -139,6 +139,8 @@ pub(crate) struct ChatWidget<'a> {
// Track the ID of the current streaming message to prevent duplicates
// Track the ID of the current streaming reasoning to prevent duplicates
running_commands: HashMap<String, RunningCommand>,
// Track active custom tool cells by call_id so we can replace them on completion
running_custom_tools: HashMap<String, usize>,
live_builder: RowBuilder,
// Store pending image paths keyed by their placeholder text
pending_images: HashMap<String, PathBuf>,
@@ -506,36 +508,36 @@ impl ChatWidget<'_> {
for cell in &self.history_cells {
cell.trigger_fade();
}
let McpToolCallBeginEvent {
call_id: _,
invocation,
} = ev;
// Add active MCP tool call to history
self.add_to_history(history_cell::new_active_mcp_tool_call(invocation));
let McpToolCallBeginEvent { call_id, invocation } = ev;
// Add animated running MCP tool call to history and track index
let cell = history_cell::new_running_mcp_tool_call(invocation);
self.add_to_history(cell);
if let Some(last_idx) = self.history_cells.len().checked_sub(1) {
self.running_custom_tools.insert(call_id, last_idx);
}
}
/// Handle MCP tool call end immediately
fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) {
let McpToolCallEndEvent {
call_id: _,
duration,
invocation,
result,
} = ev;
let McpToolCallEndEvent { call_id, duration, invocation, result } = ev;
// Determine success from result
let success = !result.as_ref().map(|r| r.is_error.unwrap_or(false)).unwrap_or(false);
// Add completed MCP tool call to history
self.add_to_history(history_cell::new_completed_mcp_tool_call(
80, // TODO: Use actual terminal width
let completed = history_cell::new_completed_mcp_tool_call(
80, // TODO: use actual terminal width
invocation,
duration,
success,
result,
));
// MCP tool calls are added directly to history, no active cell to move
);
if let Some(idx) = self.running_custom_tools.remove(&call_id) {
if idx < self.history_cells.len() {
self.history_cells[idx] = completed;
self.invalidate_height_cache();
self.request_redraw();
return;
}
}
self.add_to_history(completed);
}
/// Handle patch apply end immediately
@@ -828,6 +830,7 @@ impl ChatWidget<'_> {
content_buffer: String::new(),
last_assistant_message: None,
running_commands: HashMap::new(),
running_custom_tools: HashMap::new(),
// Use max width to disable wrapping during streaming
// Text will be properly wrapped when displayed based on terminal width
live_builder: RowBuilder::new(usize::MAX),
@@ -1943,7 +1946,7 @@ impl ChatWidget<'_> {
);
}
EventMsg::CustomToolCallBegin(CustomToolCallBeginEvent {
call_id: _,
call_id,
tool_name,
parameters,
}) => {
@@ -1952,12 +1955,18 @@ impl ChatWidget<'_> {
self.finalize_active_stream();
// Flush any queued interrupts when streaming ends
self.flush_interrupt_queue();
// For browser_* and agent_* tools, suppress the separate begin entry to show a single combined section
if !(tool_name.starts_with("browser_") || tool_name.starts_with("agent_")) {
let params_string = parameters.map(|p| p.to_string());
self.add_to_history(history_cell::new_active_custom_tool_call(
tool_name.clone(), params_string,
));
// Show an active entry immediately for all custom tools so the user sees progress
let params_string = parameters.map(|p| p.to_string());
// Animated running cell with live timer and formatted args
let cell = if tool_name.starts_with("browser_") {
history_cell::new_running_browser_tool_call(tool_name.clone(), params_string.clone())
} else {
history_cell::new_running_custom_tool_call(tool_name.clone(), params_string.clone())
};
self.add_to_history(cell);
// Track index so we can replace it on completion
if let Some(last_idx) = self.history_cells.len().checked_sub(1) {
self.running_custom_tools.insert(call_id.clone(), last_idx);
}
// Update border status based on tool
@@ -1971,7 +1980,7 @@ impl ChatWidget<'_> {
}
}
EventMsg::CustomToolCallEnd(CustomToolCallEndEvent {
call_id: _,
call_id,
tool_name,
parameters,
duration,
@@ -1984,13 +1993,24 @@ impl ChatWidget<'_> {
Ok(content) => (true, content),
Err(error) => (false, error),
};
self.add_to_history(history_cell::new_completed_custom_tool_call(
tool_name,
params_string,
duration,
success,
let completed = history_cell::new_completed_custom_tool_call(
tool_name,
params_string,
duration,
success,
content,
));
);
if let Some(idx) = self.running_custom_tools.remove(&call_id) {
if idx < self.history_cells.len() {
self.history_cells[idx] = Box::new(completed);
self.invalidate_height_cache();
self.request_redraw();
} else {
self.add_to_history(completed);
}
} else {
self.add_to_history(completed);
}
// After tool completes, likely transitioning to response
self.bottom_pane.update_status_text("responding".to_string());
+116 -1
View File
@@ -573,6 +573,32 @@ impl ToolCallCell {
}
}
// ==================== RunningToolCallCell (animated) ====================
pub(crate) struct RunningToolCallCell {
title: String,
start_time: Instant,
arg_lines: Vec<Line<'static>>,
}
impl HistoryCell for RunningToolCallCell {
fn as_any(&self) -> &dyn std::any::Any { self }
fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
fn kind(&self) -> HistoryCellType { HistoryCellType::Tool { status: ToolStatus::Running } }
fn is_animating(&self) -> bool { true }
fn display_lines(&self) -> Vec<Line<'static>> {
let elapsed = self.start_time.elapsed();
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::styled(
format!("{} ({})", self.title, format_duration(elapsed)),
Style::default().fg(crate::colors::primary()).add_modifier(Modifier::BOLD),
));
lines.extend(self.arg_lines.clone());
lines.push(Line::from(""));
lines
}
}
// ==================== CollapsibleReasoningCell ====================
// For reasoning content that can be collapsed to show only summary
@@ -1500,7 +1526,7 @@ pub(crate) fn new_active_custom_tool_call(tool_name: String, args: Option<String
} else {
format!("{}()", tool_name)
};
let lines: Vec<Line> = vec![
title_line,
Line::styled(
@@ -1514,6 +1540,95 @@ pub(crate) fn new_active_custom_tool_call(tool_name: String, args: Option<String
ToolCallCell { lines, state: ToolState::Running }
}
// Friendly present-participle titles for running browser tools
fn browser_running_title(tool_name: &str) -> &'static str {
match tool_name {
"browser_click" => "Clicking...",
"browser_type" => "Typing...",
"browser_key" => "Sending key...",
"browser_javascript" => "Running JavaScript...",
"browser_scroll" => "Scrolling...",
"browser_open" => "Opening...",
"browser_close" => "Closing...",
"browser_status" => "Checking status...",
"browser_history" => "Navigating...",
"browser_inspect" => "Inspecting...",
"browser_console" => "Reading console...",
"browser_move" => "Moving...",
_ => "Working...",
}
}
pub(crate) fn new_running_browser_tool_call(tool_name: String, args: Option<String>) -> RunningToolCallCell {
// Parse args JSON and format like completed cells
let mut arg_lines: Vec<Line<'static>> = Vec::new();
if let Some(args_str) = args {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&args_str) {
arg_lines.extend(format_browser_args_line(&json));
}
}
RunningToolCallCell {
title: browser_running_title(&tool_name).to_string(),
start_time: Instant::now(),
arg_lines,
}
}
fn custom_tool_running_title(tool_name: &str) -> String {
if tool_name.starts_with("agent_") {
// Reuse agent title and append ellipsis
format!("{}...", agent_tool_title(tool_name))
} else if tool_name.starts_with("browser_") {
browser_running_title(tool_name).to_string()
} else {
// TitleCase from snake_case and append ellipsis
let pretty = tool_name
.split('_')
.filter(|s| !s.is_empty())
.map(|s| {
let mut chars = s.chars();
match chars.next() {
Some(f) => format!("{}{}", f.to_uppercase(), chars.as_str()),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ");
format!("{}...", pretty)
}
}
pub(crate) fn new_running_custom_tool_call(tool_name: String, args: Option<String>) -> RunningToolCallCell {
// Parse args JSON and format as key/value lines
let mut arg_lines: Vec<Line<'static>> = Vec::new();
if let Some(args_str) = args {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&args_str) {
arg_lines.extend(format_browser_args_line(&json));
} else {
// Fallback to showing raw args string
arg_lines.push(Line::from(vec![
Span::styled("└ args: ", Style::default().fg(crate::colors::text_dim())),
Span::styled(args_str, Style::default().fg(crate::colors::text())),
]));
}
}
RunningToolCallCell {
title: custom_tool_running_title(&tool_name),
start_time: Instant::now(),
arg_lines,
}
}
pub(crate) fn new_running_mcp_tool_call(invocation: McpInvocation) -> RunningToolCallCell {
// Represent as provider.tool(...) on one dim line beneath a generic running header with timer
let line = format_mcp_invocation(invocation);
RunningToolCallCell {
title: "Working...".to_string(),
start_time: Instant::now(),
arg_lines: vec![line],
}
}
pub(crate) fn new_completed_custom_tool_call(
tool_name: String,
args: Option<String>,