mirror of
https://github.com/openai/codex.git
synced 2026-09-15 12:08:01 +00:00
feat: show MCP tool calls in TUI
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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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:?}"));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,91 @@ 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(),
|
||||
]);
|
||||
|
||||
// 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 +335,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