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; use codex_common::summarize_sandbox_policy; use codex_core::WireApi; use codex_core::config::Config; use codex_core::model_supports_reasoning_summaries; use codex_core::protocol::FileChange; use codex_core::protocol::SessionConfiguredEvent; 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; use ratatui::style::Style; use ratatui::text::Line as RtLine; use ratatui::text::Span as RtSpan; use ratatui_image::Image as TuiImage; use ratatui_image::Resize as ImgResize; use ratatui_image::picker::ProtocolType; use std::collections::HashMap; use std::io::Cursor; use std::path::PathBuf; use std::time::Duration; use std::time::Instant; use tracing::error; pub(crate) struct CommandOutput { pub(crate) exit_code: i32, pub(crate) stdout: String, pub(crate) stderr: String, pub(crate) duration: Duration, } pub(crate) enum PatchEventType { ApprovalRequest, ApplyBegin { auto_approved: bool }, } /// Represents an event to display in the conversation history. Returns its /// `Vec>` representation to make it easier to display in a /// scrollable list. pub(crate) enum HistoryCell { /// Welcome message. WelcomeMessage { view: TextBlock }, /// Message from the user. UserPrompt { view: TextBlock }, /// Message from the agent. AgentMessage { view: TextBlock }, /// Reasoning event from the agent. AgentReasoning { view: TextBlock }, /// An exec tool call that has not finished yet. ActiveExecCommand { call_id: String, /// The shell command, escaped and formatted. command: String, start: Instant, view: TextBlock, }, /// Completed exec tool call. CompletedExecCommand { view: TextBlock }, /// An MCP tool call that has not finished yet. ActiveMcpToolCall { call_id: 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. CompletedMcpToolCall { view: TextBlock }, /// Completed MCP tool call where the result is an image. /// Admittedly, [mcp_types::CallToolResult] can have multiple content types, /// which could be a mix of text and images, so we need to tighten this up. // NOTE: For image output we keep the *original* image around and lazily // compute a resized copy that fits the available cell width. Caching the // resized version avoids doing the potentially expensive rescale twice // because the scroll-view first calls `height()` for layouting and then // `render_window()` for painting. CompletedMcpToolCallWithImageOutput { image: DynamicImage, /// Cached data derived from the current terminal width. The cache is /// invalidated whenever the width changes (e.g. when the user /// resizes the window). render_cache: std::cell::RefCell>, }, /// Background event. BackgroundEvent { view: TextBlock }, /// Output from the `/diff` command. GitDiffOutput { view: TextBlock }, /// Error event from the backend. ErrorEvent { view: TextBlock }, /// Info describing the newly-initialized session. SessionInfo { view: TextBlock }, /// A pending code patch that is awaiting user approval. Mirrors the /// behaviour of `ActiveExecCommand` so the user sees *what* patch the /// model wants to apply before being prompted to approve or deny it. PendingPatch { view: TextBlock }, } const TOOL_CALL_MAX_LINES: usize = 5; impl HistoryCell { pub(crate) fn new_session_info( config: &Config, event: SessionConfiguredEvent, is_first_event: bool, ) -> Self { let SessionConfiguredEvent { model, session_id, history_log_id: _, history_entry_count: _, } = event; if is_first_event { const VERSION: &str = env!("CARGO_PKG_VERSION"); let mut lines: Vec> = vec![ Line::from(vec![ "OpenAI ".into(), "Codex".bold(), format!(" v{VERSION}").into(), " (research preview)".dim(), ]), Line::from(""), Line::from(vec![ "codex session".magenta().bold(), " ".into(), session_id.to_string().dim(), ]), ]; let mut entries = vec![ ("workdir", config.cwd.display().to_string()), ("model", config.model.clone()), ("provider", config.model_provider_id.clone()), ("approval", format!("{:?}", config.approval_policy)), ("sandbox", summarize_sandbox_policy(&config.sandbox_policy)), ]; if config.model_provider.wire_api == WireApi::Responses && model_supports_reasoning_summaries(config) { entries.push(( "reasoning effort", config.model_reasoning_effort.to_string(), )); entries.push(( "reasoning summaries", config.model_reasoning_summary.to_string(), )); } for (key, value) in entries { lines.push(Line::from(vec![format!("{key}: ").bold(), value.into()])); } lines.push(Line::from("")); HistoryCell::WelcomeMessage { view: TextBlock::new(lines), } } else if config.model == model { HistoryCell::SessionInfo { view: TextBlock::new(Vec::new()), } } else { let lines = vec![ Line::from("model changed:".magenta().bold()), Line::from(format!("requested: {}", config.model)), Line::from(format!("used: {model}")), Line::from(""), ]; HistoryCell::SessionInfo { view: TextBlock::new(lines), } } } pub(crate) fn new_user_prompt(message: String) -> Self { let mut lines: Vec> = Vec::new(); lines.push(Line::from("user".cyan().bold())); lines.extend(message.lines().map(|l| Line::from(l.to_string()))); lines.push(Line::from("")); HistoryCell::UserPrompt { view: TextBlock::new(lines), } } pub(crate) fn new_agent_message(config: &Config, message: String) -> Self { let mut lines: Vec> = Vec::new(); lines.push(Line::from("codex".magenta().bold())); append_markdown(&message, &mut lines, config); lines.push(Line::from("")); HistoryCell::AgentMessage { view: TextBlock::new(lines), } } pub(crate) fn new_agent_reasoning(config: &Config, text: String) -> Self { let mut lines: Vec> = Vec::new(); lines.push(Line::from("thinking".magenta().italic())); append_markdown(&text, &mut lines, config); lines.push(Line::from("")); HistoryCell::AgentReasoning { view: TextBlock::new(lines), } } pub(crate) fn new_active_exec_command(call_id: String, command: Vec) -> Self { let command_escaped = escape_command(&command); let start = Instant::now(); let lines: Vec> = vec![ Line::from(vec!["command".magenta(), " running...".dim()]), Line::from(format!("$ {command_escaped}")), Line::from(""), ]; HistoryCell::ActiveExecCommand { call_id, command: command_escaped, start, view: TextBlock::new(lines), } } pub(crate) fn new_completed_exec_command(command: String, output: CommandOutput) -> Self { let CommandOutput { exit_code, stdout, stderr, duration, } = output; let mut lines: Vec> = Vec::new(); // Title depends on whether we have output yet. let title_line = Line::from(vec![ "command".magenta(), format!( " (code: {}, duration: {})", exit_code, format_duration(duration) ) .dim(), ]); lines.push(title_line); 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(TOOL_CALL_MAX_LINES) { lines.push(ansi_escape_line(raw).dim()); } let remaining = lines_iter.count(); if remaining > 0 { lines.push(Line::from(format!("... {remaining} additional lines")).dim()); } lines.push(Line::from("")); HistoryCell::CompletedExecCommand { view: TextBlock::new(lines), } } pub(crate) fn new_active_mcp_tool_call( call_id: String, server: String, tool: String, arguments: Option, ) -> Self { // 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_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, invocation.clone(), Line::from("")]; HistoryCell::ActiveMcpToolCall { call_id, 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 { match result { Ok(mcp_types::CallToolResult { content, .. }) => { if let Some(mcp_types::CallToolResultContent::ImageContent(image)) = content.first() { let raw_data = match base64::engine::general_purpose::STANDARD.decode(&image.data) { Ok(data) => data, Err(e) => { error!("Failed to decode image data: {e}"); return None; } }; let reader = match ImageReader::new(Cursor::new(raw_data)).with_guessed_format() { Ok(reader) => reader, Err(e) => { error!("Failed to guess image format: {e}"); return None; } }; let image = match reader.decode() { Ok(image) => image, Err(e) => { error!("Image decoding failed: {e}"); return None; } }; Some(HistoryCell::CompletedMcpToolCallWithImageOutput { image, render_cache: std::cell::RefCell::new(None), }) } else { None } } _ => None, } } pub(crate) fn new_completed_mcp_tool_call( num_cols: u16, invocation: Line<'static>, start: Instant, success: bool, result: Result, ) -> Self { if let Some(cell) = Self::try_new_completed_mcp_tool_call_with_image_output(&result) { return cell; } let duration = format_duration(start.elapsed()); let status_str = if success { "success" } else { "failed" }; let title_line = Line::from(vec![ "tool".magenta(), " ".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(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(_) => { "