From 6032d784eead8d82e2fbbef957fb2f04fa74684d Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 24 Sep 2025 13:36:01 -0700 Subject: [PATCH] improve MCP tool call styling (#3871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshot 2025-09-18 at 12 29 15 PM --- codex-rs/tui/src/chatwidget.rs | 151 +++--- codex-rs/tui/src/chatwidget/tests.rs | 6 +- codex-rs/tui/src/history_cell.rs | 456 ++++++++++++++---- ..._tests__active_mcp_tool_call_snapshot.snap | 6 + ...ompleted_mcp_tool_call_error_snapshot.snap | 7 + ...call_multiple_outputs_inline_snapshot.snap | 8 + ...p_tool_call_multiple_outputs_snapshot.snap | 11 + ...pleted_mcp_tool_call_success_snapshot.snap | 7 + ...cp_tool_call_wrapped_outputs_snapshot.snap | 14 + 9 files changed, 523 insertions(+), 143 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__active_mcp_tool_call_snapshot.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_error_snapshot.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_multiple_outputs_inline_snapshot.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_multiple_outputs_snapshot.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_success_snapshot.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_wrapped_outputs_snapshot.snap diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3ac33534..ec749f4b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -76,6 +76,7 @@ use crate::history_cell::AgentMessageCell; use crate::history_cell::CommandOutput; use crate::history_cell::ExecCell; use crate::history_cell::HistoryCell; +use crate::history_cell::McpToolCallCell; use crate::history_cell::PatchEventType; use crate::history_cell::RateLimitSnapshotDisplay; use crate::markdown::append_markdown; @@ -191,7 +192,7 @@ pub(crate) struct ChatWidget { app_event_tx: AppEventSender, codex_op_tx: UnboundedSender, bottom_pane: BottomPane, - active_exec_cell: Option, + active_cell: Option>, config: Config, auth_manager: Arc, session_header: SessionHeader, @@ -395,7 +396,7 @@ impl ChatWidget { /// Finalize any active exec as failed and stop/clear running UI state. fn finalize_turn(&mut self) { // Ensure any spinner is replaced by a red ✗ and flushed into history. - self.finalize_active_exec_cell_as_failed(); + self.finalize_active_cell_as_failed(); // Reset running state and clear streaming buffers. self.bottom_pane.set_task_running(false); self.running_commands.clear(); @@ -601,7 +602,7 @@ impl ChatWidget { #[inline] fn handle_streaming_delta(&mut self, delta: String) { // Before streaming agent content, flush any active exec cell group. - self.flush_active_exec_cell(); + self.flush_active_cell(); if self.stream_controller.is_none() { self.stream_controller = Some(StreamController::new(self.config.clone())); @@ -621,16 +622,25 @@ impl ChatWidget { None => (vec![ev.call_id.clone()], Vec::new()), }; - if self.active_exec_cell.is_none() { - // This should have been created by handle_exec_begin_now, but in case it wasn't, - // create it now. - self.active_exec_cell = Some(history_cell::new_active_exec_command( + let needs_new = self + .active_cell + .as_ref() + .map(|cell| cell.as_any().downcast_ref::().is_none()) + .unwrap_or(true); + if needs_new { + self.flush_active_cell(); + self.active_cell = Some(Box::new(history_cell::new_active_exec_command( ev.call_id.clone(), command, parsed, - )); + ))); } - if let Some(cell) = self.active_exec_cell.as_mut() { + + if let Some(cell) = self + .active_cell + .as_mut() + .and_then(|c| c.as_any_mut().downcast_mut::()) + { cell.complete_call( &ev.call_id, CommandOutput { @@ -642,7 +652,7 @@ impl ChatWidget { ev.duration, ); if cell.should_flush() { - self.flush_active_exec_cell(); + self.flush_active_cell(); } } } @@ -709,50 +719,68 @@ impl ChatWidget { parsed_cmd: ev.parsed_cmd.clone(), }, ); - if let Some(exec) = &self.active_exec_cell { - if let Some(new_exec) = exec.with_added_call( + if let Some(cell) = self + .active_cell + .as_mut() + .and_then(|c| c.as_any_mut().downcast_mut::()) + && let Some(new_exec) = cell.with_added_call( ev.call_id.clone(), ev.command.clone(), ev.parsed_cmd.clone(), - ) { - self.active_exec_cell = Some(new_exec); - } else { - // Make a new cell. - self.flush_active_exec_cell(); - self.active_exec_cell = Some(history_cell::new_active_exec_command( - ev.call_id.clone(), - ev.command.clone(), - ev.parsed_cmd, - )); - } + ) + { + *cell = new_exec; } else { - self.active_exec_cell = Some(history_cell::new_active_exec_command( + self.flush_active_cell(); + + self.active_cell = Some(Box::new(history_cell::new_active_exec_command( ev.call_id.clone(), ev.command.clone(), ev.parsed_cmd, - )); + ))); } - // Request a redraw so the working header and command list are visible immediately. self.request_redraw(); } pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) { self.flush_answer_stream_with_separator(); - self.add_to_history(history_cell::new_active_mcp_tool_call(ev.invocation)); + self.flush_active_cell(); + self.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call( + ev.call_id, + ev.invocation, + ))); + self.request_redraw(); } pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) { self.flush_answer_stream_with_separator(); - self.add_boxed_history(history_cell::new_completed_mcp_tool_call( - 80, - ev.invocation, - ev.duration, - ev.result - .as_ref() - .map(|r| !r.is_error.unwrap_or(false)) - .unwrap_or(false), - ev.result, - )); + + let McpToolCallEndEvent { + call_id, + invocation, + duration, + result, + } = ev; + + let extra_cell = match self + .active_cell + .as_mut() + .and_then(|cell| cell.as_any_mut().downcast_mut::()) + { + Some(cell) if cell.call_id() == call_id => cell.complete(duration, result), + _ => { + self.flush_active_cell(); + let mut cell = history_cell::new_active_mcp_tool_call(call_id, invocation); + let extra_cell = cell.complete(duration, result); + self.active_cell = Some(Box::new(cell)); + extra_cell + } + }; + + self.flush_active_cell(); + if let Some(extra) = extra_cell { + self.add_boxed_history(extra); + } } fn layout_areas(&self, area: Rect) -> [Rect; 3] { @@ -760,7 +788,7 @@ impl ChatWidget { let remaining = area.height.saturating_sub(bottom_min); let active_desired = self - .active_exec_cell + .active_cell .as_ref() .map_or(0, |c| c.desired_height(area.width) + 1); let active_height = active_desired.min(remaining); @@ -805,7 +833,7 @@ impl ChatWidget { placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, }), - active_exec_cell: None, + active_cell: None, config: config.clone(), auth_manager, session_header: SessionHeader::new(config.model), @@ -866,7 +894,7 @@ impl ChatWidget { placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, }), - active_exec_cell: None, + active_cell: None, config: config.clone(), auth_manager, session_header: SessionHeader::new(config.model), @@ -897,7 +925,7 @@ impl ChatWidget { pub fn desired_height(&self, width: u16) -> u16 { self.bottom_pane.desired_height(width) + self - .active_exec_cell + .active_cell .as_ref() .map_or(0, |c| c.desired_height(width) + 1) } @@ -1115,10 +1143,9 @@ impl ChatWidget { } } - fn flush_active_exec_cell(&mut self) { - if let Some(active) = self.active_exec_cell.take() { - self.app_event_tx - .send(AppEvent::InsertHistoryCell(Box::new(active))); + fn flush_active_cell(&mut self) { + if let Some(active) = self.active_cell.take() { + self.app_event_tx.send(AppEvent::InsertHistoryCell(active)); } } @@ -1129,7 +1156,7 @@ impl ChatWidget { fn add_boxed_history(&mut self, cell: Box) { if !cell.display_lines(u16::MAX).is_empty() { // Only break exec grouping if the cell renders visible lines. - self.flush_active_exec_cell(); + self.flush_active_cell(); } self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); } @@ -1350,7 +1377,7 @@ impl ChatWidget { if let Some(output) = review.review_output { self.flush_answer_stream_with_separator(); self.flush_interrupt_queue(); - self.flush_active_exec_cell(); + self.flush_active_cell(); if output.findings.is_empty() { let explanation = output.overall_explanation.trim().to_string(); @@ -1419,12 +1446,16 @@ impl ChatWidget { } } - /// Mark the active exec cell as failed (✗) and flush it into history. - fn finalize_active_exec_cell_as_failed(&mut self) { - if let Some(cell) = self.active_exec_cell.take() { - let cell = cell.into_failed(); - // Insert finalized exec into history and keep grouping consistent. - self.add_to_history(cell); + /// Mark the active cell as failed (✗) and flush it into history. + fn finalize_active_cell_as_failed(&mut self) { + if let Some(mut cell) = self.active_cell.take() { + // Insert finalized cell into history and keep grouping consistent. + if let Some(exec) = cell.as_any_mut().downcast_mut::() { + exec.mark_failed(); + } else if let Some(tool) = cell.as_any_mut().downcast_mut::() { + tool.mark_failed(); + } + self.add_boxed_history(cell); } } @@ -1899,12 +1930,16 @@ impl WidgetRef for &ChatWidget { let [_, active_cell_area, bottom_pane_area] = self.layout_areas(area); (&self.bottom_pane).render(bottom_pane_area, buf); if !active_cell_area.is_empty() - && let Some(cell) = &self.active_exec_cell + && let Some(cell) = &self.active_cell { - let mut active_cell_area = active_cell_area; - active_cell_area.y = active_cell_area.y.saturating_add(1); - active_cell_area.height -= 1; - cell.render_ref(active_cell_area, buf); + let mut area = active_cell_area; + area.y = area.y.saturating_add(1); + area.height = area.height.saturating_sub(1); + if let Some(exec) = cell.as_any().downcast_ref::() { + exec.render_ref(area, buf); + } else if let Some(tool) = cell.as_any().downcast_ref::() { + tool.render_ref(area, buf); + } } } } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index fa3235b6..7805e07e 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -314,7 +314,7 @@ fn make_chatwidget_manual() -> ( app_event_tx, codex_op_tx: op_tx, bottom_pane: bottom, - active_exec_cell: None, + active_cell: None, config: cfg.clone(), auth_manager, session_header: SessionHeader::new(cfg.model), @@ -551,9 +551,9 @@ fn end_exec(chat: &mut ChatWidget, call_id: &str, stdout: &str, stderr: &str, ex fn active_blob(chat: &ChatWidget) -> String { let lines = chat - .active_exec_cell + .active_cell .as_ref() - .expect("active exec cell present") + .expect("active cell present") .display_lines(80); lines_to_single_string(&lines) } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 5b6c4af3..a63da9ea 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -105,6 +105,10 @@ impl dyn HistoryCell { pub(crate) fn as_any(&self) -> &dyn Any { self } + + pub(crate) fn as_any_mut(&mut self) -> &mut dyn Any { + self + } } #[derive(Debug)] @@ -542,9 +546,7 @@ impl WidgetRef for &ExecCell { } impl ExecCell { - /// Convert an active exec cell into a failed, completed exec cell. - /// Any call without output is marked as failed with a red ✗. - pub(crate) fn into_failed(mut self) -> ExecCell { + pub(crate) fn mark_failed(&mut self) { for call in self.calls.iter_mut() { if call.output.is_none() { let elapsed = call @@ -561,7 +563,6 @@ impl ExecCell { }); } } - self } pub(crate) fn new(call: ExecCall) -> Self { @@ -942,6 +943,179 @@ impl HistoryCell for CompositeHistoryCell { } } +#[derive(Debug)] +pub(crate) struct McpToolCallCell { + call_id: String, + invocation: McpInvocation, + start_time: Instant, + duration: Option, + result: Option>, +} + +impl McpToolCallCell { + pub(crate) fn new(call_id: String, invocation: McpInvocation) -> Self { + Self { + call_id, + invocation, + start_time: Instant::now(), + duration: None, + result: None, + } + } + + pub(crate) fn call_id(&self) -> &str { + &self.call_id + } + + pub(crate) fn complete( + &mut self, + duration: Duration, + result: Result, + ) -> Option> { + let image_cell = try_new_completed_mcp_tool_call_with_image_output(&result) + .map(|cell| Box::new(cell) as Box); + self.duration = Some(duration); + self.result = Some(result); + image_cell + } + + fn success(&self) -> Option { + match self.result.as_ref() { + Some(Ok(result)) => Some(!result.is_error.unwrap_or(false)), + Some(Err(_)) => Some(false), + None => None, + } + } + + pub(crate) fn mark_failed(&mut self) { + let elapsed = self.start_time.elapsed(); + self.duration = Some(elapsed); + self.result = Some(Err("interrupted".to_string())); + } + + fn render_content_block(block: &mcp_types::ContentBlock, width: usize) -> String { + match block { + mcp_types::ContentBlock::TextContent(text) => { + format_and_truncate_tool_result(&text.text, TOOL_CALL_MAX_LINES, width) + } + mcp_types::ContentBlock::ImageContent(_) => "".to_string(), + mcp_types::ContentBlock::AudioContent(_) => "