use crate::exec_command::relativize_to_home; use crate::exec_command::strip_bash_lc_and_escape; use crate::slash_command::SlashCommand; 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::create_config_summary_entries; use codex_common::elapsed::format_duration; use codex_core::config::Config; use codex_core::plan_tool::PlanItemArg; use codex_core::plan_tool::StepStatus; use codex_core::plan_tool::UpdatePlanArgs; use codex_core::protocol::FileChange; use codex_core::protocol::McpInvocation; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionConfiguredEvent; use codex_core::protocol::TokenUsage; use codex_login::get_auth_file; use codex_login::try_read_auth_json; use image::DynamicImage; use image::ImageReader; use mcp_types::EmbeddedResourceResource; use mcp_types::ResourceLink; 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::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; use std::collections::HashMap; use std::io::Cursor; use std::path::PathBuf; use std::time::Duration; use tracing::error; pub(crate) struct CommandOutput { pub(crate) exit_code: i32, pub(crate) stdout: String, pub(crate) stderr: String, } struct FileSummary { display_path: String, added: usize, removed: usize, } pub(crate) enum PatchEventType { ApprovalRequest, ApplyBegin { auto_approved: bool }, } fn span_to_static(span: &Span) -> Span<'static> { Span { style: span.style, content: std::borrow::Cow::Owned(span.content.clone().into_owned()), } } fn line_to_static(line: &Line) -> Line<'static> { Line { style: line.style, alignment: line.alignment, spans: line.spans.iter().map(span_to_static).collect(), } } /// 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 }, // AgentMessage and AgentReasoning variants were unused and have been removed. /// An exec tool call that has not finished yet. ActiveExecCommand { view: TextBlock }, /// Completed exec tool call. CompletedExecCommand { view: TextBlock }, /// An MCP tool call that has not finished yet. ActiveMcpToolCall { 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 }, /// Background event. BackgroundEvent { view: TextBlock }, /// Output from the `/diff` command. GitDiffOutput { view: TextBlock }, /// Output from the `/status` command. StatusOutput { view: TextBlock }, /// Output from the `/prompts` command. PromptsOutput { 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 }, /// A human‑friendly rendering of the model's current plan and step /// statuses provided via the `update_plan` tool. PlanUpdate { view: TextBlock }, /// Result of applying a patch (success or failure) with optional output. PatchApplyResult { view: TextBlock }, } const TOOL_CALL_MAX_LINES: usize = 3; fn title_case(s: &str) -> String { if s.is_empty() { return String::new(); } let mut chars = s.chars(); let first = match chars.next() { Some(c) => c, None => return String::new(), }; let rest: String = chars.as_str().to_ascii_lowercase(); first.to_uppercase().collect::() + &rest } fn pretty_provider_name(id: &str) -> String { if id.eq_ignore_ascii_case("openai") { "OpenAI".to_string() } else { title_case(id) } } impl HistoryCell { /// Return a cloned, plain representation of the cell's lines suitable for /// one‑shot insertion into the terminal scrollback. Image cells are /// represented with a simple placeholder for now. pub(crate) fn plain_lines(&self) -> Vec> { match self { HistoryCell::WelcomeMessage { view } | HistoryCell::UserPrompt { view } | HistoryCell::BackgroundEvent { view } | HistoryCell::GitDiffOutput { view } | HistoryCell::StatusOutput { view } | HistoryCell::PromptsOutput { view } | HistoryCell::ErrorEvent { view } | HistoryCell::SessionInfo { view } | HistoryCell::CompletedExecCommand { view } | HistoryCell::CompletedMcpToolCall { view } | HistoryCell::PendingPatch { view } | HistoryCell::PlanUpdate { view } | HistoryCell::PatchApplyResult { view } | HistoryCell::ActiveExecCommand { view, .. } | HistoryCell::ActiveMcpToolCall { view, .. } => { view.lines.iter().map(line_to_static).collect() } HistoryCell::CompletedMcpToolCallWithImageOutput { .. } => vec![ Line::from("tool result (image output omitted)"), Line::from(""), ], } } pub(crate) fn desired_height(&self, width: u16) -> u16 { Paragraph::new(Text::from(self.plain_lines())) .wrap(Wrap { trim: false }) .line_count(width) .try_into() .unwrap_or(0) } 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 { let cwd_str = match relativize_to_home(&config.cwd) { Some(rel) if !rel.as_os_str().is_empty() => format!("~/{}", rel.display()), Some(_) => "~".to_string(), None => config.cwd.display().to_string(), }; let lines: Vec> = vec![ Line::from(vec![ Span::raw(">_ ").dim(), Span::styled( "You are using OpenAI Codex in", Style::default().add_modifier(Modifier::BOLD), ), Span::raw(format!(" {cwd_str}")).dim(), ]), Line::from("".dim()), Line::from(" To get started, describe a task or try one of these commands:".dim()), Line::from("".dim()), Line::from(format!(" /init - {}", SlashCommand::Init.description()).dim()), Line::from(format!(" /status - {}", SlashCommand::Status.description()).dim()), Line::from(format!(" /diff - {}", SlashCommand::Diff.description()).dim()), Line::from(format!(" /prompts - {}", SlashCommand::Prompts.description()).dim()), Line::from("".dim()), ]; 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_active_exec_command(command: Vec) -> Self { let command_escaped = strip_bash_lc_and_escape(&command); let lines: Vec> = vec![ Line::from(vec![ "▌ ".cyan(), "Running command ".magenta(), command_escaped.into(), ]), Line::from(""), ]; HistoryCell::ActiveExecCommand { view: TextBlock::new(lines), } } pub(crate) fn new_completed_exec_command(command: Vec, output: CommandOutput) -> Self { let CommandOutput { exit_code, stdout, stderr, } = output; let mut lines: Vec> = Vec::new(); let command_escaped = strip_bash_lc_and_escape(&command); lines.push(Line::from(vec![ "⚡ Ran command ".magenta(), command_escaped.into(), ])); let src = if exit_code == 0 { stdout } else { stderr }; let mut lines_iter = src.lines(); for (idx, raw) in lines_iter.by_ref().take(TOOL_CALL_MAX_LINES).enumerate() { let mut line = ansi_escape_line(raw); let prefix = if idx == 0 { " ⎿ " } else { " " }; line.spans.insert(0, prefix.into()); line.spans.iter_mut().for_each(|span| { span.style = span.style.add_modifier(Modifier::DIM); }); lines.push(line); } let remaining = lines_iter.count(); if remaining > 0 { let mut more = Line::from(format!("... +{remaining} lines")); // Continuation/ellipsis is treated as a subsequent line for prefixing more.spans.insert(0, " ".into()); more.spans.iter_mut().for_each(|span| { span.style = span.style.add_modifier(Modifier::DIM); }); lines.push(more); } lines.push(Line::from("")); HistoryCell::CompletedExecCommand { view: TextBlock::new(lines), } } pub(crate) fn new_active_mcp_tool_call(invocation: McpInvocation) -> Self { let title_line = Line::from(vec!["tool".magenta(), " running...".dim()]); let lines: Vec = vec![ title_line, format_mcp_invocation(invocation.clone()), Line::from(""), ]; HistoryCell::ActiveMcpToolCall { 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::ContentBlock::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: image }) } else { None } } _ => None, } } pub(crate) fn new_completed_mcp_tool_call( num_cols: u16, invocation: McpInvocation, duration: Duration, 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(duration); 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(format_mcp_invocation(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::ContentBlock::TextContent(text) => { format_and_truncate_tool_result( &text.text, TOOL_CALL_MAX_LINES, num_cols as usize, ) } mcp_types::ContentBlock::ImageContent(_) => { // TODO show images even if they're not the first result, will require a refactor of `CompletedMcpToolCall` "".to_string() } mcp_types::ContentBlock::AudioContent(_) => { "