use crate::diff_render::create_diff_summary; use crate::diff_render::display_path_for; use crate::exec_cell::CommandOutput; use crate::exec_cell::OutputLinesParams; use crate::exec_cell::TOOL_CALL_MAX_LINES; use crate::exec_cell::output_lines; use crate::exec_cell::spinner; use crate::exec_command::relativize_to_home; use crate::exec_command::strip_bash_lc_and_escape; use crate::markdown::append_markdown; use crate::render::line_utils::line_to_static; use crate::render::line_utils::prefix_lines; use crate::render::line_utils::push_owned_lines; use crate::style::user_message_style; use crate::text_formatting::format_and_truncate_tool_result; use crate::text_formatting::truncate_text; use crate::ui_consts::LIVE_PREFIX_COLS; use crate::updates::UpdateAction; use crate::version::CODEX_CLI_VERSION; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; use crate::wrapping::word_wrap_lines; use base64::Engine; use codex_common::format_env_display::format_env_display; use codex_core::config::Config; use codex_core::config_types::McpServerTransportConfig; use codex_core::config_types::ReasoningSummaryFormat; use codex_core::protocol::FileChange; use codex_core::protocol::McpAuthStatus; use codex_core::protocol::McpInvocation; use codex_core::protocol::SessionConfiguredEvent; use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; use image::DynamicImage; use image::ImageReader; use mcp_types::EmbeddedResourceResource; use mcp_types::Resource; use mcp_types::ResourceLink; use mcp_types::ResourceTemplate; use ratatui::prelude::*; use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::style::Styled; use ratatui::style::Stylize; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; use std::any::Any; use std::collections::HashMap; use std::io::Cursor; use std::path::Path; use std::path::PathBuf; use std::time::Duration; use std::time::Instant; use tracing::error; use unicode_width::UnicodeWidthStr; /// 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) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { fn display_lines(&self, width: u16) -> Vec>; fn desired_height(&self, width: u16) -> u16 { Paragraph::new(Text::from(self.display_lines(width))) .wrap(Wrap { trim: false }) .line_count(width) .try_into() .unwrap_or(0) } fn transcript_lines(&self, width: u16) -> Vec> { self.display_lines(width) } fn desired_transcript_height(&self, width: u16) -> u16 { let lines = self.transcript_lines(width); // Workaround for ratatui bug: if there's only one line and it's whitespace-only, ratatui gives 2 lines. if let [line] = &lines[..] && line .spans .iter() .all(|s| s.content.chars().all(char::is_whitespace)) { return 1; } Paragraph::new(Text::from(lines)) .wrap(Wrap { trim: false }) .line_count(width) .try_into() .unwrap_or(0) } fn is_stream_continuation(&self) -> bool { false } } 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)] pub(crate) struct UserHistoryCell { pub message: String, } impl HistoryCell for UserHistoryCell { fn display_lines(&self, width: u16) -> Vec> { let mut lines: Vec> = Vec::new(); let wrap_width = width .saturating_sub( LIVE_PREFIX_COLS + 1, /* keep a one-column right margin for wrapping */ ) .max(1); let style = user_message_style(); let wrapped = word_wrap_lines( &self .message .lines() .map(|l| Line::from(l).style(style)) .collect::>(), // Wrap algorithm matches textarea.rs. RtOptions::new(usize::from(wrap_width)) .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), ); lines.push(Line::from("").style(style)); lines.extend(prefix_lines(wrapped, "› ".bold().dim(), " ".into())); lines.push(Line::from("").style(style)); lines } } #[derive(Debug)] pub(crate) struct ReasoningSummaryCell { _header: String, content: String, transcript_only: bool, } impl ReasoningSummaryCell { pub(crate) fn new(header: String, content: String, transcript_only: bool) -> Self { Self { _header: header, content, transcript_only, } } fn lines(&self, width: u16) -> Vec> { let mut lines: Vec> = Vec::new(); append_markdown( &self.content, Some((width as usize).saturating_sub(2)), &mut lines, ); let summary_style = Style::default().dim().italic(); let summary_lines = lines .into_iter() .map(|mut line| { line.spans = line .spans .into_iter() .map(|span| span.patch_style(summary_style)) .collect(); line }) .collect::>(); word_wrap_lines( &summary_lines, RtOptions::new(width as usize) .initial_indent("• ".dim().into()) .subsequent_indent(" ".into()), ) } } impl HistoryCell for ReasoningSummaryCell { fn display_lines(&self, width: u16) -> Vec> { if self.transcript_only { Vec::new() } else { self.lines(width) } } fn desired_height(&self, width: u16) -> u16 { if self.transcript_only { 0 } else { self.lines(width).len() as u16 } } fn transcript_lines(&self, width: u16) -> Vec> { self.lines(width) } fn desired_transcript_height(&self, width: u16) -> u16 { self.lines(width).len() as u16 } } #[derive(Debug)] pub(crate) struct AgentMessageCell { lines: Vec>, is_first_line: bool, } impl AgentMessageCell { pub(crate) fn new(lines: Vec>, is_first_line: bool) -> Self { Self { lines, is_first_line, } } } impl HistoryCell for AgentMessageCell { fn display_lines(&self, width: u16) -> Vec> { word_wrap_lines( &self.lines, RtOptions::new(width as usize) .initial_indent(if self.is_first_line { "• ".dim().into() } else { " ".into() }) .subsequent_indent(" ".into()), ) } fn is_stream_continuation(&self) -> bool { !self.is_first_line } } #[derive(Debug)] pub(crate) struct PlainHistoryCell { lines: Vec>, } impl PlainHistoryCell { pub(crate) fn new(lines: Vec>) -> Self { Self { lines } } } impl HistoryCell for PlainHistoryCell { fn display_lines(&self, _width: u16) -> Vec> { self.lines.clone() } } #[cfg_attr(debug_assertions, allow(dead_code))] #[derive(Debug)] pub(crate) struct UpdateAvailableHistoryCell { latest_version: String, update_action: Option, } #[cfg_attr(debug_assertions, allow(dead_code))] impl UpdateAvailableHistoryCell { pub(crate) fn new(latest_version: String, update_action: Option) -> Self { Self { latest_version, update_action, } } } impl HistoryCell for UpdateAvailableHistoryCell { fn display_lines(&self, width: u16) -> Vec> { use ratatui_macros::line; use ratatui_macros::text; let update_instruction = if let Some(update_action) = self.update_action { line!["Run ", update_action.command_str().cyan(), " to update."] } else { line![ "See ", "https://github.com/openai/codex".cyan().underlined(), " for installation options." ] }; let content = text![ line![ padded_emoji("✨").bold().cyan(), "Update available!".bold().cyan(), " ", format!("{CODEX_CLI_VERSION} -> {}", self.latest_version).bold(), ], update_instruction, "", "See full release notes:", "https://github.com/openai/codex/releases/latest" .cyan() .underlined(), ]; let inner_width = content .width() .min(usize::from(width.saturating_sub(4))) .max(1); with_border_with_inner_width(content.lines, inner_width) } } #[derive(Debug)] pub(crate) struct PrefixedWrappedHistoryCell { text: Text<'static>, initial_prefix: Line<'static>, subsequent_prefix: Line<'static>, } impl PrefixedWrappedHistoryCell { pub(crate) fn new( text: impl Into>, initial_prefix: impl Into>, subsequent_prefix: impl Into>, ) -> Self { Self { text: text.into(), initial_prefix: initial_prefix.into(), subsequent_prefix: subsequent_prefix.into(), } } } impl HistoryCell for PrefixedWrappedHistoryCell { fn display_lines(&self, width: u16) -> Vec> { if width == 0 { return Vec::new(); } let opts = RtOptions::new(width.max(1) as usize) .initial_indent(self.initial_prefix.clone()) .subsequent_indent(self.subsequent_prefix.clone()); let wrapped = word_wrap_lines(&self.text, opts); let mut out = Vec::new(); push_owned_lines(&wrapped, &mut out); out } fn desired_height(&self, width: u16) -> u16 { self.display_lines(width).len() as u16 } } fn truncate_exec_snippet(full_cmd: &str) -> String { let mut snippet = match full_cmd.split_once('\n') { Some((first, _)) => format!("{first} ..."), None => full_cmd.to_string(), }; snippet = truncate_text(&snippet, 80); snippet } fn exec_snippet(command: &[String]) -> String { let full_cmd = strip_bash_lc_and_escape(command); truncate_exec_snippet(&full_cmd) } pub fn new_approval_decision_cell( command: Vec, decision: codex_core::protocol::ReviewDecision, ) -> Box { use codex_core::protocol::ReviewDecision::*; let (symbol, summary): (Span<'static>, Vec>) = match decision { Approved => { let snippet = Span::from(exec_snippet(&command)).dim(); ( "✔ ".green(), vec![ "You ".into(), "approved".bold(), " codex to run ".into(), snippet, " this time".bold(), ], ) } ApprovedForSession => { let snippet = Span::from(exec_snippet(&command)).dim(); ( "✔ ".green(), vec![ "You ".into(), "approved".bold(), " codex to run ".into(), snippet, " every time this session".bold(), ], ) } Denied => { let snippet = Span::from(exec_snippet(&command)).dim(); ( "✗ ".red(), vec![ "You ".into(), "did not approve".bold(), " codex to run ".into(), snippet, ], ) } Abort => { let snippet = Span::from(exec_snippet(&command)).dim(); ( "✗ ".red(), vec![ "You ".into(), "canceled".bold(), " the request to run ".into(), snippet, ], ) } }; Box::new(PrefixedWrappedHistoryCell::new( Line::from(summary), symbol, " ", )) } /// Cyan history cell line showing the current review status. pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell { PlainHistoryCell { lines: vec![Line::from(message.cyan())], } } #[derive(Debug)] pub(crate) struct PatchHistoryCell { changes: HashMap, cwd: PathBuf, } impl HistoryCell for PatchHistoryCell { fn display_lines(&self, width: u16) -> Vec> { create_diff_summary(&self.changes, &self.cwd, width as usize) } } #[derive(Debug)] struct CompletedMcpToolCallWithImageOutput { _image: DynamicImage, } impl HistoryCell for CompletedMcpToolCallWithImageOutput { fn display_lines(&self, _width: u16) -> Vec> { vec!["tool result (image output omitted)".into()] } } pub(crate) const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value pub(crate) fn card_inner_width(width: u16, max_inner_width: usize) -> Option { if width < 4 { return None; } let inner_width = std::cmp::min(width.saturating_sub(4) as usize, max_inner_width); Some(inner_width) } /// Render `lines` inside a border sized to the widest span in the content. pub(crate) fn with_border(lines: Vec>) -> Vec> { with_border_internal(lines, None) } /// Render `lines` inside a border whose inner width is at least `inner_width`. /// /// This is useful when callers have already clamped their content to a /// specific width and want the border math centralized here instead of /// duplicating padding logic in the TUI widgets themselves. pub(crate) fn with_border_with_inner_width( lines: Vec>, inner_width: usize, ) -> Vec> { with_border_internal(lines, Some(inner_width)) } fn with_border_internal( lines: Vec>, forced_inner_width: Option, ) -> Vec> { let max_line_width = lines .iter() .map(|line| { line.iter() .map(|span| UnicodeWidthStr::width(span.content.as_ref())) .sum::() }) .max() .unwrap_or(0); let content_width = forced_inner_width .unwrap_or(max_line_width) .max(max_line_width); let mut out = Vec::with_capacity(lines.len() + 2); let border_inner_width = content_width + 2; out.push(vec![format!("╭{}╮", "─".repeat(border_inner_width)).dim()].into()); for line in lines.into_iter() { let used_width: usize = line .iter() .map(|span| UnicodeWidthStr::width(span.content.as_ref())) .sum(); let span_count = line.spans.len(); let mut spans: Vec> = Vec::with_capacity(span_count + 4); spans.push(Span::from("│ ").dim()); spans.extend(line.into_iter()); if used_width < content_width { spans.push(Span::from(" ".repeat(content_width - used_width)).dim()); } spans.push(Span::from(" │").dim()); out.push(Line::from(spans)); } out.push(vec![format!("╰{}╯", "─".repeat(border_inner_width)).dim()].into()); out } /// Return the emoji followed by a hair space (U+200A). /// Using only the hair space avoids excessive padding after the emoji while /// still providing a small visual gap across terminals. pub(crate) fn padded_emoji(emoji: &str) -> String { format!("{emoji}\u{200A}") } pub(crate) fn new_session_info( config: &Config, event: SessionConfiguredEvent, is_first_event: bool, ) -> CompositeHistoryCell { let SessionConfiguredEvent { model, reasoning_effort, session_id: _, history_log_id: _, history_entry_count: _, initial_messages: _, rollout_path: _, } = event; if is_first_event { // Header box rendered as history (so it appears at the very top) let header = SessionHeaderHistoryCell::new( model, reasoning_effort, config.cwd.clone(), crate::version::CODEX_CLI_VERSION, ); // Help lines below the header (new copy and list) let help_lines: Vec> = vec![ " To get started, describe a task or try one of these commands:" .dim() .into(), Line::from(""), Line::from(vec![ " ".into(), "/init".into(), " - create an AGENTS.md file with instructions for Codex".dim(), ]), Line::from(vec![ " ".into(), "/status".into(), " - show current session configuration".dim(), ]), Line::from(vec![ " ".into(), "/approvals".into(), " - choose what Codex can do without approval".dim(), ]), Line::from(vec![ " ".into(), "/model".into(), " - choose what model and reasoning effort to use".dim(), ]), Line::from(vec![ " ".into(), "/review".into(), " - review any changes and find issues".dim(), ]), ]; CompositeHistoryCell { parts: vec![ Box::new(header), Box::new(PlainHistoryCell { lines: help_lines }), ], } } else if config.model == model { CompositeHistoryCell { parts: vec![] } } else { let lines = vec![ "model changed:".magenta().bold().into(), format!("requested: {}", config.model).into(), format!("used: {model}").into(), ]; CompositeHistoryCell { parts: vec![Box::new(PlainHistoryCell { lines })], } } } pub(crate) fn new_user_prompt(message: String) -> UserHistoryCell { UserHistoryCell { message } } #[derive(Debug)] struct SessionHeaderHistoryCell { version: &'static str, model: String, reasoning_effort: Option, directory: PathBuf, } impl SessionHeaderHistoryCell { fn new( model: String, reasoning_effort: Option, directory: PathBuf, version: &'static str, ) -> Self { Self { version, model, reasoning_effort, directory, } } fn format_directory(&self, max_width: Option) -> String { Self::format_directory_inner(&self.directory, max_width) } fn format_directory_inner(directory: &Path, max_width: Option) -> String { let formatted = if let Some(rel) = relativize_to_home(directory) { if rel.as_os_str().is_empty() { "~".to_string() } else { format!("~{}{}", std::path::MAIN_SEPARATOR, rel.display()) } } else { directory.display().to_string() }; if let Some(max_width) = max_width { if max_width == 0 { return String::new(); } if UnicodeWidthStr::width(formatted.as_str()) > max_width { return crate::text_formatting::center_truncate_path(&formatted, max_width); } } formatted } fn reasoning_label(&self) -> Option<&'static str> { self.reasoning_effort.map(|effort| match effort { ReasoningEffortConfig::Minimal => "minimal", ReasoningEffortConfig::Low => "low", ReasoningEffortConfig::Medium => "medium", ReasoningEffortConfig::High => "high", }) } } impl HistoryCell for SessionHeaderHistoryCell { fn display_lines(&self, width: u16) -> Vec> { let Some(inner_width) = card_inner_width(width, SESSION_HEADER_MAX_INNER_WIDTH) else { return Vec::new(); }; let make_row = |spans: Vec>| Line::from(spans); // Title line rendered inside the box: ">_ OpenAI Codex (vX)" let title_spans: Vec> = vec![ Span::from(">_ ").dim(), Span::from("OpenAI Codex").bold(), Span::from(" ").dim(), Span::from(format!("(v{})", self.version)).dim(), ]; const CHANGE_MODEL_HINT_COMMAND: &str = "/model"; const CHANGE_MODEL_HINT_EXPLANATION: &str = " to change"; const DIR_LABEL: &str = "directory:"; let label_width = DIR_LABEL.len(); let model_label = format!( "{model_label:> = vec![ Span::from(format!("{model_label} ")).dim(), Span::from(self.model.clone()), ]; if let Some(reasoning) = reasoning_label { model_spans.push(Span::from(" ")); model_spans.push(Span::from(reasoning)); } model_spans.push(" ".dim()); model_spans.push(CHANGE_MODEL_HINT_COMMAND.cyan()); model_spans.push(CHANGE_MODEL_HINT_EXPLANATION.dim()); let dir_label = format!("{DIR_LABEL:>, } impl CompositeHistoryCell { pub(crate) fn new(parts: Vec>) -> Self { Self { parts } } } impl HistoryCell for CompositeHistoryCell { fn display_lines(&self, width: u16) -> Vec> { let mut out: Vec> = Vec::new(); let mut first = true; for part in &self.parts { let mut lines = part.display_lines(width); if !lines.is_empty() { if !first { out.push(Line::from("")); } out.append(&mut lines); first = false; } } out } } #[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(_) => "