use crate::diff_render::create_diff_summary; use crate::exec_command::relativize_to_home; use crate::exec_command::strip_bash_lc_and_escape; use crate::markdown::append_markdown; use crate::slash_command::SlashCommand; 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::project_doc::discover_project_doc_paths; 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 codex_protocol::parse_command::ParsedCommand; 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::style::Stylize; 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 std::time::Instant; use tracing::error; use uuid::Uuid; #[derive(Clone, Debug)] pub(crate) struct CommandOutput { pub(crate) exit_code: i32, pub(crate) stdout: String, pub(crate) stderr: String, pub(crate) formatted_output: String, } 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) trait HistoryCell: std::fmt::Debug + Send + Sync { fn display_lines(&self) -> Vec>; fn transcript_lines(&self) -> Vec> { self.display_lines() } fn desired_height(&self, width: u16) -> u16 { Paragraph::new(Text::from(self.display_lines())) .wrap(Wrap { trim: false }) .line_count(width) .try_into() .unwrap_or(0) } } #[derive(Debug)] pub(crate) struct PlainHistoryCell { lines: Vec>, } impl HistoryCell for PlainHistoryCell { fn display_lines(&self) -> Vec> { self.lines.clone() } } #[derive(Debug)] pub(crate) struct TranscriptOnlyHistoryCell { lines: Vec>, } impl HistoryCell for TranscriptOnlyHistoryCell { fn display_lines(&self) -> Vec> { Vec::new() } fn transcript_lines(&self) -> Vec> { self.lines.clone() } } #[derive(Debug)] pub(crate) struct ExecCell { pub(crate) command: Vec, pub(crate) parsed: Vec, pub(crate) output: Option, start_time: Option, duration: Option, include_header: bool, } impl HistoryCell for ExecCell { fn display_lines(&self) -> Vec> { exec_command_lines( &self.command, &self.parsed, self.output.as_ref(), self.start_time, self.include_header, ) } fn transcript_lines(&self) -> Vec> { let mut lines: Vec> = vec!["".into()]; let cmd_display = strip_bash_lc_and_escape(&self.command); for (i, part) in cmd_display.lines().enumerate() { if i == 0 { lines.push(Line::from(vec!["$ ".magenta(), part.to_string().into()])); } else { lines.push(Line::from(vec![" ".into(), part.to_string().into()])); } } // Command output: include full stdout and stderr (no truncation) if let Some(output) = self.output.as_ref() { lines.extend(output.formatted_output.lines().map(ansi_escape_line)); } if let Some(output) = self.output.as_ref() { let duration = self .duration .map(format_duration) .unwrap_or_else(|| "unknown".to_string()); let mut result = if output.exit_code == 0 { Line::from("✓".green().bold()) } else { Line::from(vec![ "✗".red().bold(), format!(" ({})", output.exit_code).into(), ]) }; result.push_span(format!(" • {duration}").dim()); lines.push(result); } lines } } impl WidgetRef for &ExecCell { fn render_ref(&self, area: Rect, buf: &mut Buffer) { if area.height == 0 { return; } let content_area = Rect { x: area.x, y: area.y, width: area.width, height: area.height, }; Paragraph::new(Text::from(self.display_lines())) .wrap(Wrap { trim: false }) .render(content_area, buf); } } impl ExecCell { /// Convert an active exec cell into a failed, completed exec cell. /// Replaces the spinner with a red ✗ and sets a zero/elapsed duration. pub(crate) fn into_failed(mut self) -> ExecCell { let elapsed = self .start_time .map(|st| st.elapsed()) .unwrap_or_else(|| Duration::from_millis(0)); self.start_time = None; self.duration = Some(elapsed); self.output = Some(CommandOutput { exit_code: 1, stdout: String::new(), stderr: String::new(), formatted_output: String::new(), }); self } } #[derive(Debug)] struct CompletedMcpToolCallWithImageOutput { _image: DynamicImage, } impl HistoryCell for CompletedMcpToolCallWithImageOutput { fn display_lines(&self) -> Vec> { vec![ Line::from(""), Line::from("tool result (image output omitted)"), ] } } const TOOL_CALL_MAX_LINES: usize = 5; 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) } } /// Return the emoji followed by a hair space (U+200A) and a normal space. /// This creates a reasonable gap across different terminals, /// in particular Terminal.app and iTerm, which render too tightly with just a single normal space. /// /// Improvements here could be to condition this behavior on terminal, /// or possibly on emoji. fn padded_emoji(emoji: &str) -> String { format!("{emoji}\u{200A} ") } /// Convenience function over `padded_emoji()`. fn padded_emoji_with(emoji: &str, text: impl AsRef) -> String { format!("{}{}", padded_emoji(emoji), text.as_ref()) } pub(crate) fn new_session_info( config: &Config, event: SessionConfiguredEvent, is_first_event: bool, ) -> PlainHistoryCell { 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() => { let sep = std::path::MAIN_SEPARATOR; format!("~{sep}{}", rel.display()) } Some(_) => "~".to_string(), None => config.cwd.display().to_string(), }; let lines: Vec> = vec![ Line::from(Span::from("")), 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!(" /approvals - {}", SlashCommand::Approvals.description()).dim()), Line::from(format!(" /model - {}", SlashCommand::Model.description()).dim()), ]; PlainHistoryCell { lines } } else if config.model == model { PlainHistoryCell { lines: Vec::new() } } else { let lines = vec![ Line::from(""), Line::from("model changed:".magenta().bold()), Line::from(format!("requested: {}", config.model)), Line::from(format!("used: {model}")), ]; PlainHistoryCell { lines } } } pub(crate) fn new_user_prompt(message: String) -> PlainHistoryCell { let mut lines: Vec> = Vec::new(); lines.push(Line::from("")); lines.push(Line::from("user".cyan().bold())); lines.extend(message.lines().map(|l| Line::from(l.to_string()))); PlainHistoryCell { lines } } pub(crate) fn new_active_exec_command( command: Vec, parsed: Vec, include_header: bool, ) -> ExecCell { ExecCell { command, parsed, output: None, start_time: Some(Instant::now()), duration: None, include_header, } } pub(crate) fn new_completed_exec_command( command: Vec, parsed: Vec, output: CommandOutput, include_header: bool, duration: Duration, ) -> ExecCell { ExecCell { command, parsed, output: Some(output), start_time: None, duration: Some(duration), include_header, } } fn exec_command_lines( command: &[String], parsed: &[ParsedCommand], output: Option<&CommandOutput>, start_time: Option, include_header: bool, ) -> Vec> { match parsed.is_empty() { true => new_exec_command_generic(command, output, start_time, include_header), false => new_parsed_command(command, parsed, output, start_time, include_header), } } fn new_parsed_command( _command: &[String], parsed_commands: &[ParsedCommand], output: Option<&CommandOutput>, start_time: Option, include_header: bool, ) -> Vec> { let mut lines: Vec = Vec::new(); // Leading spacer and header line above command list if include_header { lines.push(Line::from("")); lines.push(Line::from(">_".magenta())); } // Determine the leading status marker: spinner while running, ✓ on success, ✗ on failure. let status_marker: Span<'static> = match output { None => { // Animated braille spinner – choose frame based on elapsed time. const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; let idx = start_time .map(|st| ((st.elapsed().as_millis() / 100) as usize) % FRAMES.len()) .unwrap_or(0); let ch = FRAMES[idx]; Span::raw(format!("{ch}")) } Some(o) if o.exit_code == 0 => Span::styled("✓", Style::default().fg(Color::Green)), Some(_) => Span::styled("✗", Style::default().fg(Color::Red)), }; for parsed in parsed_commands.iter() { let text = match parsed { ParsedCommand::Read { name, .. } => padded_emoji_with("📖", name), ParsedCommand::ListFiles { cmd, path } => match path { Some(p) => padded_emoji_with("📂", p), None => padded_emoji_with("📂", cmd), }, ParsedCommand::Search { query, path, cmd } => match (query, path) { (Some(q), Some(p)) => padded_emoji_with("🔎", format!("{q} in {p}")), (Some(q), None) => padded_emoji_with("🔎", q), (None, Some(p)) => padded_emoji_with("🔎", p), (None, None) => padded_emoji_with("🔎", cmd), }, ParsedCommand::Format { .. } => padded_emoji_with("✨", "Formatting"), ParsedCommand::Test { cmd } => padded_emoji_with("🧪", cmd), ParsedCommand::Lint { cmd, .. } => padded_emoji_with("🧹", cmd), ParsedCommand::Unknown { cmd } => padded_emoji_with("⌨️", cmd), ParsedCommand::Noop { cmd } => padded_emoji_with("🔄", cmd), }; // Prefix: two spaces, marker, space. Continuations align under the text block. for (j, line_text) in text.lines().enumerate() { if j == 0 { lines.push(Line::from(vec![ " ".into(), status_marker.clone(), " ".into(), line_text.to_string().light_blue(), ])); } else { lines.push(Line::from(vec![ " ".into(), line_text.to_string().light_blue(), ])); } } } lines.extend(output_lines(output, true, false)); lines } fn new_exec_command_generic( command: &[String], output: Option<&CommandOutput>, start_time: Option, include_header: bool, ) -> Vec> { let mut lines: Vec> = Vec::new(); // Leading spacer and header line above command list if include_header { lines.push(Line::from("")); lines.push(Line::from(">_".magenta())); } let command_escaped = strip_bash_lc_and_escape(command); // Determine marker: spinner while running, ✓/✗ when completed let status_marker: Span<'static> = match output { None => { const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; let idx = start_time .map(|st| ((st.elapsed().as_millis() / 100) as usize) % FRAMES.len()) .unwrap_or(0); let ch = FRAMES[idx]; Span::raw(format!("{ch}")) } Some(o) if o.exit_code == 0 => Span::styled("✓", Style::default().fg(Color::Green)), Some(_) => Span::styled("✗", Style::default().fg(Color::Red)), }; for (i, line) in command_escaped.lines().enumerate() { if i == 0 { lines.push(Line::from(vec![ Span::raw(" "), status_marker.clone(), Span::raw(" "), Span::raw(line.to_string()), ])); } else { lines.push(Line::from(vec![ Span::styled(" ", Style::default().add_modifier(Modifier::DIM)), Span::raw(line.to_string()), ])); } } lines.extend(output_lines(output, false, true)); lines } pub(crate) fn new_active_mcp_tool_call(invocation: McpInvocation) -> PlainHistoryCell { let title_line = Line::from(vec!["tool".magenta(), " running...".dim()]); let lines: Vec = vec![ Line::from(""), title_line, format_mcp_invocation(invocation.clone()), ]; PlainHistoryCell { lines } } pub(crate) fn new_web_search_call(query: String) -> PlainHistoryCell { let lines: Vec> = vec![ Line::from(""), Line::from(vec![padded_emoji("🌐").into(), query.into()]), ]; PlainHistoryCell { 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(CompletedMcpToolCallWithImageOutput { _image: image }) } else { None } } _ => None, } } pub(crate) fn new_completed_mcp_tool_call( num_cols: usize, invocation: McpInvocation, duration: Duration, success: bool, result: Result, ) -> Box { if let Some(cell) = try_new_completed_mcp_tool_call_with_image_output(&result) { return Box::new(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}").dim(), ]); 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, ) } 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(_) => "