diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 694e11383f6..ae574ba303f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -768,6 +768,7 @@ dependencies = [ "tui-input", "tui-markdown", "tui-textarea", + "unicode-segmentation", "uuid", ] diff --git a/codex-rs/mcp-types/src/lib.rs b/codex-rs/mcp-types/src/lib.rs index afd6f4ad631..0ed518535f7 100644 --- a/codex-rs/mcp-types/src/lib.rs +++ b/codex-rs/mcp-types/src/lib.rs @@ -1144,6 +1144,7 @@ pub enum ServerRequest { #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] #[serde(untagged)] +#[allow(clippy::large_enum_variant)] pub enum ServerResult { Result(Result), InitializeResult(InitializeResult), diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 235f5f0c7a1..ffc107e831f 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -34,7 +34,7 @@ ratatui = { version = "0.29.0", features = [ ] } ratatui-image = "8.0.0" regex-lite = "0.1" -serde_json = "1" +serde_json = { version = "1", features = ["preserve_order"] } shlex = "1.3.0" strum = "0.27.1" strum_macros = "0.27.1" @@ -51,6 +51,7 @@ tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } tui-input = "0.11.1" tui-markdown = "0.3.3" tui-textarea = "0.7.0" +unicode-segmentation = "1.12.0" uuid = "1" [dev-dependencies] diff --git a/codex-rs/tui/src/conversation_history_widget.rs b/codex-rs/tui/src/conversation_history_widget.rs index 9242e00389e..a23e00d7760 100644 --- a/codex-rs/tui/src/conversation_history_widget.rs +++ b/codex-rs/tui/src/conversation_history_widget.rs @@ -299,7 +299,6 @@ impl ConversationHistoryWidget { for entry in self.entries.iter_mut() { if let HistoryCell::ActiveMcpToolCall { call_id: history_id, - fq_tool_name, invocation, start, .. @@ -307,7 +306,7 @@ impl ConversationHistoryWidget { { if &call_id == history_id { let completed = HistoryCell::new_completed_mcp_tool_call( - fq_tool_name.clone(), + width, invocation.clone(), *start, success, diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index a1fc672c6be..481576b5b3b 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -2,6 +2,7 @@ use crate::cell_widget::CellWidget; use crate::exec_command::escape_command; use crate::markdown::append_markdown; use crate::text_block::TextBlock; +use crate::text_formatting::format_and_truncate_tool_result; use base64::Engine; use codex_ansi_escape::ansi_escape_line; use codex_common::elapsed::format_duration; @@ -14,6 +15,7 @@ use image::DynamicImage; use image::GenericImageView; use image::ImageReader; use lazy_static::lazy_static; +use mcp_types::EmbeddedResourceResource; use ratatui::prelude::*; use ratatui::style::Color; use ratatui::style::Modifier; @@ -73,18 +75,14 @@ pub(crate) enum HistoryCell { /// 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, + /// Formatted line that shows the command name and arguments + invocation: Line<'static>, start: Instant, view: TextBlock, }, /// Completed MCP tool call where we show the result serialized as JSON. - CompletedMcpToolCallWithTextOutput { view: TextBlock }, + CompletedMcpToolCall { view: TextBlock }, /// Completed MCP tool call where the result is an image. /// Admittedly, [mcp_types::CallToolResult] can have multiple content types, @@ -289,8 +287,6 @@ impl HistoryCell { 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. @@ -302,29 +298,30 @@ impl HistoryCell { }) .unwrap_or_default(); - let invocation = if args_str.is_empty() { - format!("{fq_tool_name}()") - } else { - format!("{fq_tool_name}({args_str})") - }; + let invocation_spans = vec![ + Span::styled(server, Style::default().fg(Color::Blue)), + Span::raw("."), + Span::styled(tool, Style::default().fg(Color::Blue)), + Span::raw("("), + Span::styled(args_str, Style::default().fg(Color::Gray)), + Span::raw(")"), + ]; + let invocation = Line::from(invocation_spans); 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(""), - ]; + let lines: Vec> = vec![title_line, invocation.clone(), Line::from("")]; HistoryCell::ActiveMcpToolCall { call_id, - fq_tool_name, invocation, start, view: TextBlock::new(lines), } } + /// If the first content is an image, return a new cell with the image. + /// TODO(rgwood-dd): Handle images properly even if they're not the first result. fn try_new_completed_mcp_tool_call_with_image_output( result: &Result, ) -> Option { @@ -370,8 +367,8 @@ impl HistoryCell { } pub(crate) fn new_completed_mcp_tool_call( - fq_tool_name: String, - invocation: String, + num_cols: u16, + invocation: Line<'static>, start: Instant, success: bool, result: Result, @@ -384,36 +381,70 @@ impl HistoryCell { 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(), + " ".into(), + if success { + status_str.green() + } else { + status_str.red() + }, + format!(", duration: {duration}").gray(), ]); let mut lines: Vec> = Vec::new(); lines.push(title_line); - lines.push(Line::from(format!("$ {invocation}"))); - - // 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())) - }); - - if let Ok(res_val) = result_val { - 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()); + lines.push(invocation); + + match result { + Ok(mcp_types::CallToolResult { content, .. }) => { + if !content.is_empty() { + lines.push(Line::from("")); + + for tool_call_result in content { + let line_text = match tool_call_result { + mcp_types::CallToolResultContent::TextContent(text) => { + format_and_truncate_tool_result( + &text.text, + TOOL_CALL_MAX_LINES, + num_cols as usize, + ) + } + mcp_types::CallToolResultContent::ImageContent(_) => { + // TODO show images even if they're not the first result, will require a refactor of `CompletedMcpToolCall` + "".to_string() + } + mcp_types::CallToolResultContent::AudioContent(_) => { + "