feat: show MCP tool calls in TUI (#836)
Adds logic for the `McpToolCallBegin` and `McpToolCallEnd` events in `codex-rs/tui/src/chatwidget.rs` so they get entries in the conversation history in the TUI. Building on top of https://github.com/openai/codex/pull/829, here is the result of running: ``` cargo run --bin codex -- 'what is the weather in san francisco tomorrow' ``` 
This commit is contained in:
2
codex-rs/Cargo.lock
generated
2
codex-rs/Cargo.lock
generated
@@ -597,7 +597,9 @@ dependencies = [
|
||||
"codex-core",
|
||||
"color-eyre",
|
||||
"crossterm",
|
||||
"mcp-types",
|
||||
"ratatui",
|
||||
"serde_json",
|
||||
"shlex",
|
||||
"tokio",
|
||||
"tracing",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:?}"));
|
||||
|
||||
@@ -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<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 +245,43 @@ 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 {
|
||||
|
||||
@@ -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>> },
|
||||
|
||||
@@ -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<Line<'static>> = 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<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_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<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(),
|
||||
]);
|
||||
|
||||
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(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<Line<'static>> = 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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user