Skip to content

Commit 6acede5

Browse files
authored
tui: restore visible line numbers for hidden file links (#12870)
we recently changed file linking so the model uses markdown links when it wants something to be clickable. This works well across the GUI surfaces because they can render markdown cleanly and use the full absolute path in the anchor target. A previous pass hid the absolute path in the TUI (and only showed the label), but that also meant we could lose useful location info when the model put the line number or range in the anchor target instead of the label. This follow-up keeps the TUI behavior simple while making local file links feel closer to the old TUI file reference style. key changes: - Local markdown file links in the TUI keep the old file-ref feel: code styling, no underline, no visible absolute path. - If the hidden local anchor target includes a location suffix and the label does not already include one, we append that suffix to the visible label. - This works for single lines, line/column references, and ranges. - If the label already includes the location, we leave it alone. - normal web links keep the old TUI markdown-link behavior some examples: - `[foo.rs](/abs/path/foo.rs)` renders as `foo.rs` - `[foo.rs](/abs/path/foo.rs:45)` renders as `foo.rs:45` - `[foo.rs](/abs/path/foo.rs:45:3-48:9)` renders as `foo.rs:45:3-48:9` - `[foo.rs:45](/abs/path/foo.rs:45)` stays `foo.rs:45` - `[docs](https://example.com/docs)` still renders like a normal web link how it looks: <img width="732" height="813" alt="Screenshot 2026-02-26 at 9 27 55 AM" src="https://github.com/user-attachments/assets/d51bf236-653a-4e83-96e4-9427f0804471" />
1 parent 14a08d6 commit 6acede5

File tree

6 files changed

+246
-8
lines changed

6 files changed

+246
-8
lines changed

codex-rs/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codex-rs/tui/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ codex-utils-fuzzy-match = { workspace = true }
5050
codex-utils-oss = { workspace = true }
5151
codex-utils-sandbox-summary = { workspace = true }
5252
codex-utils-sleep-inhibitor = { workspace = true }
53+
codex-utils-string = { workspace = true }
5354
color-eyre = { workspace = true }
5455
crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] }
5556
derive_more = { workspace = true, features = ["is_variant"] }

codex-rs/tui/src/markdown_render.rs

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use crate::render::highlight::highlight_code_to_lines;
22
use crate::render::line_utils::line_to_static;
33
use crate::wrapping::RtOptions;
44
use crate::wrapping::adaptive_wrap_line;
5+
use codex_utils_string::normalize_markdown_hash_location_suffix;
56
use pulldown_cmark::CodeBlockKind;
67
use pulldown_cmark::CowStr;
78
use pulldown_cmark::Event;
@@ -14,6 +15,8 @@ use ratatui::style::Style;
1415
use ratatui::text::Line;
1516
use ratatui::text::Span;
1617
use ratatui::text::Text;
18+
use regex_lite::Regex;
19+
use std::sync::LazyLock;
1720

