From 62ae65f854ec311fc0cf57e729ebcda2de605e89 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 14 May 2026 14:41:13 -0700 Subject: [PATCH 1/2] tui: split history cells into modules --- codex-rs/tui/src/history_cell.rs | 6135 ----------------- codex-rs/tui/src/history_cell/approvals.rs | 360 + codex-rs/tui/src/history_cell/base.rs | 104 + codex-rs/tui/src/history_cell/exec.rs | 242 + codex-rs/tui/src/history_cell/mcp.rs | 780 +++ codex-rs/tui/src/history_cell/messages.rs | 455 ++ codex-rs/tui/src/history_cell/mod.rs | 300 + codex-rs/tui/src/history_cell/notices.rs | 186 + codex-rs/tui/src/history_cell/patches.rs | 93 + codex-rs/tui/src/history_cell/plans.rs | 215 + .../src/history_cell/request_user_input.rs | 187 + codex-rs/tui/src/history_cell/search.rs | 144 + codex-rs/tui/src/history_cell/separators.rs | 171 + codex-rs/tui/src/history_cell/session.rs | 429 ++ codex-rs/tui/src/history_cell/tests.rs | 2502 +++++++ 15 files changed, 6168 insertions(+), 6135 deletions(-) delete mode 100644 codex-rs/tui/src/history_cell.rs create mode 100644 codex-rs/tui/src/history_cell/approvals.rs create mode 100644 codex-rs/tui/src/history_cell/base.rs create mode 100644 codex-rs/tui/src/history_cell/exec.rs create mode 100644 codex-rs/tui/src/history_cell/mcp.rs create mode 100644 codex-rs/tui/src/history_cell/messages.rs create mode 100644 codex-rs/tui/src/history_cell/mod.rs create mode 100644 codex-rs/tui/src/history_cell/notices.rs create mode 100644 codex-rs/tui/src/history_cell/patches.rs create mode 100644 codex-rs/tui/src/history_cell/plans.rs create mode 100644 codex-rs/tui/src/history_cell/request_user_input.rs create mode 100644 codex-rs/tui/src/history_cell/search.rs create mode 100644 codex-rs/tui/src/history_cell/separators.rs create mode 100644 codex-rs/tui/src/history_cell/session.rs create mode 100644 codex-rs/tui/src/history_cell/tests.rs diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs deleted file mode 100644 index d78d3f86b00..00000000000 --- a/codex-rs/tui/src/history_cell.rs +++ /dev/null @@ -1,6135 +0,0 @@ -//! 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::diff_model::FileChange; -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_command::relativize_to_home; -use crate::exec_command::strip_bash_lc_and_escape; -use crate::legacy_core::config::Config; -use crate::live_wrap::take_prefix_by_width; -use crate::markdown::append_markdown; -use crate::markdown::append_markdown_agent_with_cwd; -use crate::motion::MotionMode; -use crate::motion::ReducedMotionIndicator; -use crate::motion::activity_indicator; -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::session_state::ThreadSessionState; -use crate::style::proposed_plan_style; -use crate::style::user_message_style; -#[cfg(test)] -use crate::test_support::PathBufExt; -#[cfg(test)] -use crate::test_support::test_path_buf; -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_app_server_protocol::AskForApproval; -use codex_app_server_protocol::McpAuthStatus; -use codex_app_server_protocol::McpServerStatus; -use codex_app_server_protocol::McpServerStatusDetail; -use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; -use codex_app_server_protocol::PermissionProfileFileSystemPermissions; -use codex_app_server_protocol::PermissionProfileNetworkPermissions; -use codex_app_server_protocol::ToolRequestUserInputAnswer; -use codex_app_server_protocol::ToolRequestUserInputQuestion; -use codex_app_server_protocol::WebSearchAction; -use codex_config::types::McpServerTransportConfig; -#[cfg(test)] -use codex_mcp::qualified_mcp_tool_name_prefix; -use codex_otel::RuntimeMetricsSummary; -use codex_protocol::account::PlanType; -use codex_protocol::approvals::ExecPolicyAmendment; -use codex_protocol::approvals::NetworkPolicyAmendment; -#[cfg(test)] -use codex_protocol::mcp::Resource; -#[cfg(test)] -use codex_protocol::mcp::ResourceTemplate; -use codex_protocol::models::PermissionProfile; -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::user_input::TextElement; -use codex_utils_absolute_path::AbsolutePathBuf; -use codex_utils_cli::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::Clear; -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; -use url::Url; - -const RAW_DIFF_SUMMARY_WIDTH: usize = 10_000; -const RAW_TOOL_OUTPUT_WIDTH: usize = 10_000; - -mod hook_cell; - -pub(crate) use hook_cell::HookCell; -pub(crate) use hook_cell::new_active_hook_cell; -pub(crate) use hook_cell::new_completed_hook_cell; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum HistoryRenderMode { - Rich, - Raw, -} - -pub(crate) fn raw_lines_from_source(source: &str) -> Vec> { - if source.is_empty() { - return Vec::new(); - } - - let mut parts = source.split('\n').collect::>(); - if source.ends_with('\n') { - parts.pop(); - } - - parts - .into_iter() - .map(|line| Line::from(line.to_string())) - .collect() -} - -pub(crate) fn plain_lines(lines: impl IntoIterator>) -> Vec> { - lines - .into_iter() - .map(|line| { - let text = line - .spans - .into_iter() - .map(|span| span.content.into_owned()) - .collect::(); - Line::from(text) - }) - .collect() -} - -/// 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 copy-friendly plain logical lines for raw scrollback mode. - fn raw_lines(&self) -> Vec>; - - fn display_lines_for_mode(&self, width: u16, mode: HistoryRenderMode) -> Vec> { - match mode { - HistoryRenderMode::Rich => self.display_lines(width), - HistoryRenderMode::Raw => self.raw_lines(), - } - } - - /// 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 { - self.desired_height_for_mode(width, HistoryRenderMode::Rich) - } - - fn desired_height_for_mode(&self, width: u16, mode: HistoryRenderMode) -> u16 { - Paragraph::new(Text::from(self.display_lines_for_mode(width, mode))) - .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) - }; - // Active-cell content can reflow dramatically during resize/stream updates. Clear the - // entire draw area first so stale glyphs from previous frames never linger. - Clear.render(area, buf); - 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 - } - - fn raw_lines(&self) -> Vec> { - let mut lines = raw_lines_from_source(self.message.trim_end_matches(['\r', '\n'])); - if !self.remote_image_urls.is_empty() { - if !lines.is_empty() { - lines.push(Line::from("")); - } - lines.extend( - self.remote_image_urls - .iter() - .enumerate() - .map(|(idx, _url)| Line::from(local_image_label_text(idx.saturating_add(1)))), - ); - } - lines - } -} - -#[derive(Debug)] -pub(crate) struct ReasoningSummaryCell { - _header: String, - content: String, - /// Session cwd used to render local file links inside the reasoning body. - cwd: PathBuf, - transcript_only: bool, -} - -impl ReasoningSummaryCell { - /// Create a reasoning summary cell that will render local file links relative to the session - /// cwd active when the summary was recorded. - pub(crate) fn new(header: String, content: String, cwd: &Path, transcript_only: bool) -> Self { - Self { - _header: header, - content, - cwd: cwd.to_path_buf(), - transcript_only, - } - } - - fn lines(&self, width: u16) -> Vec> { - let mut lines: Vec> = Vec::new(); - append_markdown( - &self.content, - crate::width::usable_content_width_u16(width, /*reserved_cols*/ 2), - Some(self.cwd.as_path()), - &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) - } - - fn raw_lines(&self) -> Vec> { - if self.transcript_only { - Vec::new() - } else { - raw_lines_from_source(self.content.trim()) - } - } -} - -#[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 raw_lines(&self) -> Vec> { - plain_lines(self.lines.clone()) - } - - fn is_stream_continuation(&self) -> bool { - !self.is_first_line - } -} - -/// A consolidated agent message cell that stores raw markdown source and re-renders from it. -/// -/// After a stream finalizes, the `ConsolidateAgentMessage` handler in `App` -/// replaces the contiguous run of `AgentMessageCell`s with a single -/// `AgentMarkdownCell`. On terminal resize, `display_lines(width)` re-renders -/// from source via `append_markdown_agent`, producing correctly-sized tables -/// with box-drawing borders. -/// -/// The cell snapshots `cwd` at construction so local file-link display remains aligned with the -/// session that produced the message. Reusing the current process cwd during reflow would make old -/// transcript content change meaning after a later `/cd` or resumed session. -#[derive(Debug)] -pub(crate) struct AgentMarkdownCell { - markdown_source: String, - cwd: PathBuf, -} - -impl AgentMarkdownCell { - /// Create a finalized source-backed assistant message cell. - /// - /// `markdown_source` must be the raw source accumulated by the stream controller, not already - /// wrapped terminal lines. Passing rendered lines here would make future resize reflow preserve - /// stale wrapping instead of repairing it. - pub(crate) fn new(markdown_source: String, cwd: &Path) -> Self { - Self { - markdown_source, - cwd: cwd.to_path_buf(), - } - } -} - -impl HistoryCell for AgentMarkdownCell { - fn display_lines(&self, width: u16) -> Vec> { - let Some(wrap_width) = - crate::width::usable_content_width_u16(width, /*reserved_cols*/ 2) - else { - return prefix_lines(vec![Line::default()], "• ".dim(), " ".into()); - }; - - let mut lines: Vec> = Vec::new(); - // Re-render markdown from source at the current width. Reserve 2 columns for the "• " / - // " " prefix prepended below. - crate::markdown::append_markdown_agent_with_cwd( - &self.markdown_source, - Some(wrap_width), - Some(self.cwd.as_path()), - &mut lines, - ); - prefix_lines(lines, "• ".dim(), " ".into()) - } - - fn raw_lines(&self) -> Vec> { - raw_lines_from_source(&self.markdown_source) - } -} - -/// Transient active-cell representation of the mutable tail of an agent stream. -/// -/// During streaming, lines that have not yet been committed to scrollback because they belong to -/// an in-progress table are displayed via this cell in the `active_cell` slot. It is replaced on -/// every delta and cleared when the stream finalizes. -#[derive(Debug)] -pub(crate) struct StreamingAgentTailCell { - lines: Vec>, - is_first_line: bool, -} - -impl StreamingAgentTailCell { - pub(crate) fn new(lines: Vec>, is_first_line: bool) -> Self { - Self { - lines, - is_first_line, - } - } -} - -impl HistoryCell for StreamingAgentTailCell { - fn display_lines(&self, _width: u16) -> Vec> { - // Tail lines are already rendered at the controller's current stream width. - // Re-wrapping them here can split table borders and produce malformed in-flight rows. - prefix_lines( - self.lines.clone(), - if self.is_first_line { - "• ".dim() - } else { - " ".into() - }, - " ".into(), - ) - } - - fn raw_lines(&self) -> Vec> { - plain_lines(self.display_lines(u16::MAX)) - } - - fn is_stream_continuation(&self) -> bool { - !self.is_first_line - } -} - -/// Transient active-cell representation of the mutable tail of a proposed-plan stream. -/// -/// The controller prepares the full styled plan lines because plan tails need the same header, -/// padding, and background treatment as committed `ProposedPlanStreamCell`s while remaining -/// preview-only during streaming. -#[derive(Debug)] -pub(crate) struct StreamingPlanTailCell { - lines: Vec>, - is_stream_continuation: bool, -} - -impl StreamingPlanTailCell { - pub(crate) fn new(lines: Vec>, is_stream_continuation: bool) -> Self { - Self { - lines, - is_stream_continuation, - } - } -} - -impl HistoryCell for StreamingPlanTailCell { - fn display_lines(&self, _width: u16) -> Vec> { - self.lines.clone() - } - - fn raw_lines(&self) -> Vec> { - plain_lines(self.lines.clone()) - } - - fn is_stream_continuation(&self) -> bool { - self.is_stream_continuation - } -} - -#[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() - } - - fn raw_lines(&self) -> Vec> { - plain_lines(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) - } - - fn raw_lines(&self) -> Vec> { - let update_instruction = if let Some(update_action) = self.update_action { - format!("Run {} to update.", update_action.command_str()) - } else { - "See https://github.com/openai/codex for installation options.".to_string() - }; - vec![ - Line::from("Update available!"), - Line::from(format!("{CODEX_CLI_VERSION} -> {}", self.latest_version)), - Line::from(update_instruction), - Line::from(""), - Line::from("See full release notes:"), - Line::from("https://github.com/openai/codex/releases/latest"), - ] - } -} - -#[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) - } - - fn raw_lines(&self) -> Vec> { - plain_lines(self.text.clone().lines) - } -} - -#[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 - } - - fn raw_lines(&self) -> Vec> { - let mut out = Vec::new(); - if self.stdin.is_empty() { - if let Some(command) = self - .command_display - .as_ref() - .filter(|command| !command.is_empty()) - { - out.push(Line::from(format!( - "Waited for background terminal: {command}" - ))); - } else { - out.push(Line::from("Waited for background terminal")); - } - return out; - } - - if let Some(command) = self - .command_display - .as_ref() - .filter(|command| !command.is_empty()) - { - out.push(Line::from(format!( - "Interacted with background terminal: {command}" - ))); - } else { - out.push(Line::from("Interacted with background terminal")); - } - out.extend(raw_lines_from_source(&self.stdin)); - 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 raw_lines(&self) -> Vec> { - plain_lines(self.display_lines(u16::MAX)) - } - - 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, /*max_graphemes*/ 80); - snippet -} - -fn exec_snippet(command: &[String]) -> String { - let full_cmd = strip_bash_lc_and_escape(command); - truncate_exec_snippet(&full_cmd) -} - -fn non_empty_exec_snippet(command: &[String]) -> Option { - let snippet = exec_snippet(command); - (!snippet.is_empty()).then_some(snippet) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum ReviewDecision { - Approved, - ApprovedExecpolicyAmendment { - proposed_execpolicy_amendment: ExecPolicyAmendment, - }, - ApprovedForSession, - NetworkPolicyAmendment { - network_policy_amendment: NetworkPolicyAmendment, - }, - Denied, - TimedOut, - Abort, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum ApprovalDecisionSubject { - Command(Vec), - NetworkAccess { target: String }, -} - -pub fn new_approval_decision_cell( - subject: ApprovalDecisionSubject, - decision: ReviewDecision, - actor: ApprovalDecisionActor, -) -> Box { - use ReviewDecision::*; - use codex_protocol::approvals::NetworkPolicyRuleAction; - - let (symbol, summary): (Span<'static>, Vec>) = match decision { - Approved => match subject { - ApprovalDecisionSubject::Command(command) => { - let summary = if let Some(snippet) = non_empty_exec_snippet(&command) { - vec![ - actor.subject().into(), - "approved".bold(), - " codex to run ".into(), - Span::from(snippet).dim(), - " this time".bold(), - ] - } else { - vec![ - actor.subject().into(), - "approved".bold(), - " this request".into(), - " this time".bold(), - ] - }; - ("✔ ".green(), summary) - } - ApprovalDecisionSubject::NetworkAccess { target } => ( - "✔ ".green(), - vec![ - actor.subject().into(), - "approved".bold(), - " codex network access to ".into(), - Span::from(target).dim(), - " this time".bold(), - ], - ), - }, - ApprovedExecpolicyAmendment { - proposed_execpolicy_amendment, - } => { - let snippet = Span::from(exec_snippet(&proposed_execpolicy_amendment.command)).dim(); - ( - "✔ ".green(), - vec![ - actor.subject().into(), - "approved".bold(), - " codex to always run commands that start with ".into(), - snippet, - ], - ) - } - ApprovedForSession => match subject { - ApprovalDecisionSubject::Command(command) => { - let summary = if let Some(snippet) = non_empty_exec_snippet(&command) { - vec![ - actor.subject().into(), - "approved".bold(), - " codex to run ".into(), - Span::from(snippet).dim(), - " every time this session".bold(), - ] - } else { - vec![ - actor.subject().into(), - "approved".bold(), - " this request".into(), - " every time this session".bold(), - ] - }; - ("✔ ".green(), summary) - } - ApprovalDecisionSubject::NetworkAccess { target } => ( - "✔ ".green(), - vec![ - actor.subject().into(), - "approved".bold(), - " codex network access to ".into(), - Span::from(target).dim(), - " every time this session".bold(), - ], - ), - }, - NetworkPolicyAmendment { - network_policy_amendment, - } => { - let target = match subject { - ApprovalDecisionSubject::NetworkAccess { target } => target, - ApprovalDecisionSubject::Command(_) => network_policy_amendment.host, - }; - match network_policy_amendment.action { - NetworkPolicyRuleAction::Allow => ( - "✔ ".green(), - vec![ - actor.subject().into(), - "persisted".bold(), - " Codex network access to ".into(), - Span::from(target).dim(), - ], - ), - NetworkPolicyRuleAction::Deny => ( - "✗ ".red(), - vec![ - actor.subject().into(), - "denied".bold(), - " codex network access to ".into(), - Span::from(target).dim(), - " and saved that rule".into(), - ], - ), - } - } - Denied => match subject { - ApprovalDecisionSubject::Command(command) => { - let summary = if let Some(snippet) = non_empty_exec_snippet(&command) { - let snippet = Span::from(snippet).dim(); - match actor { - ApprovalDecisionActor::User => vec![ - actor.subject().into(), - "did not approve".bold(), - " codex to run ".into(), - snippet, - ], - ApprovalDecisionActor::Guardian => vec![ - "Request ".into(), - "denied".bold(), - " for codex to run ".into(), - snippet, - ], - } - } else { - match actor { - ApprovalDecisionActor::User => vec![ - actor.subject().into(), - "did not approve".bold(), - " this request".into(), - ], - ApprovalDecisionActor::Guardian => { - vec!["Request ".into(), "denied".bold()] - } - } - }; - ("✗ ".red(), summary) - } - ApprovalDecisionSubject::NetworkAccess { target } => ( - "✗ ".red(), - vec![ - actor.subject().into(), - "did not approve".bold(), - " codex network access to ".into(), - Span::from(target).dim(), - ], - ), - }, - TimedOut => match subject { - ApprovalDecisionSubject::Command(command) => { - let summary = if let Some(snippet) = non_empty_exec_snippet(&command) { - vec![ - "Review ".into(), - "timed out".bold(), - " before codex could run ".into(), - Span::from(snippet).dim(), - ] - } else { - vec![ - "Review ".into(), - "timed out".bold(), - " before this request could be approved".into(), - ] - }; - ("✗ ".red(), summary) - } - ApprovalDecisionSubject::NetworkAccess { target } => ( - "✗ ".red(), - vec![ - "Review ".into(), - "timed out".bold(), - " before codex could access ".into(), - Span::from(target).dim(), - ], - ), - }, - Abort => match subject { - ApprovalDecisionSubject::Command(command) => { - let summary = if let Some(snippet) = non_empty_exec_snippet(&command) { - vec![ - actor.subject().into(), - "canceled".bold(), - " the request to run ".into(), - Span::from(snippet).dim(), - ] - } else { - vec![ - actor.subject().into(), - "canceled".bold(), - " this request".into(), - ] - }; - ("✗ ".red(), summary) - } - ApprovalDecisionSubject::NetworkAccess { target } => ( - "✗ ".red(), - vec![ - actor.subject().into(), - "canceled".bold(), - " the request for codex network access to ".into(), - Span::from(target).dim(), - ], - ), - }, - }; - - Box::new(PrefixedWrappedHistoryCell::new( - Line::from(summary), - symbol, - " ", - )) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ApprovalDecisionActor { - User, - Guardian, -} - -impl ApprovalDecisionActor { - fn subject(self) -> &'static str { - match self { - Self::User => "You ", - Self::Guardian => "Auto-reviewer ", - } - } -} - -pub fn new_guardian_denied_patch_request(files: Vec) -> Box { - let mut summary = vec![ - "Request ".into(), - "denied".bold(), - " for codex to apply ".into(), - ]; - if files.len() == 1 { - summary.push("a patch touching ".into()); - summary.push(Span::from(files[0].clone()).dim()); - } else { - summary.push("a patch touching ".into()); - summary.push(Span::from(files.len().to_string()).dim()); - summary.push(" files".into()); - } - - Box::new(PrefixedWrappedHistoryCell::new( - Line::from(summary), - "✗ ".red(), - " ", - )) -} - -pub fn new_guardian_denied_action_request(summary: String) -> Box { - let line = Line::from(vec![ - "Request ".into(), - "denied".bold(), - " for ".into(), - Span::from(summary).dim(), - ]); - Box::new(PrefixedWrappedHistoryCell::new(line, "✗ ".red(), " ")) -} - -pub fn new_guardian_approved_action_request(summary: String) -> Box { - let line = Line::from(vec![ - "Request ".into(), - "approved".bold(), - " for ".into(), - Span::from(summary).dim(), - ]); - Box::new(PrefixedWrappedHistoryCell::new(line, "✔ ".green(), " ")) -} - -pub fn new_guardian_timed_out_patch_request(files: Vec) -> Box { - let mut summary = vec![ - "Review ".into(), - "timed out".bold(), - " before codex could apply ".into(), - ]; - if files.len() == 1 { - summary.push("a patch touching ".into()); - summary.push(Span::from(files[0].clone()).dim()); - } else { - summary.push("a patch touching ".into()); - summary.push(Span::from(files.len().to_string()).dim()); - summary.push(" files".into()); - } - - Box::new(PrefixedWrappedHistoryCell::new( - Line::from(summary), - "✗ ".red(), - " ", - )) -} - -pub fn new_guardian_timed_out_action_request(summary: String) -> Box { - let line = Line::from(vec![ - "Review ".into(), - "timed out".bold(), - " before ".into(), - Span::from(summary).dim(), - ]); - Box::new(PrefixedWrappedHistoryCell::new(line, "✗ ".red(), " ")) -} - -/// 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) - } - - fn raw_lines(&self) -> Vec> { - plain_lines(create_diff_summary( - &self.changes, - &self.cwd, - RAW_DIFF_SUMMARY_WIDTH, - )) - } -} - -#[derive(Debug)] -struct CompletedMcpToolCallWithImageOutput { - _image: DynamicImage, -} -impl HistoryCell for CompletedMcpToolCallWithImageOutput { - fn display_lines(&self, _width: u16) -> Vec> { - vec!["tool result (image output)".into()] - } - - fn raw_lines(&self) -> Vec> { - vec![Line::from("tool result (image output)")] - } -} - -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, /*forced_inner_width*/ 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, - cwd: PathBuf, -} - -impl TooltipHistoryCell { - fn new(tip: String, cwd: &Path) -> Self { - Self { - tip, - cwd: cwd.to_path_buf(), - } - } -} - -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), - Some(self.cwd.as_path()), - &mut lines, - ); - - prefix_lines(lines, indent.into(), indent.into()) - } - - fn raw_lines(&self) -> Vec> { - vec![Line::from(format!("Tip: {}", self.tip))] - } -} - -#[derive(Debug)] -pub struct SessionInfoCell(CompositeHistoryCell); - -impl HistoryCell for SessionInfoCell { - fn display_lines(&self, width: u16) -> Vec> { - self.0.display_lines(width) - } - - fn desired_height(&self, width: u16) -> u16 { - self.0.desired_height(width) - } - - fn transcript_lines(&self, width: u16) -> Vec> { - self.0.transcript_lines(width) - } - - fn raw_lines(&self) -> Vec> { - self.0.raw_lines() - } -} - -pub(crate) fn new_session_info( - config: &Config, - requested_model: &str, - session: &ThreadSessionState, - is_first_event: bool, - tooltip_override: Option, - auth_plan: Option, - show_fast_status: bool, -) -> SessionInfoCell { - // Header box rendered as history (so it appears at the very top) - let header = SessionHeaderHistoryCell::new( - session.model.clone(), - session.reasoning_effort, - show_fast_status, - config.cwd.to_path_buf(), - CODEX_CLI_VERSION, - ) - .with_yolo_mode(has_yolo_permissions( - session.approval_policy, - &session.permission_profile, - )); - let mut parts: Vec> = vec![Box::new(header)]; - - if is_first_event { - // 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(), - "/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) = tooltip_override - .or_else(|| tooltips::get_tooltip(auth_plan, show_fast_status)) - .map(|tip| TooltipHistoryCell::new(tip, &config.cwd)) - { - parts.push(Box::new(tooltips)); - } - if requested_model != session.model.as_str() { - let lines = vec![ - "model changed:".magenta().bold().into(), - format!("requested: {requested_model}").into(), - format!("used: {}", session.model).into(), - ]; - parts.push(Box::new(PlainHistoryCell { lines })); - } - } - - SessionInfoCell(CompositeHistoryCell { parts }) -} - -pub(crate) fn is_yolo_mode(config: &Config) -> bool { - has_yolo_permissions( - AskForApproval::from(config.permissions.approval_policy.value()), - &config.permissions.effective_permission_profile(), - ) -} - -fn has_yolo_permissions( - approval_policy: AskForApproval, - permission_profile: &PermissionProfile, -) -> bool { - let permission_profile = AppServerPermissionProfile::from(permission_profile.clone()); - approval_policy == AskForApproval::Never - && matches!( - permission_profile, - AppServerPermissionProfile::Disabled - | AppServerPermissionProfile::Managed { - file_system: PermissionProfileFileSystemPermissions::Unrestricted, - network: PermissionProfileNetworkPermissions { enabled: true }, - } - ) -} - -fn mcp_auth_status_label(status: McpAuthStatus) -> &'static str { - match status { - McpAuthStatus::Unsupported => "Unsupported", - McpAuthStatus::NotLoggedIn => "Not logged in", - McpAuthStatus::BearerToken => "Bearer token", - McpAuthStatus::OAuth => "OAuth", - } -} - -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, - show_fast_status: bool, - directory: PathBuf, - yolo_mode: bool, -} - -impl SessionHeaderHistoryCell { - pub(crate) fn new( - model: String, - reasoning_effort: Option, - show_fast_status: bool, - directory: PathBuf, - version: &'static str, - ) -> Self { - Self::new_with_style( - model, - Style::default(), - reasoning_effort, - show_fast_status, - directory, - version, - ) - } - - pub(crate) fn new_with_style( - model: String, - model_style: Style, - reasoning_effort: Option, - show_fast_status: bool, - directory: PathBuf, - version: &'static str, - ) -> Self { - Self { - version, - model, - model_style, - reasoning_effort, - show_fast_status, - directory, - yolo_mode: false, - } - } - - pub(crate) fn with_yolo_mode(mut self, yolo_mode: bool) -> Self { - self.yolo_mode = yolo_mode; - self - } - - 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", - }) - } -} - -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:"; - const PERMISSIONS_LABEL: &str = "permissions:"; - let label_width = if self.yolo_mode { - DIR_LABEL.len().max(PERMISSIONS_LABEL.len()) - } else { - 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)); - } - if self.show_fast_status { - spans.push(" ".into()); - spans.push(Span::styled("fast", self.model_style.magenta())); - } - spans.push(" ".dim()); - spans.push(CHANGE_MODEL_HINT_COMMAND.cyan()); - spans.push(CHANGE_MODEL_HINT_EXPLANATION.dim()); - spans - }; - - let dir_label = format!("{DIR_LABEL: Vec> { - let mut lines = vec![ - Line::from(format!("OpenAI Codex (v{})", self.version)), - Line::from(format!( - "model: {}{}", - self.model, - self.reasoning_label() - .map(|reasoning| format!(" {reasoning}")) - .unwrap_or_default() - )), - Line::from(format!( - "directory: {}", - self.format_directory(/*max_width*/ None) - )), - ]; - if self.yolo_mode { - lines.push(Line::from("permissions: YOLO mode")); - } - lines - } -} - -#[derive(Debug)] -pub(crate) struct CompositeHistoryCell { - parts: Vec>, -} - -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 - } - - fn raw_lines(&self) -> Vec> { - let mut out: Vec> = Vec::new(); - let mut first = true; - for part in &self.parts { - let mut lines = part.raw_lines(); - 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, -} - -#[derive(Debug, Clone)] -pub(crate) struct McpInvocation { - pub(crate) server: String, - pub(crate) tool: String, - pub(crate) arguments: Option, -} - -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(_) => "