diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index 0b94b2b7aa..0c16062a42 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -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::(); + 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)); diff --git a/codex-rs/tui/src/app/agents_overview_view.rs b/codex-rs/tui/src/app/agents_overview_view.rs index f047ae7116..2b8cc78dd9 100644 --- a/codex-rs/tui/src/app/agents_overview_view.rs +++ b/codex-rs/tui/src/app/agents_overview_view.rs @@ -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| { + lines + .into_iter() + .flat_map(|line| { + let wrapped = crate::wrapping::word_wrap_lines([line.line.clone()], width); + remap_wrapped_line(&line, wrapped) + }) + .collect::>() + }; 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); } } diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_link_rows.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_link_rows.snap new file mode 100644 index 0000000000..be7b352879 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_link_rows.snap @@ -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