Preserve terminal hyperlinks in agents overview previews (#50431)

## What changed

Keep Markdown link destinations attached to prompt and last-message previews in the agents overview details pane as text wraps and is clipped. Render visible links as terminal hyperlinks, while keeping truncation ellipses unlinked.

## Testing

Add a regression test and snapshot covering links that span multiple rows, prompt links, and clipped previews with unlinked ellipses.

GitOrigin-RevId: d121e062b197790ce45a70d6d64aadcb181e4dde
This commit is contained in:
Benjamin Carlsson
2026-10-02 21:28:40 +00:00
committed by copyberry
parent bee28e8a06
commit b6903c0669
3 changed files with 95 additions and 17 deletions
@@ -959,6 +959,56 @@ async fn agents_overview_details_render_markdown() {
);
}
#[tokio::test]
async fn agents_overview_preview_links_survive_wrapping_and_clipping() {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
let mut thread = overview_thread(
thread_id,
/*parent_thread_id*/ None,
"Review links",
ThreadStatus::Idle,
);
let destination = "https://example.com/a/long/path/to/the/pull/request";
thread.preview = "[Prompt](https://example.com/prompt)".into();
app.agents_overview.last_messages.insert(
thread_id,
format!("Opened [a pull request with a label that wraps across rows]({destination}).\n\nMore context that should be clipped in a short preview.\n\nAdditional details."),
);
let view = app.agents_overview_view(vec![thread], Some(thread_id));
for height in [40, 22] {
let area =
ratatui::layout::Rect::new(/*x*/ 0, /*y*/ 0, /*width*/ 96, height);
let mut buffer = ratatui::buffer::Buffer::empty(area);
view.render(area, &mut buffer);
let mut linked_rows = Vec::new();
for y in 0..height {
let linked = (0..area.width)
.map(|x| buffer[(x, y)].symbol())
.filter(|symbol| symbol.starts_with(&format!("\x1b]8;;{destination}\x07")))
.map(crate::terminal_hyperlinks::strip_osc8)
.collect::<String>();
if !linked.is_empty() {
linked_rows.push(linked);
}
}
assert!(linked_rows.len() >= 2, "link must span multiple rows");
assert!(buffer.content.iter().all(|cell| {
crate::terminal_hyperlinks::strip_osc8(cell.symbol()) != "…"
|| !cell.symbol().contains("\x1b]8;;")
}));
if height == 40 {
insta::assert_snapshot!("agents_overview_link_rows", linked_rows.join("\n"));
assert!(buffer.content.iter().any(|cell| {
cell.symbol()
.starts_with("\x1b]8;;https://example.com/prompt\x07")
}));
} else {
assert!(buffer.content.iter().any(|cell| cell.symbol() == "…"));
}
}
}
#[test]
fn agents_overview_markdown_preview_preserves_layout_and_bounds() {
let text = format!("a\r\n\t\u{1b}{}", "界".repeat(600));
+36 -17
View File
@@ -29,6 +29,11 @@ use crate::keymap::ListAction;
use crate::keymap::ListKeymap;
use crate::keymap::RuntimeKeymap;
use crate::render::renderable::Renderable;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::mark_buffer_hyperlinks;
use crate::terminal_hyperlinks::plain_hyperlink_lines;
use crate::terminal_hyperlinks::remap_wrapped_line;
use crate::terminal_hyperlinks::visible_lines;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadActiveFlag;
use codex_app_server_protocol::ThreadStatus;
@@ -442,38 +447,51 @@ impl AgentsOverviewView {
lines.push("Branch".dim().into());
lines.push(branch.clone().into());
}
// Keep destinations attached to their text through wrapping and clipping.
let wrap = |lines: Vec<HyperlinkLine>| {
lines
.into_iter()
.flat_map(|line| {
let wrapped = crate::wrapping::word_wrap_lines([line.line.clone()], width);
remap_wrapped_line(&line, wrapped)
})
.collect::<Vec<_>>()
};
let preview = super::agents_overview_details::preview_markdown(&row.thread.preview);
let prompt_start = crate::wrapping::word_wrap_lines(lines.clone(), width).len();
lines.extend([Line::default(), Line::from("Prompt".dim())]);
let prompt = crate::markdown_render::render_markdown_text_with_width_and_cwd(
let prompt = crate::markdown_render::render_markdown_lines_with_width_and_cwd(
match preview.as_str() {
"" => "No prompt available.",
preview => preview,
},
Some(width),
Some(row.thread.cwd.as_path()),
)
.lines;
let mut prompt = crate::wrapping::word_wrap_lines(prompt, width);
);
let mut prompt = wrap(prompt);
if prompt.len() > 2 {
prompt.truncate(2);
prompt[1] = "…".dim().into();
prompt[1] = HyperlinkLine::new("…".dim().into());
}
lines.extend(prompt);
let details_start = crate::wrapping::word_wrap_lines(lines[..4].to_vec(), width).len();
let mut lines = crate::wrapping::word_wrap_lines(lines, width);
let mut lines = wrap(plain_hyperlink_lines(lines));
lines.extend(prompt);
if self.state().connection_notice.is_none() {
let mut details = row.details.lines.clone();
let mut details = plain_hyperlink_lines(row.details.lines.clone());
if let Some((message, cwd)) = &row.details.last_message {
details.extend([Line::default(), "Last message".dim().into()]);
crate::markdown::append_markdown(
&crate::markdown::normalize_markdown_for_rendering(message),
Some(width),
Some(cwd.as_path()),
&mut details,
details.extend(plain_hyperlink_lines(vec![
Line::default(),
"Last message".dim().into(),
]));
details.extend(
crate::markdown_render::render_markdown_lines_with_width_and_cwd(
&crate::markdown::normalize_markdown_for_rendering(message),
Some(width),
Some(cwd.as_path()),
),
);
}
let mut details = crate::wrapping::word_wrap_lines(details, width);
let mut details = wrap(details);
if !row.details.usage_lines.is_empty()
&& details.len() > usize::from(area.height).saturating_sub(lines.len())
{
@@ -484,12 +502,13 @@ impl AgentsOverviewView {
if details.len() > available {
details.truncate(available);
if let Some(last) = details.last_mut() {
*last = "…".dim().into();
*last = HyperlinkLine::new("…".dim().into());
}
}
lines.splice(details_start..details_start, details);
}
Paragraph::new(lines).render(area, buf);
Paragraph::new(visible_lines(lines.clone())).render(area, buf);
mark_buffer_hyperlinks(buf, area, &lines, /*scroll_rows*/ 0);
}
}
@@ -0,0 +1,9 @@
---
source: tui/src/app/agents_overview_tests.rs
assertion_line: 1271
expression: "linked_rows.join(\"\\n\")"
---
a pull request with a label
that wraps across rows
https://example.com/a/long/path/to/
the/pull/request