Files
codex/codex-rs/tui/src/history_cell/mod.rs
T
Felipe Coury 7a26497836 feat(tui): add OSC 8 web links to rich content (#24472)
## Why

Wrapped URLs in rich TUI output, especially URLs rendered inside
Markdown tables, are split across terminal rows. In terminals that
support OSC 8 hyperlinks, treating each visible fragment as part of the
complete destination enables reliable open-link and copy-link actions
even after table layout wraps the URL.

This addresses the semantic-link portion of #12200 and the behavior
described in
https://github.com/openai/codex/issues/12200#issuecomment-4535452980. It
does not change ordinary drag-selection across bordered table rows.

## What Changed

- Added shared TUI OSC 8 support that validates `http://` and `https://`
destinations, sanitizes terminal payloads, and applies metadata
separately from visible line width/layout.
- Added semantic web-link annotations to assistant and proposed-plan
Markdown, including explicit web links and bare web URLs in prose and
table cells while excluding code and non-web Markdown destinations.
- Preserved complete URL targets through table wrapping, narrow pipe
fallback, streaming, transcript overlay rendering, history insertion,
and resize replay.
- Routed intentional Codex-owned links in notices,
status/setup/app-link, feedback, onboarding, MCP/plugin help, memories,
and update surfaces through the shared hyperlink handling.

## How to Test

1. Run Codex in a terminal with OSC 8 link support, such as Ghostty, and
request an assistant response containing a Markdown table whose last
column contains a long `https://` URL.
2. Make the terminal narrow enough for the URL to wrap across multiple
bordered table rows.
3. Use the terminal's open-link or copy-link action on more than one
wrapped URL fragment and confirm each fragment resolves to the complete
original URL.
4. Resize the terminal after the table is rendered and repeat the link
action to confirm the destination survives scrollback replay.
5. Open the transcript overlay while rich output is present and confirm
web links remain interactive there.
6. As a regression check, render inline/fenced code containing URL text
and a Markdown link such as
`[https://example.com](mailto:support@example.com)`; confirm these do
not acquire a web OSC 8 destination.

Targeted automated coverage exercised Markdown links and exclusions,
wrapped and pipe-fallback tables, streaming/transcript overlay
propagation, status-link truncation, and rendered word-wrapping cell
alignment. `just test -p codex-tui` was also run; it passed the
hyperlink coverage and reproduced two unrelated existing guardian
feature-flag test failures.
2026-05-27 20:14:55 +00:00

333 lines
12 KiB
Rust

//! 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::mark_buffer_hyperlinks;
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 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::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 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 approvals;
mod base;
mod exec;
mod hook_cell;
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<Line<'static>> {
if source.is_empty() {
return Vec::new();
}
let mut parts = source.split('\n').collect::<Vec<_>>();
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<Item = Line<'static>>) -> Vec<Line<'static>> {
lines
.into_iter()
.map(|line| {
let text = line
.spans
.into_iter()
.map(|span| span.content.into_owned())
.collect::<String>();
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<Line<'static>>;
/// Returns copy-friendly plain logical lines for raw scrollback mode.
fn raw_lines(&self) -> Vec<Line<'static>>;
/// Returns rich visible lines plus terminal hyperlink metadata.
fn display_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
plain_hyperlink_lines(self.display_lines(width))
}
fn display_lines_for_mode(&self, width: u16, mode: HistoryRenderMode) -> Vec<Line<'static>> {
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<HyperlinkLine> {
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<Line<'static>> {
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<HyperlinkLine> {
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`. 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 = visible_lines(self.transcript_hyperlink_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<u64> {
None
}
}
impl Renderable for Box<dyn HistoryCell> {
fn render(&self, area: Rect, buf: &mut Buffer) {
let hyperlink_lines = self.display_hyperlink_lines(area.width);
let lines = visible_lines(hyperlink_lines.clone());
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);
mark_buffer_hyperlinks(buf, area, &hyperlink_lines, usize::from(y));
}
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
}
}