Preserve composer hyperlinks across wrapped lines (#40720)

## What changed

- Detect visible HTTP(S) URLs in the composer and attach the complete OSC 8 destination to every wrapped fragment, including when part of the URL is scrolled offscreen.
- Cache hyperlink metadata with the textarea wrap state while leaving masked input and destinations larger than 8 KiB unannotated.
- Make shared hyperlink mapping grapheme-aware, recognize Unicode whitespace boundaries, and scan URLs and trailing delimiters incrementally for large inputs.

## Testing

- Add coverage for wrapped and scrolled URLs, joined emoji, Unicode whitespace, preserved highlighting, masked input, cache invalidation, oversized destinations, and drafts containing many URLs.

GitOrigin-RevId: 40facdf2fe79438c91c82bff2d849c342f1ab28a
This commit is contained in:
Charlie Marsh authored and copyberry committed 2026-08-25 22:05:48 +00:00
1 parent 42624fd63b
commit d6174a8798
9 files changed
+726 -50

No files matched your search

+17 -2
View File
@@ -13,6 +13,8 @@
//! Wrapping also reserves a visible insertion point: full logical lines get continuation rows,
//! and trailing spaces wrap instead of moving the cursor outside the textarea. At soft word
//! breaks, interior separators hang off the preceding row without changing the editable text.
//! Visible web URLs carry their complete terminal hyperlink destination across wrapped rows;
//! masked rendering never exposes hyperlink destinations.
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::is_altgr;
@@ -38,12 +40,14 @@ use ratatui::text::Span;
use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::WidgetRef;
use std::borrow::Cow;
use std::cell::OnceCell;
use std::cell::Ref;
use std::cell::RefCell;
use std::ops::Range;
use std::sync::Arc;
use unicode_segmentation::UnicodeSegmentation;
mod hyperlinks;
mod vim;
mod vim_commands;
mod wrapping;
@@ -147,6 +151,7 @@ pub(crate) struct TextArea {
struct WrapCache {
width: u16,
lines: Vec<Range<usize>>,
hyperlinks: OnceCell<hyperlinks::HyperlinkCache>,
}
#[derive(Debug, Default, Clone, Copy)]
@@ -1994,7 +1999,11 @@ impl TextArea {
if needs_recalc {
let display_text = text_for_display(&self.text);
let lines = wrapping::wrapped_lines(display_text.as_ref(), width);
*cache = Some(WrapCache { width, lines });
*cache = Some(WrapCache {
width,
lines,
hyperlinks: OnceCell::new(),
});
}
}
@@ -2107,7 +2116,7 @@ impl TextArea {
highlights: &[(Range<usize>, Style)],
) {
let element_style = base_style.fg(Color::Cyan);
for (row, idx) in range.enumerate() {
for (row, idx) in range.clone().enumerate() {
let r = &lines[idx];
let y = area.y + row as u16;
let visible = wrapping::visible_prefix(&self.text[r.start..r.end - 1], area.width);
@@ -2149,6 +2158,12 @@ impl TextArea {
);
}
}
if let Some(wrap_cache) = self.wrap_cache.borrow().as_ref() {
wrap_cache
.hyperlinks
.get_or_init(|| hyperlinks::HyperlinkCache::new(&self.text, lines))
.mark(buf, area, &self.text, lines, range);
}
}
/// Renders width-preserving mask glyphs without writing outside the textarea viewport.
@@ -0,0 +1,98 @@
//! Preserve complete web destinations on visible, independently wrapped textarea rows.
use super::text_for_display;
use super::wrapping::visible_prefix;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::TerminalHyperlink;
use crate::terminal_hyperlinks::mark_buffer_hyperlinks;
use crate::terminal_hyperlinks::web_links_in_text;
use crate::width::display_width;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use std::ops::Range;
/// Destinations and row offsets retained for one lazily initialized textarea wrap state.
#[derive(Clone, Debug, Default)]
pub(super) struct HyperlinkCache {
hyperlinks: Vec<TerminalHyperlink>,
source_columns: Vec<usize>,
}
impl HyperlinkCache {
/// Scan the draft once and precompute row offsets for constant-time scrolled lookup.
pub(super) fn new(text: &str, lines: &[Range<usize>]) -> Self {
let display_text = text_for_display(text);
let hyperlinks = web_links_in_text(display_text.as_ref());
if hyperlinks.is_empty() {
return Self::default();
}
let mut source_column = 0;
let mut previous_start = 0;
let source_columns = lines
.iter()
.map(|line| {
source_column += display_width(&display_text[previous_start..line.start]);
previous_start = line.start;
source_column
})
.collect();
Self {
hyperlinks,
source_columns,
}
}
/// Attach complete destinations after styling without revisiting offscreen draft text.
pub(super) fn mark(
&self,
buf: &mut Buffer,
area: Rect,
text: &str,
lines: &[Range<usize>],
visible_rows: Range<usize>,
) {
if area.width == 0 || area.height == 0 || self.hyperlinks.is_empty() {
return;
}
for (row, index) in visible_rows.enumerate() {
let line_range = &lines[index];
let source_column = self.source_columns[index];
let raw_visible = visible_prefix(
&text[line_range.start..line_range.end.saturating_sub(1)],
area.width,
);
let visible = text_for_display(raw_visible);
let line_end = source_column + display_width(visible.as_ref());
let first_hyperlink = self
.hyperlinks
.partition_point(|hyperlink| hyperlink.columns.end <= source_column);
let mut annotated = HyperlinkLine::new(Line::from(visible.into_owned()));
for hyperlink in self.hyperlinks[first_hyperlink..]
.iter()
.take_while(|hyperlink| hyperlink.columns.start < line_end)
{
let start = hyperlink.columns.start.max(source_column) - source_column;
let end = hyperlink.columns.end.min(line_end) - source_column;
annotated.hyperlinks.push(TerminalHyperlink::web(
start..end,
hyperlink.destination.clone(),
));
}
if !annotated.hyperlinks.is_empty() {
let row_area =
Rect::new(area.x, area.y + row as u16, area.width, /*height*/ 1);
mark_buffer_hyperlinks(buf, row_area, &[annotated], /*scroll_rows*/ 0);
}
}
}
}
#[cfg(test)]
#[path = "hyperlinks_tests.rs"]
mod tests;
@@ -0,0 +1,354 @@
use super::super::TextArea;
use super::super::TextAreaState;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ChatComposer;
use crate::render::renderable::Renderable;
use crate::terminal_hyperlinks::strip_osc8;
use crate::width::display_width;
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::widgets::StatefulWidgetRef;
use tokio::sync::mpsc::unbounded_channel;
type LinkedCell = (u16, u16, String, String);
fn linked_cells(buf: &Buffer) -> Vec<LinkedCell> {
buf.area
.positions()
.filter_map(|position| {
let symbol = buf[position].symbol();
let (destination, _) = symbol.strip_prefix("\x1b]8;;")?.split_once('\x07')?;
Some((
position.x,
position.y,
strip_osc8(symbol),
destination.to_string(),
))
})
.collect()
}
fn render(text: &str, width: u16, height: u16) -> (TextArea, TextAreaState, Buffer) {
let mut textarea = TextArea::new();
textarea.insert_str(text);
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height);
let mut buf = Buffer::empty(area);
let mut state = TextAreaState::default();
StatefulWidgetRef::render_ref(&(&textarea), area, &mut buf, &mut state);
(textarea, state, buf)
}
fn linked_text(cells: &[LinkedCell]) -> String {
cells.iter().map(|(_, _, text, _)| text.as_str()).collect()
}
fn hyperlink_snapshot(buf: &Buffer) -> String {
let mut snapshot = (0..buf.area.height)
.map(|row| {
let text = (0..buf.area.width)
.map(|column| strip_osc8(buf[(column, row)].symbol()))
.collect::<String>();
format!("|{text}|\n")
})
.collect::<String>();
let linked = linked_cells(buf);
snapshot.push_str("Hyperlinks:\n");
if linked.is_empty() {
snapshot.push_str(" none\n");
}
for cells in linked.chunk_by(|left, right| {
left.1 == right.1
&& left.3 == right.3
&& usize::from(left.0) + display_width(&left.2) == usize::from(right.0)
}) {
let (column, row, _, destination) = &cells[0];
let last = cells.last().unwrap();
let end = usize::from(last.0) + display_width(&last.2);
snapshot.push_str(&format!(
" row {row}, columns {column}..{end} -> {destination}\n"
));
}
snapshot
}
fn assert_destination(buf: &Buffer, destination: &str) -> Vec<LinkedCell> {
let linked = linked_cells(buf);
assert!(!linked.is_empty());
assert!(linked.iter().all(|(_, _, _, target)| target == destination));
linked
}
#[test]
fn wrapped_url_fragments_keep_the_complete_destination() {
let url = "https://github.com/openai/codex/pull/20252";
let (_, _, buf) = render(
&format!("Fix CI on {url}"),
/*width*/ 26,
/*height*/ 5,
);
let area = buf.area;
let linked = assert_destination(&buf, url);
assert_eq!(linked_text(&linked), url);
assert!(linked.windows(2).any(|pair| pair[0].1 != pair[1].1));
assert!(!buf[(0, 0)].symbol().contains("\x1b]8;;"));
let visible = (0..area.height)
.map(|row| {
(0..area.width)
.map(|column| strip_osc8(buf[(column, row)].symbol()))
.collect::<String>()
.trim_end()
.to_string()
})
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!("wrapped_textarea_url_keeps_original_appearance", visible);
}
#[test]
fn composer_wrapped_url_fragments_keep_the_complete_destination() {
let url = "https://github.com/openai/codex/pull/20252";
let (sender, _receiver) = unbounded_channel::<AppEvent>();
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
AppEventSender::new(sender),
/*enhanced_keys_supported*/ true,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_text_content(format!("Fix CI on {url}"), Vec::new(), Vec::new());
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 34, /*height*/ 8,
);
let mut buf = Buffer::empty(area);
composer.render(area, &mut buf);
let linked = assert_destination(&buf, url);
assert_eq!(linked_text(&linked), url);
assert!(linked.windows(2).any(|pair| pair[0].1 != pair[1].1));
}
#[test]
fn scrolled_url_fragments_keep_the_offscreen_destination() {
let url = "https://example.test/alpha/beta/gamma/delta/epsilon";
let (_, state, buf) = render(url, /*width*/ 16, /*height*/ 2);
let visible_url = linked_text(&assert_destination(&buf, url));
assert!(state.scroll > 0);
assert_ne!(visible_url, url);
assert!(url.ends_with(&visible_url));
}
#[test]
fn long_drafts_reuse_hyperlink_detection_across_cursor_redraws() {
let text = "x".repeat(MAX_USER_INPUT_TEXT_CHARS);
let (mut textarea, mut state, mut buf) = render(&text, /*width*/ 80, /*height*/ 2);
let area = buf.area;
let first_cache = {
let cache = textarea.wrap_cache.borrow();
let hyperlinks = cache.as_ref().unwrap().hyperlinks.get().unwrap();
assert!(hyperlinks.hyperlinks.is_empty());
hyperlinks as *const super::HyperlinkCache
};
textarea.set_cursor(text.len() - 1);
StatefulWidgetRef::render_ref(&(&textarea), area, &mut buf, &mut state);
let cache = textarea.wrap_cache.borrow();
let second_cache = cache.as_ref().unwrap().hyperlinks.get().unwrap();
assert!(std::ptr::eq(first_cache, second_cache));
}
#[test]
fn maximum_length_urls_render_without_osc8_annotations() {
let prefix = "https://example.test/";
let text = format!(
"{prefix}{}",
"a".repeat(MAX_USER_INPUT_TEXT_CHARS - prefix.len())
);
let (textarea, _, buf) = render(&text, /*width*/ 80, /*height*/ 2);
assert_eq!(textarea.text(), text);
assert_eq!(linked_cells(&buf), Vec::new());
assert!(buf.content.iter().all(|cell| cell.symbol().len() == 1));
insta::assert_snapshot!(
"maximum_length_url_without_annotations",
hyperlink_snapshot(&buf)
);
}
#[test]
fn many_urls_render_with_the_complete_destination() {
let destination = "https://x.io";
let text = format!("{destination} ").repeat(/*n*/ 50_000);
let (textarea, state, buf) = render(&text, /*width*/ 80, /*height*/ 2);
assert!(state.scroll > 0);
assert_destination(&buf, destination);
assert_eq!(
textarea
.wrap_cache
.borrow()
.as_ref()
.unwrap()
.hyperlinks
.get()
.unwrap()
.hyperlinks
.len(),
50_000
);
}
#[test]
fn joined_emoji_preserve_complete_url_cell_ranges() {
let mut snapshots = Vec::new();
for url in ["https://example.test/path", "https://example.test/👩‍💻/path"] {
for width in [64, 24, 16] {
let (_, _, buf) = render(&format!("👩‍💻 {url} tail"), width, /*height*/ 6);
let linked = assert_destination(&buf, url);
assert_eq!(linked_text(&linked), url);
for row in 0..buf.area.height {
let cells = linked
.iter()
.filter(|(_, y, _, _)| *y == row)
.cloned()
.collect::<Vec<_>>();
if let (Some(first), Some(last)) = (cells.first(), cells.last()) {
snapshots.push(format!(
"width {width}, row {row}, columns {}..={}: {}",
first.0,
last.0,
linked_text(&cells)
));
}
}
}
}
insta::assert_snapshot!("joined_emoji_url_cell_ranges", snapshots.join("\n"));
}
#[test]
fn unicode_whitespace_separates_url_destinations() {
let first = "https://one.test/a";
let second = "https://two.test/b";
let mut snapshots = Vec::new();
for separator in ['\u{2003}', '\u{a0}', '\u{2007}', '\u{202f}', '\u{2028}'] {
for width in [48, 20] {
let (_, _, buf) = render(
&format!("{first}{separator}{second}"),
width,
/*height*/ 4,
);
let linked = linked_cells(&buf);
for destination in [first, second] {
let cells = linked
.iter()
.filter(|(_, _, _, target)| target == destination)
.cloned()
.collect::<Vec<_>>();
assert_eq!(linked_text(&cells), destination);
}
assert_eq!(linked.len(), first.len() + second.len());
snapshots.push(format!(
"separator U+{:04X}, width {width}\n{}",
separator as u32,
hyperlink_snapshot(&buf)
));
}
}
insta::assert_snapshot!("unicode_whitespace_url_destinations", snapshots.join("\n"));
}
#[test]
fn hyperlink_cache_is_invalidated_when_text_changes() {
let first = "https://one.test/before";
let second = "https://two.test/after";
let (mut textarea, mut state, mut buf) = render(first, /*width*/ 40, /*height*/ 1);
let area = buf.area;
textarea.set_text_clearing_elements(second);
assert!(textarea.wrap_cache.borrow().is_none());
buf = Buffer::empty(area);
StatefulWidgetRef::render_ref(&(&textarea), area, &mut buf, &mut state);
assert_eq!(linked_text(&assert_destination(&buf, second)), second);
}
#[test]
fn distinct_urls_respect_punctuation_wide_prefixes_and_tabs() {
let first = "https://one.test/a";
let second = "https://two.test/b";
let (_, _, buf) = render(
&format!("界\t({first}),\n{second}!"),
/*width*/ 48,
/*height*/ 2,
);
let linked = linked_cells(&buf);
for (row, column, destination) in [(0, 4, first), (1, 0, second)] {
let cells = linked
.iter()
.filter(|(_, cell_row, _, _)| *cell_row == row)
.cloned()
.collect::<Vec<_>>();
assert_eq!(cells.first().map(|cell| cell.0), Some(column));
assert!(cells.iter().all(|cell| cell.3 == destination));
assert_eq!(linked_text(&cells), destination);
}
}
#[test]
fn url_hyperlinks_preserve_existing_highlight_styles() {
let url = "https://example.test/highlight";
let mut textarea = TextArea::new();
textarea.insert_str(url);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 1,
);
let mut buf = Buffer::empty(area);
let mut state = TextAreaState::default();
let highlight = Style::default().fg(Color::Magenta);
textarea.render_ref_styled_with_highlights(
area,
&mut buf,
&mut state,
Style::default(),
&[(0..url.len(), highlight)],
);
let linked = assert_destination(&buf, url);
assert_eq!(linked.len(), url.len());
assert!(
linked
.iter()
.all(|(column, row, _, _)| { buf[(*column, *row)].fg == Color::Magenta })
);
}
#[test]
fn masked_url_input_never_exposes_hyperlink_destinations() {
let url = "https://example.test/secret";
let mut textarea = TextArea::new();
textarea.insert_str(url);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 1,
);
let mut buf = Buffer::empty(area);
let mut state = TextAreaState::default();
textarea.render_ref_masked(area, &mut buf, &mut state, '*');
assert_eq!(linked_cells(&buf), Vec::new());
assert!(
area.positions()
.all(|position| !buf[position].symbol().contains(url))
);
}
@@ -0,0 +1,16 @@
---
source: tui/src/bottom_pane/textarea/hyperlinks_tests.rs
expression: "snapshots.join(\"\\n\")"
---
width 64, row 0, columns 3..=27: https://example.test/path
width 24, row 0, columns 3..=23: https://example.test/
width 24, row 1, columns 0..=3: path
width 16, row 0, columns 3..=10: https://
width 16, row 1, columns 0..=12: example.test/
width 16, row 2, columns 0..=3: path
width 64, row 0, columns 3..=30: https://example.test/👩‍💻/path
width 24, row 0, columns 3..=23: https://example.test/
width 24, row 1, columns 0..=6: 👩‍💻/path
width 16, row 0, columns 3..=10: https://
width 16, row 1, columns 0..=15: example.test/👩‍💻/
width 16, row 2, columns 0..=3: path
@@ -0,0 +1,8 @@
---
source: tui/src/bottom_pane/textarea/hyperlinks_tests.rs
expression: hyperlink_snapshot(&buf)
---
|aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa|
|aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa |
Hyperlinks:
none
@@ -0,0 +1,96 @@
---
source: tui/src/bottom_pane/textarea/hyperlinks_tests.rs
expression: "snapshots.join(\"\\n\")"
---
separator U+2003, width 48
|https://one.test/a https://two.test/b |
| |
| |
| |
Hyperlinks:
row 0, columns 0..18 -> https://one.test/a
row 0, columns 19..37 -> https://two.test/b
separator U+2003, width 20
|https://one.test/a  |
|https://two.test/b |
| |
| |
Hyperlinks:
row 0, columns 0..18 -> https://one.test/a
row 1, columns 0..18 -> https://two.test/b
separator U+00A0, width 48
|https://one.test/a https://two.test/b |
| |
| |
| |
Hyperlinks:
row 0, columns 0..18 -> https://one.test/a
row 0, columns 19..37 -> https://two.test/b
separator U+00A0, width 20
|https://one.test/ |
|a https://two.test/b|
| |
| |
Hyperlinks:
row 0, columns 0..17 -> https://one.test/a
row 1, columns 0..1 -> https://one.test/a
row 1, columns 2..20 -> https://two.test/b
separator U+2007, width 48
|https://one.test/a https://two.test/b |
| |
| |
| |
Hyperlinks:
row 0, columns 0..18 -> https://one.test/a
row 0, columns 19..37 -> https://two.test/b
separator U+2007, width 20
|https://one.test/ |
|a https://two.test/b|
| |
| |
Hyperlinks:
row 0, columns 0..17 -> https://one.test/a
row 1, columns 0..1 -> https://one.test/a
row 1, columns 2..20 -> https://two.test/b
separator U+202F, width 48
|https://one.test/a https://two.test/b |
| |
| |
| |
Hyperlinks:
row 0, columns 0..18 -> https://one.test/a
row 0, columns 19..37 -> https://two.test/b
separator U+202F, width 20
|https://one.test/ |
|a https://two.test/b|
| |
| |
Hyperlinks:
row 0, columns 0..17 -> https://one.test/a
row 1, columns 0..1 -> https://one.test/a
row 1, columns 2..20 -> https://two.test/b
separator U+2028, width 48
|https://one.test/a
https://two.test/b |
| |
| |
| |
Hyperlinks:
row 0, columns 0..18 -> https://one.test/a
row 0, columns 19..37 -> https://two.test/b
separator U+2028, width 20
|https://one.test/a
 |
|https://two.test/b |
| |
| |
Hyperlinks:
row 0, columns 0..18 -> https://one.test/a
row 1, columns 0..18 -> https://two.test/b
@@ -0,0 +1,7 @@
---
source: tui/src/bottom_pane/textarea/hyperlinks_tests.rs
expression: visible
---
Fix CI on https://
github.com/openai/codex/
pull/20252