//! Transcript/history cells for the Codex TUI. //! //! A `HistoryCell` is the unit of display in the conversation UI, representing both committed //! transcript entries and, transiently, an in-flight active cell that can mutate in place while //! streaming. //! //! The transcript overlay (`Ctrl+T`) appends a cached live tail derived from the active cell, and //! that cached tail is refreshed based on an active-cell cache key. Cells that change based on //! elapsed time expose `transcript_animation_tick()`, and code that mutates the active cell in place //! bumps the active-cell revision tracked by `ChatWidget`, so the cache key changes whenever the //! rendered transcript output can change. use crate::chatwidget::DEFAULT_MODEL_DISPLAY_NAME; 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::live_wrap::take_prefix_by_width; 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::render::renderable::Renderable; use crate::style::proposed_plan_style; use crate::style::user_message_style; use crate::text_formatting::format_and_truncate_tool_result; use crate::text_formatting::truncate_text; use crate::tooltips; use crate::ui_consts::LIVE_PREFIX_COLS; use crate::update_action::UpdateAction; use crate::version::CODEX_CLI_VERSION; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_line; use crate::wrapping::adaptive_wrap_lines; use base64::Engine; use codex_core::config::Config; use codex_core::config::types::McpServerTransportConfig; use codex_core::web_search::web_search_detail; use codex_otel::RuntimeMetricsSummary; use codex_protocol::account::PlanType; use codex_protocol::mcp::Resource; use codex_protocol::mcp::ResourceTemplate; use codex_protocol::models::WebSearchAction; use codex_protocol::models::local_image_label_text; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::McpAuthStatus; use codex_protocol::protocol::McpInvocation; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputQuestion; use codex_protocol::user_input::TextElement; use codex_utils_cli::format_env_display::format_env_display; use image::DynamicImage; use image::ImageReader; use ratatui::prelude::*; use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::style::Styled; use ratatui::style::Stylize; use ratatui::widgets::Paragraph; 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_segmentation::UnicodeSegmentation; 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. /// A single renderable unit of conversation history. /// /// Each cell produces logical `Line`s and reports how many viewport /// rows those lines occupy at a given terminal width. The default /// height implementations use `Paragraph::wrap` to account for lines /// that overflow the viewport width (e.g. long URLs that are kept /// intact by adaptive wrapping). Concrete types only need to override /// heights when they apply additional layout logic beyond what /// `Paragraph::line_count` captures. pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { /// Returns the logical lines for the main chat viewport. fn display_lines(&self, width: u16) -> Vec>; /// Returns the number of viewport rows needed to render this cell. /// /// The default delegates to `Paragraph::line_count` with /// `Wrap { trim: false }`, which measures the actual row count after /// ratatui's viewport-level character wrapping. This is critical /// for lines containing URL-like tokens that are wider than the /// terminal — the logical line count would undercount. 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) } /// Returns lines for the transcript overlay (`Ctrl+T`). /// /// Defaults to `display_lines`. Override when the transcript /// representation differs (e.g. `ExecCell` shows all calls with /// `$`-prefixed commands and exit status). fn transcript_lines(&self, width: u16) -> Vec> { self.display_lines(width) } /// Returns the number of viewport rows for the transcript overlay. /// /// Uses the same `Paragraph::line_count` measurement as /// `desired_height`. Contains a workaround for a ratatui bug where /// a single whitespace-only line reports 2 rows instead of 1. fn desired_transcript_height(&self, width: u16) -> u16 { let lines = self.transcript_lines(width); // Workaround: ratatui's line_count returns 2 for a single // whitespace-only line. Clamp to 1 in that case. 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 } /// Returns a coarse "animation tick" when transcript output is time-dependent. /// /// The transcript overlay caches the rendered output of the in-flight active cell, so cells /// that include time-based UI (spinner, shimmer, etc.) should return a tick that changes over /// time to signal that the cached tail should be recomputed. Returning `None` means the /// transcript lines are stable, while returning `Some(tick)` during an in-flight animation /// allows the overlay to keep up with the main viewport. /// /// If a cell uses time-based visuals but always returns `None`, `Ctrl+T` can appear "frozen" on /// the first rendered frame even though the main viewport is animating. fn transcript_animation_tick(&self) -> Option { None } } impl Renderable for Box { fn render(&self, area: Rect, buf: &mut Buffer) { let lines = self.display_lines(area.width); let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); let y = if area.height == 0 { 0 } else { let overflow = paragraph .line_count(area.width) .saturating_sub(usize::from(area.height)); u16::try_from(overflow).unwrap_or(u16::MAX) }; paragraph.scroll((y, 0)).render(area, buf); } fn desired_height(&self, width: u16) -> u16 { HistoryCell::desired_height(self.as_ref(), width) } } 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, pub text_elements: Vec, #[allow(dead_code)] pub local_image_paths: Vec, pub remote_image_urls: Vec, } /// Build logical lines for a user message with styled text elements. /// /// This preserves explicit newlines while interleaving element spans and skips /// malformed byte ranges instead of panicking during history rendering. fn build_user_message_lines_with_elements( message: &str, elements: &[TextElement], style: Style, element_style: Style, ) -> Vec> { let mut elements = elements.to_vec(); elements.sort_by_key(|e| e.byte_range.start); let mut offset = 0usize; let mut raw_lines: Vec> = Vec::new(); for line_text in message.split('\n') { let line_start = offset; let line_end = line_start + line_text.len(); let mut spans: Vec> = Vec::new(); // Track how much of the line we've emitted to interleave plain and styled spans. let mut cursor = line_start; for elem in &elements { let start = elem.byte_range.start.max(line_start); let end = elem.byte_range.end.min(line_end); if start >= end { continue; } let rel_start = start - line_start; let rel_end = end - line_start; // Guard against malformed UTF-8 byte ranges from upstream data; skip // invalid elements rather than panicking while rendering history. if !line_text.is_char_boundary(rel_start) || !line_text.is_char_boundary(rel_end) { continue; } let rel_cursor = cursor - line_start; if cursor < start && line_text.is_char_boundary(rel_cursor) && let Some(segment) = line_text.get(rel_cursor..rel_start) { spans.push(Span::from(segment.to_string())); } if let Some(segment) = line_text.get(rel_start..rel_end) { spans.push(Span::styled(segment.to_string(), element_style)); cursor = end; } } let rel_cursor = cursor - line_start; if cursor < line_end && line_text.is_char_boundary(rel_cursor) && let Some(segment) = line_text.get(rel_cursor..) { spans.push(Span::from(segment.to_string())); } let line = if spans.is_empty() { Line::from(line_text.to_string()).style(style) } else { Line::from(spans).style(style) }; raw_lines.push(line); // Split on '\n' so any '\r' stays in the line; advancing by 1 accounts // for the separator byte. offset = line_end + 1; } raw_lines } fn remote_image_display_line(style: Style, index: usize) -> Line<'static> { Line::from(local_image_label_text(index)).style(style) } fn trim_trailing_blank_lines(mut lines: Vec>) -> Vec> { while lines .last() .is_some_and(|line| line.spans.iter().all(|span| span.content.trim().is_empty())) { lines.pop(); } lines } impl HistoryCell for UserHistoryCell { fn display_lines(&self, width: u16) -> Vec> { 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 element_style = style.fg(Color::Cyan); let wrapped_remote_images = if self.remote_image_urls.is_empty() { None } else { Some(adaptive_wrap_lines( self.remote_image_urls .iter() .enumerate() .map(|(idx, _url)| { remote_image_display_line(element_style, idx.saturating_add(1)) }), RtOptions::new(usize::from(wrap_width)) .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), )) }; let wrapped_message = if self.message.is_empty() && self.text_elements.is_empty() { None } else if self.text_elements.is_empty() { let message_without_trailing_newlines = self.message.trim_end_matches(['\r', '\n']); let wrapped = adaptive_wrap_lines( message_without_trailing_newlines .split('\n') .map(|line| Line::from(line).style(style)), // Wrap algorithm matches textarea.rs. RtOptions::new(usize::from(wrap_width)) .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), ); let wrapped = trim_trailing_blank_lines(wrapped); (!wrapped.is_empty()).then_some(wrapped) } else { let raw_lines = build_user_message_lines_with_elements( &self.message, &self.text_elements, style, element_style, ); let wrapped = adaptive_wrap_lines( raw_lines, RtOptions::new(usize::from(wrap_width)) .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), ); let wrapped = trim_trailing_blank_lines(wrapped); (!wrapped.is_empty()).then_some(wrapped) }; if wrapped_remote_images.is_none() && wrapped_message.is_none() { return Vec::new(); } let mut lines: Vec> = vec![Line::from("").style(style)]; if let Some(wrapped_remote_images) = wrapped_remote_images { lines.extend(prefix_lines( wrapped_remote_images, " ".into(), " ".into(), )); if wrapped_message.is_some() { lines.push(Line::from("").style(style)); } } if let Some(wrapped_message) = wrapped_message { lines.extend(prefix_lines( wrapped_message, "› ".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::>(); adaptive_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 transcript_lines(&self, width: u16) -> Vec> { self.lines(width) } } #[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> { adaptive_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()); adaptive_wrap_lines(&self.text, opts) } } #[derive(Debug)] pub(crate) struct UnifiedExecInteractionCell { command_display: Option, stdin: String, } impl UnifiedExecInteractionCell { pub(crate) fn new(command_display: Option, stdin: String) -> Self { Self { command_display, stdin, } } } impl HistoryCell for UnifiedExecInteractionCell { fn display_lines(&self, width: u16) -> Vec> { if width == 0 { return Vec::new(); } let wrap_width = width as usize; let waited_only = self.stdin.is_empty(); let mut header_spans = if waited_only { vec!["• Waited for background terminal".bold()] } else { vec!["↳ ".dim(), "Interacted with background terminal".bold()] }; if let Some(command) = &self.command_display && !command.is_empty() { header_spans.push(" · ".dim()); header_spans.push(command.clone().dim()); } let header = Line::from(header_spans); let mut out: Vec> = Vec::new(); let header_wrapped = adaptive_wrap_line(&header, RtOptions::new(wrap_width)); push_owned_lines(&header_wrapped, &mut out); if waited_only { return out; } let input_lines: Vec> = self .stdin .lines() .map(|line| Line::from(line.to_string())) .collect(); let input_wrapped = adaptive_wrap_lines( input_lines, RtOptions::new(wrap_width) .initial_indent(Line::from(" └ ".dim())) .subsequent_indent(Line::from(" ".dim())), ); out.extend(input_wrapped); out } } pub(crate) fn new_unified_exec_interaction( command_display: Option, stdin: String, ) -> UnifiedExecInteractionCell { UnifiedExecInteractionCell::new(command_display, stdin) } #[derive(Debug)] struct UnifiedExecProcessesCell { processes: Vec, } impl UnifiedExecProcessesCell { fn new(processes: Vec) -> Self { Self { processes } } } #[derive(Debug, Clone)] pub(crate) struct UnifiedExecProcessDetails { pub(crate) command_display: String, pub(crate) recent_chunks: Vec, } impl HistoryCell for UnifiedExecProcessesCell { fn display_lines(&self, width: u16) -> Vec> { if width == 0 { return Vec::new(); } let wrap_width = width as usize; let max_processes = 16usize; let mut out: Vec> = Vec::new(); out.push(vec!["Background terminals".bold()].into()); out.push("".into()); if self.processes.is_empty() { out.push(" • No background terminals running.".italic().into()); return out; } let prefix = " • "; let prefix_width = UnicodeWidthStr::width(prefix); let truncation_suffix = " [...]"; let truncation_suffix_width = UnicodeWidthStr::width(truncation_suffix); let mut shown = 0usize; for process in &self.processes { if shown >= max_processes { break; } let command = &process.command_display; let (snippet, snippet_truncated) = { let (first_line, has_more_lines) = match command.split_once('\n') { Some((first, _)) => (first, true), None => (command.as_str(), false), }; let max_graphemes = 80; let mut graphemes = first_line.grapheme_indices(true); if let Some((byte_index, _)) = graphemes.nth(max_graphemes) { (first_line[..byte_index].to_string(), true) } else { (first_line.to_string(), has_more_lines) } }; if wrap_width <= prefix_width { out.push(Line::from(prefix.dim())); shown += 1; continue; } let budget = wrap_width.saturating_sub(prefix_width); let mut needs_suffix = snippet_truncated; if !needs_suffix { let (_, remainder, _) = take_prefix_by_width(&snippet, budget); if !remainder.is_empty() { needs_suffix = true; } } if needs_suffix && budget > truncation_suffix_width { let available = budget.saturating_sub(truncation_suffix_width); let (truncated, _, _) = take_prefix_by_width(&snippet, available); out.push(vec![prefix.dim(), truncated.cyan(), truncation_suffix.dim()].into()); } else { let (truncated, _, _) = take_prefix_by_width(&snippet, budget); out.push(vec![prefix.dim(), truncated.cyan()].into()); } let chunk_prefix_first = " ↳ "; let chunk_prefix_next = " "; for (idx, chunk) in process.recent_chunks.iter().enumerate() { let chunk_prefix = if idx == 0 { chunk_prefix_first } else { chunk_prefix_next }; let chunk_prefix_width = UnicodeWidthStr::width(chunk_prefix); if wrap_width <= chunk_prefix_width { out.push(Line::from(chunk_prefix.dim())); continue; } let budget = wrap_width.saturating_sub(chunk_prefix_width); let (truncated, remainder, _) = take_prefix_by_width(chunk, budget); if !remainder.is_empty() && budget > truncation_suffix_width { let available = budget.saturating_sub(truncation_suffix_width); let (shorter, _, _) = take_prefix_by_width(chunk, available); out.push( vec![chunk_prefix.dim(), shorter.dim(), truncation_suffix.dim()].into(), ); } else { out.push(vec![chunk_prefix.dim(), truncated.dim()].into()); } } shown += 1; } let remaining = self.processes.len().saturating_sub(shown); if remaining > 0 { let more_text = format!("... and {remaining} more running"); if wrap_width <= prefix_width { out.push(Line::from(prefix.dim())); } else { let budget = wrap_width.saturating_sub(prefix_width); let (truncated, _, _) = take_prefix_by_width(&more_text, budget); out.push(vec![prefix.dim(), truncated.dim()].into()); } } out } fn desired_height(&self, width: u16) -> u16 { self.display_lines(width).len() as u16 } } pub(crate) fn new_unified_exec_processes_output( processes: Vec, ) -> CompositeHistoryCell { let command = PlainHistoryCell::new(vec!["/ps".magenta().into()]); let summary = UnifiedExecProcessesCell::new(processes); CompositeHistoryCell::new(vec![Box::new(command), Box::new(summary)]) } 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_protocol::protocol::ReviewDecision, ) -> Box { use codex_protocol::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(), ], ) } ApprovedExecpolicyAmendment { proposed_execpolicy_amendment, } => { let snippet = Span::from(exec_snippet(&proposed_execpolicy_amendment.command)).dim(); ( "✔ ".green(), vec![ "You ".into(), "approved".bold(), " codex to always run commands that start with ".into(), snippet, ], ) } 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(), ], ) } NetworkPolicyAmendment { network_policy_amendment, } => { let host = Span::from(network_policy_amendment.host).dim(); match network_policy_amendment.action { codex_protocol::protocol::NetworkPolicyRuleAction::Allow => ( "✔ ".green(), vec![ "You ".into(), "approved".bold(), " future network access to ".into(), host, ], ), codex_protocol::protocol::NetworkPolicyRuleAction::Deny => ( "✗ ".red(), vec![ "You ".into(), "blocked".bold(), " future network access to ".into(), host, ], ), } } 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)".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}") } #[derive(Debug)] struct TooltipHistoryCell { tip: String, } impl TooltipHistoryCell { fn new(tip: String) -> Self { Self { tip } } } impl HistoryCell for TooltipHistoryCell { fn display_lines(&self, width: u16) -> Vec> { let indent = " "; let indent_width = UnicodeWidthStr::width(indent); let wrap_width = usize::from(width.max(1)) .saturating_sub(indent_width) .max(1); let mut lines: Vec> = Vec::new(); append_markdown( &format!("**Tip:** {}", self.tip), Some(wrap_width), &mut lines, ); prefix_lines(lines, indent.into(), indent.into()) } } pub(crate) fn new_session_info_body( config: &Config, requested_model: &str, event: &SessionConfiguredEvent, is_first_event: bool, auth_plan: Option, ) -> Option> { let parts = session_info_body_parts(config, requested_model, event, is_first_event, auth_plan); match parts.len() { 0 => None, 1 => parts.into_iter().next(), _ => Some(Box::new(CompositeHistoryCell::new(parts))), } } fn session_info_body_parts( config: &Config, requested_model: &str, event: &SessionConfiguredEvent, is_first_event: bool, auth_plan: Option, ) -> Vec> { let mut parts: Vec> = Vec::new(); if is_first_event { 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(), "/permissions".into(), " - choose what Codex is allowed to do".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(), ]), ]; parts.push(Box::new(PlainHistoryCell { lines: help_lines })); } else { if config.show_tooltips && let Some(tooltips) = tooltips::get_tooltip(auth_plan).map(TooltipHistoryCell::new) { parts.push(Box::new(tooltips)); } if requested_model != event.model { let lines = vec![ "model changed:".magenta().bold().into(), format!("requested: {requested_model}").into(), format!("used: {}", event.model).into(), ]; parts.push(Box::new(PlainHistoryCell { lines })); } } parts } pub(crate) fn new_user_prompt( message: String, text_elements: Vec, local_image_paths: Vec, remote_image_urls: Vec, ) -> UserHistoryCell { UserHistoryCell { message, text_elements, local_image_paths, remote_image_urls, } } #[derive(Debug)] pub(crate) struct SessionHeaderHistoryCell { version: &'static str, model: String, model_style: Style, reasoning_effort: Option, directory: PathBuf, } impl SessionHeaderHistoryCell { pub(crate) fn new( model: String, reasoning_effort: Option, directory: PathBuf, version: &'static str, ) -> Self { Self::new_with_style( model, Style::default(), reasoning_effort, directory, version, ) } pub(crate) fn new_with_style( model: String, model_style: Style, reasoning_effort: Option, directory: PathBuf, version: &'static str, ) -> Self { Self { version, model, model_style, 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", ReasoningEffortConfig::XHigh => "xhigh", ReasoningEffortConfig::None => "none", }) } pub(crate) fn is_loading_placeholder(&self) -> bool { self.model == DEFAULT_MODEL_DISPLAY_NAME } } 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:> = { let mut spans = vec![ Span::from(format!("{model_label} ")).dim(), Span::styled(self.model.clone(), self.model_style), ]; if let Some(reasoning) = reasoning_label { spans.push(Span::from(" ")); spans.push(Span::from(reasoning)); } spans.push(" ".dim()); spans.push(CHANGE_MODEL_HINT_COMMAND.cyan()); spans.push(CHANGE_MODEL_HINT_EXPLANATION.dim()); spans }; 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>, animations_enabled: bool, } impl McpToolCallCell { pub(crate) fn new( call_id: String, invocation: McpInvocation, animations_enabled: bool, ) -> Self { Self { call_id, invocation, start_time: Instant::now(), duration: None, result: None, animations_enabled, } } 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: &serde_json::Value, width: usize) -> String { let content = match serde_json::from_value::(block.clone()) { Ok(content) => content, Err(_) => { return format_and_truncate_tool_result( &block.to_string(), TOOL_CALL_MAX_LINES, width, ); } }; match content.raw { rmcp::model::RawContent::Text(text) => { format_and_truncate_tool_result(&text.text, TOOL_CALL_MAX_LINES, width) } rmcp::model::RawContent::Image(_) => "".to_string(), rmcp::model::RawContent::Audio(_) => "