From 22ebb0fb178fe369190123c256ba200585765725 Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Thu, 8 Oct 2026 23:44:05 +0000 Subject: [PATCH] Clamp wrapped source ranges before remapping terminal hyperlinks (#52330) ## Why Wrapping can retain a cursor sentinel one byte past the end of the input. Using that range to slice source text during hyperlink remapping can panic. ## What changed Clamp wrapped range boundaries to the input length before calculating hyperlink columns and recording logical source ranges, keeping both aligned with the visible text. ## Testing Add a regression test that extends a wrapped range past the input boundary and verifies that the line, hyperlinks, and logical source are preserved. GitOrigin-RevId: c83c817df8d3ea790f7d126768092ae317ff9217 --- codex-rs/tui/src/terminal_hyperlinks.rs | 12 ++++++++---- codex-rs/tui/src/terminal_hyperlinks_tests.rs | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/terminal_hyperlinks.rs b/codex-rs/tui/src/terminal_hyperlinks.rs index 8457b6e447..83d949b3b5 100644 --- a/codex-rs/tui/src/terminal_hyperlinks.rs +++ b/codex-rs/tui/src/terminal_hyperlinks.rs @@ -389,10 +389,14 @@ pub(crate) fn remap_source_wrapped_line( let line = line_to_static(&wrapped.line); let displayed = line_text(&line); let prefix_columns = display_width(&displayed[..wrapped.prefix_bytes]); - source_column += display_width(&text[source_byte..wrapped.range.start]); + // Some wrapping paths retain a one-past-the-end cursor sentinel. Span slicing already + // clips that sentinel to the visible line; keep hyperlink and source projection on the + // same bounded range instead of indexing past the flattened text. + let range = wrapped.range.start.min(text.len())..wrapped.range.end.min(text.len()); + source_column += display_width(&text[source_byte..range.start]); let start = source_column; - let end = start + display_width(&text[wrapped.range.clone()]); - source_byte = wrapped.range.end; + let end = start + display_width(&text[range.clone()]); + source_byte = range.end; source_column = end; let hyperlinks = source .hyperlinks @@ -410,7 +414,7 @@ pub(crate) fn remap_source_wrapped_line( HyperlinkLine { line, hyperlinks, - source: Some(logical.wrapped(wrapped.range, wrapped.prefix_bytes)), + source: Some(logical.wrapped(range, wrapped.prefix_bytes)), } }) .collect() diff --git a/codex-rs/tui/src/terminal_hyperlinks_tests.rs b/codex-rs/tui/src/terminal_hyperlinks_tests.rs index ead3f3662b..dc3c66b423 100644 --- a/codex-rs/tui/src/terminal_hyperlinks_tests.rs +++ b/codex-rs/tui/src/terminal_hyperlinks_tests.rs @@ -65,3 +65,17 @@ fn decorated_hyperlinks_preserve_joined_emoji_columns() { ))] ); } + +#[test] +fn source_wrapping_clips_a_cursor_sentinel_at_the_input_boundary() { + let destination = "https://example.test/pull/9410"; + let text = format!("› [configuration editor]({destination})"); + let source = annotate_web_urls_in_line(Line::from(text.clone())); + let mut wrapped = crate::wrapping::word_wrap_line_with_source(&source.line, text.len()); + wrapped.last_mut().expect("wrapped line").range.end += 1; + let actual = remap_source_wrapped_line(&source, wrapped); + let mut expected = source; + expected.source = Some(LogicalLineSource::from_line(&expected.line)); + + assert!(lines_with_sources_eq(&actual, &[expected])); +}