diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 3e68b7ed..184c316f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -597,7 +597,9 @@ dependencies = [ "codex-core", "color-eyre", "crossterm", + "mcp-types", "ratatui", + "serde_json", "shlex", "tokio", "tracing", diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index ff7a50f6..32ba5a82 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -18,10 +18,12 @@ codex-ansi-escape = { path = "../ansi-escape" } codex-core = { path = "../core", features = ["cli"] } color-eyre = "0.6.3" crossterm = "0.28.1" +mcp-types = { path = "../mcp-types" } ratatui = { version = "0.29.0", features = [ "unstable-widget-ref", "unstable-rendered-line-info", ] } +serde_json = "1" shlex = "1.3.0" tokio = { version = "1", features = [ "io-std", diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 54c48047..51bc6025 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -328,6 +328,25 @@ impl ChatWidget<'_> { .record_completed_exec_command(call_id, stdout, stderr, exit_code); self.request_redraw()?; } + EventMsg::McpToolCallBegin { + call_id, + server, + tool, + arguments, + } => { + self.conversation_history + .add_active_mcp_tool_call(call_id, server, tool, arguments); + self.request_redraw()?; + } + EventMsg::McpToolCallEnd { + call_id, + success, + result, + } => { + self.conversation_history + .record_completed_mcp_tool_call(call_id, success, result); + self.request_redraw()?; + } event => { self.conversation_history .add_background_event(format!("{event:?}")); diff --git a/codex-rs/tui/src/conversation_history_widget.rs b/codex-rs/tui/src/conversation_history_widget.rs index 3cd3e61d..f8fc53f9 100644 --- a/codex-rs/tui/src/conversation_history_widget.rs +++ b/codex-rs/tui/src/conversation_history_widget.rs @@ -8,6 +8,7 @@ use crossterm::event::KeyEvent; use ratatui::prelude::*; use ratatui::style::Style; use ratatui::widgets::*; +use serde_json::Value as JsonValue; use std::cell::Cell as StdCell; use std::collections::HashMap; use std::path::PathBuf; @@ -192,6 +193,18 @@ impl ConversationHistoryWidget { self.add_to_history(HistoryCell::new_active_exec_command(call_id, command)); } + pub fn add_active_mcp_tool_call( + &mut self, + call_id: String, + server: String, + tool: String, + arguments: Option, + ) { + self.add_to_history(HistoryCell::new_active_mcp_tool_call( + call_id, server, tool, arguments, + )); + } + fn add_to_history(&mut self, cell: HistoryCell) { self.history.push(cell); } @@ -232,6 +245,43 @@ impl ConversationHistoryWidget { } } } + + pub fn record_completed_mcp_tool_call( + &mut self, + call_id: String, + success: bool, + result: Option, + ) { + // Convert result into serde_json::Value early so we don't have to + // worry about lifetimes inside the match arm. + let result_val = result.map(|r| { + serde_json::to_value(r) + .unwrap_or_else(|_| serde_json::Value::String("".into())) + }); + + for cell in self.history.iter_mut() { + if let HistoryCell::ActiveMcpToolCall { + call_id: history_id, + fq_tool_name, + invocation, + start, + .. + } = cell + { + if &call_id == history_id { + let completed = HistoryCell::new_completed_mcp_tool_call( + fq_tool_name.clone(), + invocation.clone(), + *start, + success, + result_val, + ); + *cell = completed; + break; + } + } + } + } } impl WidgetRef for ConversationHistoryWidget { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 5b9d7315..87bbd167 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -48,6 +48,22 @@ pub(crate) enum HistoryCell { /// Completed exec tool call. CompletedExecCommand { lines: Vec> }, + /// An MCP tool call that has not finished yet. + ActiveMcpToolCall { + call_id: String, + /// `server.tool` fully-qualified name so we can show a concise label + fq_tool_name: String, + /// Formatted invocation that mirrors the `$ cmd ...` style of exec + /// commands. We keep this around so the completed state can reuse the + /// exact same text without re-formatting. + invocation: String, + start: Instant, + lines: Vec>, + }, + + /// Completed MCP tool call. + CompletedMcpToolCall { lines: Vec> }, + /// Background event BackgroundEvent { lines: Vec> }, @@ -64,6 +80,8 @@ pub(crate) enum HistoryCell { }, } +const TOOL_CALL_MAX_LINES: usize = 5; + impl HistoryCell { pub(crate) fn new_user_prompt(message: String) -> Self { let mut lines: Vec> = Vec::new(); @@ -118,13 +136,11 @@ impl HistoryCell { ]); lines.push(title_line); - const MAX_LINES: usize = 5; - let src = if exit_code == 0 { stdout } else { stderr }; lines.push(Line::from(format!("$ {command}"))); let mut lines_iter = src.lines(); - for raw in lines_iter.by_ref().take(MAX_LINES) { + for raw in lines_iter.by_ref().take(TOOL_CALL_MAX_LINES) { lines.push(ansi_escape_line(raw).dim()); } let remaining = lines_iter.count(); @@ -136,6 +152,84 @@ impl HistoryCell { HistoryCell::CompletedExecCommand { lines } } + pub(crate) fn new_active_mcp_tool_call( + call_id: String, + server: String, + tool: String, + arguments: Option, + ) -> Self { + let fq_tool_name = format!("{server}.{tool}"); + + // Format the arguments as compact JSON so they roughly fit on one + // line. If there are no arguments we keep it empty so the invocation + // mirrors a function-style call. + let args_str = arguments + .as_ref() + .map(|v| { + // Use compact form to keep things short but readable. + serde_json::to_string(v).unwrap_or_else(|_| v.to_string()) + }) + .unwrap_or_default(); + + let invocation = if args_str.is_empty() { + format!("{fq_tool_name}()") + } else { + format!("{fq_tool_name}({args_str})") + }; + + let start = Instant::now(); + let title_line = Line::from(vec!["tool".magenta(), " running...".dim()]); + let lines: Vec> = vec![ + title_line, + Line::from(format!("$ {invocation}")), + Line::from(""), + ]; + + HistoryCell::ActiveMcpToolCall { + call_id, + fq_tool_name, + invocation, + start, + lines, + } + } + + pub(crate) fn new_completed_mcp_tool_call( + fq_tool_name: String, + invocation: String, + start: Instant, + success: bool, + result: Option, + ) -> Self { + let duration = start.elapsed(); + let status_str = if success { "success" } else { "failed" }; + let title_line = Line::from(vec![ + "tool".magenta(), + format!(" {fq_tool_name} ({status_str}, duration: {:?})", duration).dim(), + ]); + + let mut lines: Vec> = Vec::new(); + lines.push(title_line); + lines.push(Line::from(format!("$ {invocation}"))); + + if let Some(res_val) = result { + let json_pretty = + serde_json::to_string_pretty(&res_val).unwrap_or_else(|_| res_val.to_string()); + let mut iter = json_pretty.lines(); + for raw in iter.by_ref().take(TOOL_CALL_MAX_LINES) { + lines.push(Line::from(raw.to_string()).dim()); + } + let remaining = iter.count(); + if remaining > 0 { + lines.push(Line::from(format!("... {} additional lines", remaining)).dim()); + } + } + + lines.push(Line::from("")); + + HistoryCell::CompletedMcpToolCall { lines } + } + pub(crate) fn new_background_event(message: String) -> Self { let mut lines: Vec> = Vec::new(); lines.push(Line::from("event".dim())); @@ -234,6 +328,8 @@ impl HistoryCell { | HistoryCell::SessionInfo { lines, .. } | HistoryCell::ActiveExecCommand { lines, .. } | HistoryCell::CompletedExecCommand { lines, .. } + | HistoryCell::ActiveMcpToolCall { lines, .. } + | HistoryCell::CompletedMcpToolCall { lines, .. } | HistoryCell::PendingPatch { lines, .. } => lines, } }