1821
struct MarkdownStyles {
1922
h1: Style,
@@ -89,12 +92,30 @@ pub(crate) fn render_markdown_text_with_width(input: &str, width: Option<usize>)
8992
struct LinkState {
9093
destination: String,
9194
show_destination: bool,
95+
hidden_location_suffix: Option<String>,
96+
label_start_span_idx: usize,
97+
label_styled: bool,
9298
}
9399

94100
fn should_render_link_destination(dest_url: &str) -> bool {
95101
!is_local_path_like_link(dest_url)
96102
}
97103

104+
static COLON_LOCATION_SUFFIX_RE: LazyLock<Regex> =
105+
LazyLock::new(
106+
|| match Regex::new(r":\d+(?::\d+)?(?:[-–]\d+(?::\d+)?)?$") {
107+
Ok(regex) => regex,
108+
Err(error) => panic!("invalid location suffix regex: {error}"),
109+
},
110+
);
111+
112+
// Covered by load_location_suffix_regexes.
113+
static HASH_LOCATION_SUFFIX_RE: LazyLock<Regex> =
114+
LazyLock::new(|| match Regex::new(r"^L\d+(?:C\d+)?(?:-L\d+(?:C\d+)?)?$") {
115+
Ok(regex) => regex,
116+
Err(error) => panic!("invalid hash location regex: {error}"),
117+
});
118+
98119
fn is_local_path_like_link(dest_url: &str) -> bool {
99120
dest_url.starts_with("file://")
100121
|| dest_url.starts_with('/')
@@ -491,20 +512,77 @@ where
491512
}
492513

493514
fn push_link(&mut self, dest_url: String) {
494-
self.push_inline_style(self.styles.link);
515+
let show_destination = should_render_link_destination(&dest_url);
516+
let label_styled = !show_destination;
517+
let label_start_span_idx = self
518+
.current_line_content
519+
.as_ref()
520+
.map(|line| line.spans.len())
521+
.unwrap_or(0);
522+
if label_styled {
523+
self.push_inline_style(self.styles.code);
524+
}
495525
self.link = Some(LinkState {
496-
show_destination: should_render_link_destination(&dest_url),
526+
show_destination,
527+
hidden_location_suffix: if is_local_path_like_link(&dest_url) {
528+
dest_url
529+
.rsplit_once('#')
530+
.and_then(|(_, fragment)| {
531+
HASH_LOCATION_SUFFIX_RE
532+
.is_match(fragment)
533+
.then(|| format!("#{fragment}"))
534+
})
535+
.and_then(|suffix| normalize_markdown_hash_location_suffix(&suffix))
536+
.or_else(|| {
537+
COLON_LOCATION_SUFFIX_RE
538+
.find(&dest_url)
539+
.map(|m| m.as_str().to_string())
540+
})
541+
} else {
542+
None
543+
},
544+
label_start_span_idx,
545+
label_styled,
497546
destination: dest_url,
498547
});
499548
}
500549

501550
fn pop_link(&mut self) {
502551
if let Some(link) = self.link.take() {
503-
self.pop_inline_style();
504552
if link.show_destination {
553+
if link.label_styled {
554+
self.pop_inline_style();
555+
}
505556
self.push_span(" (".into());
506557
self.push_span(Span::styled(link.destination, self.styles.link));
507558
self.push_span(")".into());
559+
} else if let Some(location_suffix) = link.hidden_location_suffix.as_deref() {
560+
let label_text = self
561+
.current_line_content
562+
.as_ref()
563+
.and_then(|line| {
564+
line.spans.get(link.label_start_span_idx..).map(|spans| {
565+
spans
566+
.iter()
567+
.map(|span| span.content.as_ref())
568+
.collect::<String>()
569+
})
570+
})
571+
.unwrap_or_default();
572+
if label_text
573+
.rsplit_once('#')
574+
.is_some_and(|(_, fragment)| HASH_LOCATION_SUFFIX_RE.is_match(fragment))
575+
|| COLON_LOCATION_SUFFIX_RE.find(&label_text).is_some()
576+
{
577+
// The label already carries a location suffix; don't duplicate it.
578+
} else {
579+
self.push_span(Span::styled(location_suffix.to_string(), self.styles.code));
580+
}
581+
if link.label_styled {
582+
self.pop_inline_style();
583+
}
584+
} else if link.label_styled {
585+
self.pop_inline_style();
508586
}
509587
}
510588
}

codex-rs/tui/src/markdown_render_tests.rs

Lines changed: 110 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ use ratatui::text::Line;
44
use ratatui::text::Span;
55
use ratatui::text::Text;
66

7+
use crate::markdown_render::COLON_LOCATION_SUFFIX_RE;
8+
use crate::markdown_render::HASH_LOCATION_SUFFIX_RE;
79
use crate::markdown_render::render_markdown_text;
810
use insta::assert_snapshot;
911

@@ -643,27 +645,130 @@ fn strong_emphasis() {
643645
fn link() {
644646
let text = render_markdown_text("[Link](https://example.com)");
645647
let expected = Text::from(Line::from_iter([
646-
"Link".cyan().underlined(),
648+
"Link".into(),
647649
" (".into(),
648650
"https://example.com".cyan().underlined(),
649651
")".into(),
650652
]));
651653
assert_eq!(text, expected);
652654
}
653655

656+
#[test]
657+
fn load_location_suffix_regexes() {
658+
let _colon = &*COLON_LOCATION_SUFFIX_RE;
659+
let _hash = &*HASH_LOCATION_SUFFIX_RE;
660+
}
661+
654662
#[test]
655663
fn file_link_hides_destination() {
656-
let text =
657-
render_markdown_text("[markdown_render.rs:74](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74)");
658-
let expected = Text::from(Line::from("markdown_render.rs:74".cyan().underlined()));
664+
let text = render_markdown_text(
665+
"[codex-rs/tui/src/markdown_render.rs](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs)",
666+
);
667+
let expected = Text::from(Line::from_iter(["codex-rs/tui/src/markdown_render.rs".cyan()]));
668+
assert_eq!(text, expected);
669+
}
670+
671+
#[test]
672+
fn file_link_appends_line_number_when_label_lacks_it() {
673+
let text = render_markdown_text(
674+
"[markdown_render.rs](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74)",
675+
);
676+
let expected = Text::from(Line::from_iter([
677+
"markdown_render.rs".cyan(),
678+
":74".cyan(),
679+
]));
680+
assert_eq!(text, expected);
681+
}
682+
683+
#[test]
684+
fn file_link_uses_label_for_line_number() {
685+
let text = render_markdown_text(
686+
"[markdown_render.rs:74](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74)",
687+
);
688+
let expected = Text::from(Line::from_iter(["markdown_render.rs:74".cyan()]));
689+
assert_eq!(text, expected);
690+
}
691+
692+
#[test]
693+
fn file_link_appends_hash_anchor_when_label_lacks_it() {
694+
let text = render_markdown_text(
695+
"[markdown_render.rs](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3)",
696+
);
697+
let expected = Text::from(Line::from_iter([
698+
"markdown_render.rs".cyan(),
699+
":74:3".cyan(),
700+
]));
701+
assert_eq!(text, expected);
702+
}
703+
704+
#[test]
705+
fn file_link_uses_label_for_hash_anchor() {
706+
let text = render_markdown_text(
707+
"[markdown_render.rs#L74C3](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3)",
708+
);
709+
let expected = Text::from(Line::from_iter(["markdown_render.rs#L74C3".cyan()]));
710+
assert_eq!(text, expected);
711+
}
712+
713+
#[test]
714+
fn file_link_appends_range_when_label_lacks_it() {
715+
let text = render_markdown_text(
716+
"[markdown_render.rs](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74:3-76:9)",
717+
);
718+
let expected = Text::from(Line::from_iter([
719+
"markdown_render.rs".cyan(),
720+
":74:3-76:9".cyan(),
721+
]));
722+
assert_eq!(text, expected);
723+
}
724+
725+
#[test]
726+
fn file_link_uses_label_for_range() {
727+
let text = render_markdown_text(
728+
"[markdown_render.rs:74:3-76:9](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74:3-76:9)",
729+
);
730+
let expected = Text::from(Line::from_iter(["markdown_render.rs:74:3-76:9".cyan()]));
731+
assert_eq!(text, expected);
732+
}
733+
734+
#[test]
735+
fn file_link_appends_hash_range_when_label_lacks_it() {
736+
let text = render_markdown_text(
737+
"[markdown_render.rs](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3-L76C9)",
738+
);
739+
let expected = Text::from(Line::from_iter([
740+
"markdown_render.rs".cyan(),
741+
":74:3-76:9".cyan(),
742+
]));
743+
assert_eq!(text, expected);
744+
}
745+
746+
#[test]
747+
fn multiline_file_link_label_after_styled_prefix_does_not_panic() {
748+
let text = render_markdown_text(
749+
"**bold** plain [foo\nbar](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3)",
750+
);
751+
let expected = Text::from_iter([
752+
Line::from_iter(["bold".bold(), " plain ".into(), "foo".cyan()]),
753+
Line::from_iter(["bar".cyan(), ":74:3".cyan()]),
754+
]);
755+
assert_eq!(text, expected);
756+
}
757+
758+
#[test]
759+
fn file_link_uses_label_for_hash_range() {
760+
let text = render_markdown_text(
761+
"[markdown_render.rs#L74C3-L76C9](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3-L76C9)",
762+
);
763+
let expected = Text::from(Line::from_iter(["markdown_render.rs#L74C3-L76C9".cyan()]));
659764
assert_eq!(text, expected);
660765
}
661766

662767
#[test]
663768
fn url_link_shows_destination() {
664769
let text = render_markdown_text("[docs](https://example.com/docs)");
665770
let expected = Text::from(Line::from_iter([
666-
"docs".cyan().underlined(),
771+
"docs".into(),
667772
" (".into(),
668773
"https://example.com/docs".cyan().underlined(),
669774
")".into(),
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
source: tui/src/markdown_render_tests.rs
3+
assertion_line: 714
34
expression: rendered
45
---
56
See markdown_render.rs:74.

codex-rs/utils/string/src/lib.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,45 @@ pub fn find_uuids(s: &str) -> Vec<String> {
7676
re.find_iter(s).map(|m| m.as_str().to_string()).collect()
7777
}
7878

79+
/// Convert a markdown-style `#L..` location suffix into a terminal-friendly
80+
/// `:line[:column][-line[:column]]` suffix.
81+
pub fn normalize_markdown_hash_location_suffix(suffix: &str) -> Option<String> {
82+
let fragment = suffix.strip_prefix('#')?;
83+
let (start, end) = match fragment.split_once('-') {
84+
Some((start, end)) => (start, Some(end)),
85+
None => (fragment, None),
86+
};
87+
let (start_line, start_column) = parse_markdown_hash_location_point(start)?;
88+
let mut normalized = String::from(":");
89+
normalized.push_str(start_line);
90+
if let Some(column) = start_column {
91+
normalized.push(':');
92+
normalized.push_str(column);
93+
}
94+
if let Some(end) = end {
95+
let (end_line, end_column) = parse_markdown_hash_location_point(end)?;
96+
normalized.push('-');
97+
normalized.push_str(end_line);
98+
if let Some(column) = end_column {
99+
normalized.push(':');
100+
normalized.push_str(column);
101+
}
102+
}
103+
Some(normalized)
104+
}
105+
106+
fn parse_markdown_hash_location_point(point: &str) -> Option<(&str, Option<&str>)> {
107+
let point = point.strip_prefix('L')?;
108+
match point.split_once('C') {
109+
Some((line, column)) => Some((line, Some(column))),
110+
None => Some((point, None)),
111+
}
112+
}
113+
79114
#[cfg(test)]
80115
mod tests {
81116
use super::find_uuids;
117+
use super::normalize_markdown_hash_location_suffix;
82118
use super::sanitize_metric_tag_value;
83119
use pretty_assertions::assert_eq;
84120

@@ -121,4 +157,20 @@ mod tests {
121157
let msg = "bad value!";
122158
assert_eq!(sanitize_metric_tag_value(msg), "bad_value");
123159
}
160+
161+
#[test]
162+
fn normalize_markdown_hash_location_suffix_converts_single_location() {
163+
assert_eq!(
164+
normalize_markdown_hash_location_suffix("#L74C3"),
165+
Some(":74:3".to_string())
166+
);
167+
}
168+
169+
#[test]
170+
fn normalize_markdown_hash_location_suffix_converts_ranges() {
171+
assert_eq!(
172+
normalize_markdown_hash_location_suffix("#L74C3-L76C9"),
173+
Some(":74:3-76:9".to_string())
174+
);
175+
}
124176
}

0 commit comments

Comments
 (0)