Files
codex/codex-rs/tui/src/history_cell/plans.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

239 lines
8.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Proposed-plan and plan-update history cells.
use super::*;
/// 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<HyperlinkLine>,
is_stream_continuation: bool,
}
impl StreamingPlanTailCell {
pub(crate) fn new(lines: Vec<HyperlinkLine>, is_stream_continuation: bool) -> Self {
Self {
lines,
is_stream_continuation,
}
}
}
impl HistoryCell for StreamingPlanTailCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
visible_lines(self.lines.clone())
}
fn display_hyperlink_lines(&self, _width: u16) -> Vec<HyperlinkLine> {
self.lines.clone()
}
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
self.display_hyperlink_lines(width)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(visible_lines(self.lines.clone()))
}
fn is_stream_continuation(&self) -> bool {
self.is_stream_continuation
}
}
/// Render a user‑friendly plan update styled like a checkbox todo list.
pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlanUpdateCell {
let UpdatePlanArgs { explanation, plan } = update;
PlanUpdateCell { explanation, plan }
}
/// Create a proposed-plan cell that snapshots the session cwd for later markdown rendering.
///
/// The plan body is stored as raw markdown so terminal resize reflow can render it again at the
/// current width. Callers should use `new_proposed_plan_stream` only for transient live streaming
/// cells, then consolidate to this source-backed cell when the plan is complete.
pub(crate) fn new_proposed_plan(plan_markdown: String, cwd: &Path) -> ProposedPlanCell {
ProposedPlanCell {
plan_markdown,
cwd: cwd.to_path_buf(),
}
}
/// Create a transient proposed-plan stream cell from already rendered lines.
///
/// Stream cells are display fragments, not source-backed history. They should be replaced by
/// `ProposedPlanCell` during consolidation before relying on resize reflow for finalized history.
pub(crate) fn new_proposed_plan_stream(
lines: Vec<impl Into<HyperlinkLine>>,
is_stream_continuation: bool,
) -> ProposedPlanStreamCell {
ProposedPlanStreamCell {
lines: lines.into_iter().map(Into::into).collect(),
is_stream_continuation,
}
}
/// Finalized proposed-plan history that can render itself again for a new width.
///
/// This is the source-backed counterpart to `ProposedPlanStreamCell`. It owns raw markdown and the
/// session cwd needed for stable local-link rendering during later transcript reflow.
#[derive(Debug)]
pub(crate) struct ProposedPlanCell {
plan_markdown: String,
/// Session cwd used to keep local file-link display aligned with live streamed plan rendering.
cwd: PathBuf,
}
/// Transient proposed-plan history emitted while a plan is still streaming.
///
/// The lines are already rendered for the stream's current width. A finalized transcript should not
/// keep these cells after consolidation, because they cannot re-render their source on a later
/// terminal resize.
#[derive(Debug)]
pub(crate) struct ProposedPlanStreamCell {
lines: Vec<HyperlinkLine>,
is_stream_continuation: bool,
}
impl HistoryCell for ProposedPlanCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
visible_lines(self.display_hyperlink_lines(width))
}
fn display_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
let mut lines = vec![
HyperlinkLine::new(vec!["• ".dim(), "Proposed Plan".bold()].into()),
HyperlinkLine::new(Line::from(" ")),
];
let mut plan_lines = vec![HyperlinkLine::new(Line::from(" "))];
let plan_style = proposed_plan_style();
let wrap_width = width.saturating_sub(4).max(1) as usize;
let mut body = crate::markdown::render_markdown_agent_with_links_and_cwd(
&self.plan_markdown,
Some(wrap_width),
Some(self.cwd.as_path()),
);
if body.is_empty() {
body.push(HyperlinkLine::new(Line::from("(empty)".dim().italic())));
}
plan_lines.extend(prefix_hyperlink_lines(body, " ".into(), " ".into()));
plan_lines.push(HyperlinkLine::new(Line::from(" ")));
lines.extend(plan_lines.into_iter().map(|line| line.style(plan_style)));
lines
}
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
self.display_hyperlink_lines(width)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
raw_lines_from_source(&self.plan_markdown)
}
}
impl HistoryCell for ProposedPlanStreamCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
visible_lines(self.lines.clone())
}
fn display_hyperlink_lines(&self, _width: u16) -> Vec<HyperlinkLine> {
self.lines.clone()
}
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
self.display_hyperlink_lines(width)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(visible_lines(self.lines.clone()))
}
fn is_stream_continuation(&self) -> bool {
self.is_stream_continuation
}
}
#[derive(Debug)]
pub(crate) struct PlanUpdateCell {
explanation: Option<String>,
plan: Vec<PlanItemArg>,
}
impl HistoryCell for PlanUpdateCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let render_note = |text: &str| -> Vec<Line<'static>> {
let wrap_width = width.saturating_sub(4).max(1) as usize;
let note = Line::from(text.to_string().dim().italic());
let wrapped = adaptive_wrap_line(&note, RtOptions::new(wrap_width));
let mut out = Vec::new();
push_owned_lines(&wrapped, &mut out);
out
};
let render_step = |status: &StepStatus, text: &str| -> Vec<Line<'static>> {
let (box_str, step_style) = match status {
StepStatus::Completed => ("✔ ", Style::default().crossed_out().dim()),
StepStatus::InProgress => ("□ ", Style::default().cyan().bold()),
StepStatus::Pending => ("□ ", Style::default().dim()),
};
let opts = RtOptions::new(width.saturating_sub(4).max(1) as usize)
.initial_indent(box_str.into())
.subsequent_indent(" ".into());
let step = Line::from(text.to_string().set_style(step_style));
let wrapped = adaptive_wrap_line(&step, opts);
let mut out = Vec::new();
push_owned_lines(&wrapped, &mut out);
out
};
let mut lines: Vec<Line<'static>> = vec![];
lines.push(vec!["• ".dim(), "Updated Plan".bold()].into());
let mut indented_lines = vec![];
let note = self
.explanation
.as_ref()
.map(|s| s.trim())
.filter(|t| !t.is_empty());
if let Some(expl) = note {
indented_lines.extend(render_note(expl));
};
if self.plan.is_empty() {
indented_lines.push(Line::from("(no steps provided)".dim().italic()));
} else {
for PlanItemArg { step, status } in self.plan.iter() {
indented_lines.extend(render_step(status, step));
}
}
lines.extend(prefix_lines(indented_lines, " └ ".dim(), " ".into()));
lines
}
fn raw_lines(&self) -> Vec<Line<'static>> {
let mut lines = vec![Line::from("Updated Plan")];
if let Some(explanation) = self
.explanation
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
lines.extend(raw_lines_from_source(explanation));
}
if self.plan.is_empty() {
lines.push(Line::from("(no steps provided)"));
} else {
for PlanItemArg { step, status } in &self.plan {
lines.push(Line::from(format!("{status:?}: {step}")));
}
}
lines
}
}