fix(tui): linkify complete bare URLs with tildes (#27088)

## Background

Bare URLs containing `~` in their path are currently only clickable up
to the tilde in the interactive TUI. For example, Codex renders the
visible text for:


`https://www.cs.tufts.edu/~nr/cs257/archive/olin-shivers/dissertation.pdf`

but the OSC 8 destination stops at `https://www.cs.tufts.edu/`. This
makes Cmd-click open the wrong location even though the terminal
recognizes the complete URL outside Codex.

Fixes #26774.

## Root Cause

The URL scanner already accepts `~`. The truncation happens earlier:
with strikethrough parsing enabled, `pulldown-cmark` splits this URL
into adjacent decoded `Event::Text` values around the tilde. The
Markdown renderer annotated each text event independently, so only the
first event still looked like a complete URL with a supported scheme.

The renderer now merges adjacent decoded text events before URL
annotation. It preserves the combined source range while retaining
parser-decoded contents, which avoids regressing entities such as
`&`.

## Changes

- Add a small iterator that merges adjacent decoded Markdown text events
and their source ranges.
- Apply it at the Markdown renderer boundary before hyperlink detection.
- Add regression coverage for the reported URL in prose, wrapped table
output, and entity-decoded URLs.

## How to Test

1. Run Codex with `just c`.
2. Ask the assistant to output this exact bare URL with no Markdown link
syntax:

`https://www.cs.tufts.edu/~nr/cs257/archive/olin-shivers/dissertation.pdf`
3. Hold Cmd and hover or click the URL.
4. Confirm the complete URL, including the suffix after `~`, is one
destination.
5. Repeat with the URL inside a Markdown table and confirm wrapped
portions retain the same complete destination.

Targeted tests:

- `just test -p codex-tui url_with_tilde`
- `just test -p codex-tui merged_text_events_preserve_entity_decoding`

The full `codex-tui` test run was also executed. Its only failures were
the two existing Guardian feature-flag tests:

-
`app::tests::update_feature_flags_disabling_guardian_clears_review_policy_and_restores_default`
-
`app::tests::update_feature_flags_disabling_guardian_clears_manual_review_policy_without_history`
This commit is contained in:
Felipe Coury
2026-06-08 17:02:36 -07:00
committed by GitHub
parent ffec7c0933
commit 4ca2e436e5
5 changed files with 156 additions and 1 deletions
+88
View File
@@ -8,9 +8,11 @@ use std::path::Path;
use crate::markdown_render::COLON_LOCATION_SUFFIX_RE;
use crate::markdown_render::HASH_LOCATION_SUFFIX_RE;
use crate::markdown_render::render_markdown_lines_with_width_and_cwd;
use crate::markdown_render::render_markdown_text;
use crate::markdown_render::render_markdown_text_with_width;
use crate::markdown_render::render_markdown_text_with_width_and_cwd;
use insta::assert_debug_snapshot;
use insta::assert_snapshot;
fn render_markdown_text_for_cwd(input: &str, cwd: &Path) -> Text<'static> {
@@ -29,6 +31,92 @@ fn plain_lines(text: &Text<'_>) -> Vec<String> {
.collect()
}
#[test]
fn bare_url_with_tilde_keeps_complete_hyperlink() {
let destination =
"https://www.cs.tufts.edu/~nr/cs257/archive/olin-shivers/dissertation.pdf";
let lines = render_markdown_lines_with_width_and_cwd(
destination,
/*width*/ Some(80),
/*cwd*/ None,
);
let rendered = lines
.iter()
.map(|line| {
let text = line
.line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
let hyperlinks = line
.hyperlinks
.iter()
.map(|link| (link.columns.clone(), link.destination.as_str()))
.collect::<Vec<_>>();
(text, hyperlinks)
})
.collect::<Vec<_>>();
assert_debug_snapshot!(rendered);
}
#[test]
fn table_url_with_tilde_keeps_complete_hyperlink() {
let destination =
"https://www.cs.tufts.edu/~nr/cs257/archive/olin-shivers/dissertation.pdf";
let markdown = format!("| URL |\n| --- |\n| {destination} |\n");
let lines = render_markdown_lines_with_width_and_cwd(
&markdown,
/*width*/ Some(32),
/*cwd*/ None,
);
let destinations = lines
.iter()
.flat_map(|line| line.hyperlinks.iter())
.map(|link| link.destination.as_str())
.collect::<Vec<_>>();
assert!(!destinations.is_empty());
assert_eq!(destinations, vec![destination; destinations.len()]);
}
#[test]
fn merged_text_events_preserve_entity_decoding() {
let source = "https://example.com/a&amp;b~c";
let destination = "https://example.com/a&b~c";
let lines = render_markdown_lines_with_width_and_cwd(
source,
/*width*/ Some(80),
/*cwd*/ None,
);
let rendered = lines
.iter()
.map(|line| {
let text = line
.line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
let hyperlinks = line
.hyperlinks
.iter()
.map(|link| (link.columns.clone(), link.destination.as_str()))
.collect::<Vec<_>>();
(text, hyperlinks)
})
.collect::<Vec<_>>();
assert_eq!(
rendered,
vec![(
destination.to_string(),
vec![(0..destination.len(), destination)],
)],
);
}
#[test]
fn empty() {
assert_eq!(render_markdown_text(""), Text::default());