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])); +}