//! 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::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; use crate::terminal_hyperlinks::HyperlinkLine; use crate::terminal_hyperlinks::HyperlinkParagraph; use crate::terminal_hyperlinks::plain_hyperlink_lines; use crate::terminal_hyperlinks::prefix_hyperlink_lines; use crate::terminal_hyperlinks::visible_lines; #[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 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::ToolRequestUserInputAnswer; use codex_app_server_protocol::ToolRequestUserInputQuestion; use codex_app_server_protocol::WebSearchAction; #[cfg(test)] 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::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::models::local_image_label_text; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::permissions::NetworkSandboxPolicy; 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; #[cfg(test)] use codex_utils_cli::format_env_display; 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::path::Path; use std::path::PathBuf; use std::time::Duration; use std::time::Instant; use unicode_segmentation::UnicodeSegmentation; use url::Url; const RAW_DIFF_SUMMARY_WIDTH: usize = 10_000; const RAW_TOOL_OUTPUT_WIDTH: usize = 10_000; mod approvals; mod base; mod exec; mod hook_cell; mod markdown_render_cache; mod mcp; mod messages; mod notices; mod patches; mod plans; mod request_user_input; mod search; mod separators; mod session; pub(crate) use approvals::*; pub(crate) use base::*; pub(crate) use exec::*; pub(crate) use hook_cell::HookCell; pub(crate) use hook_cell::new_active_hook_cell; pub(crate) use hook_cell::new_completed_hook_cell; pub(crate) use mcp::*; pub(crate) use messages::*; pub(crate) use notices::*; pub(crate) use patches::*; pub(crate) use plans::*; pub(crate) use request_user_input::*; pub(crate) use search::*; pub(crate) use separators::*; pub(crate) use session::*; #[cfg(test)] mod tests; #[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>; /// Returns rich visible lines plus terminal hyperlink metadata. fn display_hyperlink_lines(&self, width: u16) -> Vec { plain_hyperlink_lines(self.display_lines(width)) } fn display_lines_for_mode(&self, width: u16, mode: HistoryRenderMode) -> Vec> { match mode { HistoryRenderMode::Rich => visible_lines(self.display_hyperlink_lines(width)), HistoryRenderMode::Raw => self.raw_lines(), } } fn display_hyperlink_lines_for_mode( &self, width: u16, mode: HistoryRenderMode, ) -> Vec { match mode { HistoryRenderMode::Rich => self.display_hyperlink_lines(width), HistoryRenderMode::Raw => plain_hyperlink_lines(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 transcript-overlay lines plus terminal hyperlink metadata. /// /// Defaults to the plain transcript representation because some cells render different /// display and transcript content. Rich cells whose transcript mirrors their display should /// delegate to `display_hyperlink_lines`. fn transcript_hyperlink_lines(&self, width: u16) -> Vec { plain_hyperlink_lines(self.transcript_lines(width)) } /// Returns the number of viewport rows for the transcript overlay. /// /// Uses the same `Paragraph::line_count` measurement as `desired_height`. fn desired_transcript_height(&self, width: u16) -> u16 { let lines = visible_lines(self.transcript_hyperlink_lines(width)); Paragraph::new(Text::from(lines)) .wrap(Wrap { trim: false }) .line_count(width) .try_into() .unwrap_or(0) } /// Whether the transcript height remains valid across later overlay renders. /// /// Cells backed by external state should return `false` so the pager remeasures them before /// rendering instead of reusing a height that may now clip their content. fn has_stable_transcript_height(&self) -> bool { true } 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 hyperlink_lines = self.display_hyperlink_lines(area.width); let paragraph = HyperlinkParagraph::new(&hyperlink_lines, Style::default()); 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).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 } }