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::render::line_utils::line_to_static; use crate::render::line_utils::prefix_lines; use crate::render::line_utils::push_owned_lines; use crate::slash_command::SlashCommand; use crate::text_formatting::format_and_truncate_tool_result; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; use crate::wrapping::word_wrap_lines; 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::auth::get_auth_file; use codex_core::auth::try_read_auth_json; use codex_core::config::Config; use codex_core::config_types::ReasoningSummaryFormat; 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_protocol::mcp_protocol::ConversationId; use codex_protocol::num_format::format_with_separators; use codex_protocol::parse_command::ParsedCommand; use image::DynamicImage; use image::ImageReader; use itertools::Itertools; use mcp_types::EmbeddedResourceResource; use mcp_types::ResourceLink; 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::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; #[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, } #[derive(Clone, Debug)] 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, width: u16) -> Vec>; fn transcript_lines(&self) -> Vec> { self.display_lines(u16::MAX) } 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 is_stream_continuation(&self) -> bool { false } } #[derive(Debug)] pub(crate) struct UserHistoryCell { message: String, } impl HistoryCell for UserHistoryCell { fn display_lines(&self, width: u16) -> Vec> { let mut lines: Vec> = Vec::new(); // Wrap the content first, then prefix each wrapped line with the marker. let wrap_width = width.saturating_sub(1); // account for the ▌ prefix let wrapped = textwrap::wrap( &self.message, textwrap::Options::new(wrap_width as usize) .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), // Match textarea wrap ); for line in wrapped { lines.push(vec!["▌".cyan().dim(), line.to_string().dim()].into()); } lines } fn transcript_lines(&self) -> Vec> { let mut lines: Vec> = Vec::new(); lines.push("user".cyan().bold().into()); lines.extend(self.message.lines().map(|l| l.to_string().into())); lines } } #[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 { "> ".into() } else { " ".into() }) .subsequent_indent(" ".into()), ) } fn transcript_lines(&self) -> Vec> { let mut out: Vec> = Vec::new(); if self.is_first_line { out.push("codex".magenta().bold().into()); } out.extend(self.lines.clone()); out } fn is_stream_continuation(&self) -> bool { !self.is_first_line } } #[derive(Debug)] pub(crate) struct PlainHistoryCell { lines: Vec>, } impl HistoryCell for PlainHistoryCell { fn display_lines(&self, _width: u16) -> Vec> { self.lines.clone() } } #[derive(Debug)] pub(crate) struct TranscriptOnlyHistoryCell { lines: Vec>, } impl HistoryCell for TranscriptOnlyHistoryCell { fn display_lines(&self, _width: u16) -> Vec> { Vec::new() } fn transcript_lines(&self) -> Vec> { self.lines.clone() } } #[derive(Debug)] pub(crate) struct PatchHistoryCell { event_type: PatchEventType, changes: HashMap, cwd: PathBuf, } impl HistoryCell for PatchHistoryCell { fn display_lines(&self, width: u16) -> Vec> { create_diff_summary( &self.changes, self.event_type.clone(), &self.cwd, width as usize, ) } } #[derive(Debug, Clone)] pub(crate) struct ExecCall { pub(crate) call_id: String, pub(crate) command: Vec, pub(crate) parsed: Vec, pub(crate) output: Option, start_time: Option, duration: Option, } #[derive(Debug)] pub(crate) struct ExecCell { calls: Vec, } impl HistoryCell for ExecCell { fn display_lines(&self, width: u16) -> Vec> { if self.is_exploring_cell() { self.exploring_display_lines(width) } else { self.command_display_lines(width) } } fn transcript_lines(&self) -> Vec> { let mut lines: Vec> = vec![]; for call in &self.calls { let cmd_display = strip_bash_lc_and_escape(&call.command); for (i, part) in cmd_display.lines().enumerate() { if i == 0 { lines.push(vec!["$ ".magenta(), part.to_string().into()].into()); } else { lines.push(vec![" ".into(), part.to_string().into()].into()); } } if let Some(output) = call.output.as_ref() { lines.extend(output.formatted_output.lines().map(ansi_escape_line)); let duration = call .duration .map(format_duration) .unwrap_or_else(|| "unknown".to_string()); let mut result: Line = 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.push("".into()); } lines } } impl ExecCell { fn is_active(&self) -> bool { self.calls.iter().any(|c| c.output.is_none()) } fn exploring_display_lines(&self, width: u16) -> Vec> { let mut out: Vec> = Vec::new(); let active_start_time = self .calls .iter() .find(|c| c.output.is_none()) .and_then(|c| c.start_time); out.push(Line::from(vec![ if self.is_active() { // Show an animated spinner while exploring spinner(active_start_time) } else { "•".bold() }, " ".into(), if self.is_active() { "Exploring".bold() } else { "Explored".bold() }, ])); let mut calls = self.calls.clone(); let mut out_indented = Vec::new(); while !calls.is_empty() { let mut call = calls.remove(0); if call .parsed .iter() .all(|c| matches!(c, ParsedCommand::Read { .. })) { while let Some(next) = calls.first() { if next .parsed .iter() .all(|c| matches!(c, ParsedCommand::Read { .. })) { call.parsed.extend(next.parsed.clone()); calls.remove(0); } else { break; } } } let call_lines: Vec<(&str, Vec>)> = if call .parsed .iter() .all(|c| matches!(c, ParsedCommand::Read { .. })) { let names: Vec = call .parsed .iter() .map(|c| match c { ParsedCommand::Read { name, .. } => name.clone(), _ => unreachable!(), }) .unique() .collect(); vec![( "Read", itertools::Itertools::intersperse( names.into_iter().map(|n| n.into()), ", ".dim(), ) .collect(), )] } else { let mut lines = Vec::new(); for p in call.parsed { match p { ParsedCommand::Read { name, .. } => { lines.push(("Read", vec![name.into()])); } ParsedCommand::ListFiles { cmd, path } => { lines.push(("List", vec![path.unwrap_or(cmd).into()])); } ParsedCommand::Search { cmd, query, path } => { lines.push(( "Search", match (query, path) { (Some(q), Some(p)) => { vec![q.into(), " in ".dim(), p.into()] } (Some(q), None) => vec![q.into()], _ => vec![cmd.into()], }, )); } ParsedCommand::Unknown { cmd } => { lines.push(("Run", vec![cmd.into()])); } } } lines }; for (title, line) in call_lines { let line = Line::from(line); let initial_indent = Line::from(vec![title.cyan(), " ".into()]); let subsequent_indent = " ".repeat(initial_indent.width()).into(); let wrapped = word_wrap_line( &line, RtOptions::new(width as usize) .initial_indent(initial_indent) .subsequent_indent(subsequent_indent), ); push_owned_lines(&wrapped, &mut out_indented); } } out.extend(prefix_lines(out_indented, " └ ".dim(), " ".into())); out } fn command_display_lines(&self, width: u16) -> Vec> { use textwrap::Options as TwOptions; let mut lines: Vec> = Vec::new(); let [call] = &self.calls.as_slice() else { panic!("Expected exactly one call in a command display cell"); }; let success = call.output.as_ref().map(|o| o.exit_code == 0); let bullet = match success { Some(true) => "•".green().bold(), Some(false) => "•".red().bold(), None => spinner(call.start_time), }; let title = if self.is_active() { "Running" } else { "Ran" }; let cmd_display = strip_bash_lc_and_escape(&call.command); // If the command fits on the same line as the header at the current width, // show a single compact line: "• Ran ". Use the width of // "• Running " (including trailing space) as the reserved prefix width. // If the command contains newlines, always use the multi-line variant. let reserved = "• Running ".width(); let mut body_lines: Vec> = Vec::new(); let highlighted_lines = crate::render::highlight::highlight_bash_to_lines(&cmd_display); if highlighted_lines.len() == 1 && highlighted_lines[0].width() < (width as usize).saturating_sub(reserved) { let mut line = Line::from(vec![bullet, " ".into(), title.bold(), " ".into()]); line.extend(highlighted_lines[0].clone()); lines.push(line); } else { lines.push(vec![bullet, " ".into(), title.bold()].into()); for hl_line in highlighted_lines.iter() { let opts = crate::wrapping::RtOptions::new((width as usize).saturating_sub(4)) .initial_indent("".into()) .subsequent_indent(" ".into()) // Hyphenation likes to break words on hyphens, which is bad for bash scripts --because-of-flags. .word_splitter(textwrap::WordSplitter::NoHyphenation); let wrapped_borrowed = crate::wrapping::word_wrap_line(hl_line, opts); body_lines.extend(wrapped_borrowed.iter().map(|l| line_to_static(l))); } } if let Some(output) = call.output.as_ref() && output.exit_code != 0 { let out = output_lines(Some(output), false, false, false) .into_iter() .join("\n"); if !out.trim().is_empty() { // Wrap the output. for line in out.lines() { let wrapped = textwrap::wrap(line, TwOptions::new(width as usize - 4)); body_lines.extend(wrapped.into_iter().map(|l| Line::from(l.to_string().dim()))); } } } lines.extend(prefix_lines(body_lines, " └ ".dim(), " ".into())); 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, }; let lines = self.display_lines(area.width); let max_rows = area.height as usize; let rendered = if lines.len() > max_rows { // Keep the last `max_rows` lines in original order lines[lines.len() - max_rows..].to_vec() } else { lines }; Paragraph::new(Text::from(rendered)) .wrap(Wrap { trim: false }) .render(content_area, buf); } } 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 { for call in self.calls.iter_mut() { if call.output.is_none() { let elapsed = call .start_time .map(|st| st.elapsed()) .unwrap_or_else(|| Duration::from_millis(0)); call.start_time = None; call.duration = Some(elapsed); call.output = Some(CommandOutput { exit_code: 1, stdout: String::new(), stderr: String::new(), formatted_output: String::new(), }); } } self } pub(crate) fn new(call: ExecCall) -> Self { ExecCell { calls: vec![call] } } fn is_exploring_call(call: &ExecCall) -> bool { !call.parsed.is_empty() && call.parsed.iter().all(|p| { matches!( p, ParsedCommand::Read { .. } | ParsedCommand::ListFiles { .. } | ParsedCommand::Search { .. } ) }) } fn is_exploring_cell(&self) -> bool { self.calls.iter().all(Self::is_exploring_call) } pub(crate) fn with_added_call( &self, call_id: String, command: Vec, parsed: Vec, ) -> Option { let call = ExecCall { call_id, command, parsed, output: None, start_time: Some(Instant::now()), duration: None, }; if self.is_exploring_cell() && Self::is_exploring_call(&call) { Some(Self { calls: [self.calls.clone(), vec![call]].concat(), }) } else { None } } pub(crate) fn complete_call( &mut self, call_id: &str, output: CommandOutput, duration: Duration, ) { if let Some(call) = self.calls.iter_mut().rev().find(|c| c.call_id == call_id) { call.output = Some(output); call.duration = Some(duration); call.start_time = None; } } pub(crate) fn should_flush(&self) -> bool { !self.is_exploring_cell() && self.calls.iter().all(|c| c.output.is_some()) } } #[derive(Debug)] struct CompletedMcpToolCallWithImageOutput { _image: DynamicImage, } impl HistoryCell for CompletedMcpToolCallWithImageOutput { fn display_lines(&self, _width: u16) -> Vec> { vec!["tool result (image output omitted)".into()] } } 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). /// Using only the hair space avoids excessive padding after the emoji while /// still providing a small visual gap across terminals. fn padded_emoji(emoji: &str) -> String { format!("{emoji}\u{200A}") } 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: _, initial_messages: _, rollout_path: _, } = 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(), }; // Discover AGENTS.md files to decide whether to suggest `/init`. let has_agents_md = discover_project_doc_paths(config) .map(|v| !v.is_empty()) .unwrap_or(false); let mut lines: Vec> = Vec::new(); lines.push(Line::from(vec![ ">_ ".dim(), "You are using OpenAI Codex in".bold(), format!(" {cwd_str}").dim(), ])); lines.push(Line::from("".dim())); lines.push(Line::from( " To get started, describe a task or try one of these commands:".dim(), )); lines.push(Line::from("".dim())); if !has_agents_md { lines.push(Line::from(vec![ " /init".bold(), format!(" - {}", SlashCommand::Init.description()).dim(), ])); } lines.push(Line::from(vec![ " /status".bold(), format!(" - {}", SlashCommand::Status.description()).dim(), ])); lines.push(Line::from(vec![ " /approvals".bold(), format!(" - {}", SlashCommand::Approvals.description()).dim(), ])); lines.push(Line::from(vec![ " /model".bold(), format!(" - {}", SlashCommand::Model.description()).dim(), ])); PlainHistoryCell { lines } } else if config.model == model { PlainHistoryCell { lines: Vec::new() } } else { let lines = vec![ "model changed:".magenta().bold().into(), format!("requested: {}", config.model).into(), format!("used: {model}").into(), ]; PlainHistoryCell { lines } } } pub(crate) fn new_user_prompt(message: String) -> UserHistoryCell { UserHistoryCell { message } } pub(crate) fn new_user_approval_decision(lines: Vec>) -> PlainHistoryCell { PlainHistoryCell { lines } } pub(crate) fn new_active_exec_command( call_id: String, command: Vec, parsed: Vec, ) -> ExecCell { ExecCell::new(ExecCall { call_id, command, parsed, output: None, start_time: Some(Instant::now()), duration: None, }) } fn spinner(start_time: Option) -> Span<'static> { 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]; ch.to_string().into() } 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![title_line, format_mcp_invocation(invocation)]; PlainHistoryCell { lines } } pub(crate) fn new_web_search_call(query: String) -> PlainHistoryCell { let lines: Vec> = vec![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(_) => "