feat: show MCP tool calls in TUI

This commit is contained in:
Michael Bolin
2025-05-06 14:07:24 -07:00
parent a14e109f26
commit 15243bd2b3
5 changed files with 169 additions and 2 deletions

2
codex-rs/Cargo.lock generated
View File

@@ -597,7 +597,9 @@ dependencies = [
"codex-core",
"color-eyre",
"crossterm",
"mcp-types",
"ratatui",
"serde_json",
"shlex",
"tokio",
"tracing",

View File

@@ -35,3 +35,5 @@ tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
tui-input = "0.11.1"
tui-textarea = "0.7.0"
serde_json = "1"
mcp-types = { path = "../mcp-types" }

View File

@@ -334,14 +334,22 @@ impl ChatWidget<'_> {
tool,
arguments,
} => {
todo!()
self.conversation_history.add_active_mcp_tool_call(
call_id,
server,
tool,
arguments,
);
self.request_redraw()?;
}
EventMsg::McpToolCallEnd {
call_id,
success,
result,
} => {
todo!()
self.conversation_history
.record_completed_mcp_tool_call(call_id, success, result);
self.request_redraw()?;
}
event => {
self.conversation_history

View File

@@ -3,6 +3,7 @@ use crate::history_cell::HistoryCell;
use crate::history_cell::PatchEventType;
use codex_core::config::Config;
use codex_core::protocol::FileChange;
use serde_json::Value as JsonValue;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use ratatui::prelude::*;
@@ -192,6 +193,21 @@ 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<JsonValue>,
) {
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 +248,40 @@ impl ConversationHistoryWidget {
}
}
}
pub fn record_completed_mcp_tool_call(
&mut self,
call_id: String,
success: bool,
result: Option<mcp_types::CallToolResult>,
) {
// 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("<serialization error>".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 {

View File

@@ -48,6 +48,22 @@ pub(crate) enum HistoryCell {
/// Completed exec tool call.
CompletedExecCommand { lines: Vec<Line<'static>> },
/// 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<Line<'static>>,
},
/// Completed MCP tool call.
CompletedMcpToolCall { lines: Vec<Line<'static>> },
/// Background event
BackgroundEvent { lines: Vec<Line<'static>> },
@@ -136,6 +152,93 @@ impl HistoryCell {
HistoryCell::CompletedExecCommand { lines }
}
pub(crate) fn new_active_mcp_tool_call(
call_id: String,
server: String,
tool: String,
arguments: Option<serde_json::Value>,
) -> 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_else(|| "".to_string());
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<Line<'static>> = 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<serde_json::Value>,
) -> 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(),
]);
// Render a short preview of the result (if any).
const MAX_LINES: usize = 5;
let mut lines: Vec<Line<'static>> = 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(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<Line<'static>> = Vec::new();
lines.push(Line::from("event".dim()));
@@ -234,6 +337,8 @@ impl HistoryCell {
| HistoryCell::SessionInfo { lines, .. }
| HistoryCell::ActiveExecCommand { lines, .. }
| HistoryCell::CompletedExecCommand { lines, .. }
| HistoryCell::ActiveMcpToolCall { lines, .. }
| HistoryCell::CompletedMcpToolCall { lines, .. }
| HistoryCell::PendingPatch { lines, .. } => lines,
}
}