From 7a26497836be29ec77fb8007347b33cad952b497 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 27 May 2026 17:14:55 -0300 Subject: [PATCH] feat(tui): add OSC 8 web links to rich content (#24472) ## Why Wrapped URLs in rich TUI output, especially URLs rendered inside Markdown tables, are split across terminal rows. In terminals that support OSC 8 hyperlinks, treating each visible fragment as part of the complete destination enables reliable open-link and copy-link actions even after table layout wraps the URL. This addresses the semantic-link portion of #12200 and the behavior described in https://github.com/openai/codex/issues/12200#issuecomment-4535452980. It does not change ordinary drag-selection across bordered table rows. ## What Changed - Added shared TUI OSC 8 support that validates `http://` and `https://` destinations, sanitizes terminal payloads, and applies metadata separately from visible line width/layout. - Added semantic web-link annotations to assistant and proposed-plan Markdown, including explicit web links and bare web URLs in prose and table cells while excluding code and non-web Markdown destinations. - Preserved complete URL targets through table wrapping, narrow pipe fallback, streaming, transcript overlay rendering, history insertion, and resize replay. - Routed intentional Codex-owned links in notices, status/setup/app-link, feedback, onboarding, MCP/plugin help, memories, and update surfaces through the shared hyperlink handling. ## How to Test 1. Run Codex in a terminal with OSC 8 link support, such as Ghostty, and request an assistant response containing a Markdown table whose last column contains a long `https://` URL. 2. Make the terminal narrow enough for the URL to wrap across multiple bordered table rows. 3. Use the terminal's open-link or copy-link action on more than one wrapped URL fragment and confirm each fragment resolves to the complete original URL. 4. Resize the terminal after the table is rendered and repeat the link action to confirm the destination survives scrollback replay. 5. Open the transcript overlay while rich output is present and confirm web links remain interactive there. 6. As a regression check, render inline/fenced code containing URL text and a Markdown link such as `[https://example.com](mailto:support@example.com)`; confirm these do not acquire a web OSC 8 destination. Targeted automated coverage exercised Markdown links and exclusions, wrapped and pipe-fallback tables, streaming/transcript overlay propagation, status-link truncation, and rendered word-wrapping cell alignment. `just test -p codex-tui` was also run; it passed the hyperlink coverage and reproduced two unrelated existing guardian feature-flag test failures. --- codex-rs/tui/src/app.rs | 4 +- codex-rs/tui/src/app/resize_reflow.rs | 45 +- codex-rs/tui/src/app/tests.rs | 11 +- codex-rs/tui/src/app_backtrack.rs | 14 +- codex-rs/tui/src/bottom_pane/app_link_view.rs | 11 +- codex-rs/tui/src/bottom_pane/feedback_view.rs | 4 +- .../src/bottom_pane/memories_settings_view.rs | 1 + codex-rs/tui/src/chatwidget.rs | 23 +- .../chatwidget/tests/popups_and_settings.rs | 2 +- codex-rs/tui/src/history_cell/base.rs | 61 ++ codex-rs/tui/src/history_cell/mcp.rs | 9 +- codex-rs/tui/src/history_cell/messages.rs | 67 +- codex-rs/tui/src/history_cell/mod.rs | 39 +- codex-rs/tui/src/history_cell/notices.rs | 16 + codex-rs/tui/src/history_cell/plans.rs | 57 +- codex-rs/tui/src/history_cell/tests.rs | 41 ++ codex-rs/tui/src/insert_history.rs | 71 +- codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/markdown.rs | 19 +- codex-rs/tui/src/markdown_render.rs | 421 +++++++++--- codex-rs/tui/src/onboarding/auth.rs | 60 +- codex-rs/tui/src/pager_overlay.rs | 94 ++- codex-rs/tui/src/status/card.rs | 39 +- codex-rs/tui/src/status/tests.rs | 19 + codex-rs/tui/src/streaming/controller.rs | 150 +++-- codex-rs/tui/src/streaming/mod.rs | 16 +- codex-rs/tui/src/terminal_hyperlinks.rs | 627 ++++++++++++++++++ codex-rs/tui/src/tui.rs | 17 +- codex-rs/tui/src/update_prompt.rs | 7 +- 29 files changed, 1659 insertions(+), 287 deletions(-) create mode 100644 codex-rs/tui/src/terminal_hyperlinks.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 8c89c9153b..bd0cc45548 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -473,7 +473,7 @@ struct SessionSummary { #[derive(Debug, Default)] struct InitialHistoryReplayBuffer { - retained_lines: VecDeque>, + retained_lines: VecDeque, render_from_transcript_tail: bool, } @@ -498,7 +498,7 @@ pub(crate) struct App { // Pager overlay state (Transcript or Static like Diff) pub(crate) overlay: Option, - pub(crate) deferred_history_lines: Vec>, + pub(crate) deferred_history_lines: Vec, has_emitted_history_lines: bool, transcript_reflow: TranscriptReflowState, initial_history_replay_buffer: Option, diff --git a/codex-rs/tui/src/app/resize_reflow.rs b/codex-rs/tui/src/app/resize_reflow.rs index 6ef1721266..34e25697e2 100644 --- a/codex-rs/tui/src/app/resize_reflow.rs +++ b/codex-rs/tui/src/app/resize_reflow.rs @@ -27,11 +27,12 @@ use super::InitialHistoryReplayBuffer; use crate::history_cell; use crate::history_cell::HistoryCell; use crate::insert_history::HistoryLineWrapPolicy; +use crate::terminal_hyperlinks::HyperlinkLine; use crate::transcript_reflow::TRANSCRIPT_REFLOW_DEBOUNCE; use crate::tui; struct ReflowCellDisplay { - lines: Vec>, + lines: Vec, is_stream_continuation: bool, } @@ -41,7 +42,7 @@ struct ReflowCellDisplay { /// already-wrapped rows. Callers should keep treating `transcript_cells` as the source of truth; the /// rows here are a transient render product for a single terminal width. pub(super) struct ReflowRenderResult { - pub(super) lines: Vec>, + pub(super) lines: Vec, } pub(super) fn trailing_run_start(transcript_cells: &[Arc]) -> usize { @@ -75,12 +76,12 @@ impl App { &mut self, cell: &dyn HistoryCell, width: u16, - ) -> Vec> { + ) -> Vec { let mut display = - cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()); + cell.display_hyperlink_lines_for_mode(width, self.chat_widget.history_render_mode()); if !display.is_empty() && !cell.is_stream_continuation() { if self.has_emitted_history_lines { - display.insert(0, Line::from("")); + display.insert(/*index*/ 0, HyperlinkLine::new(Line::from(""))); } else { self.has_emitted_history_lines = true; } @@ -101,7 +102,10 @@ impl App { if self.overlay.is_some() { self.deferred_history_lines.extend(display); } else { - tui.insert_history_lines_with_wrap_policy(display, self.history_line_wrap_policy()); + tui.insert_history_hyperlink_lines_with_wrap_policy( + display, + self.history_line_wrap_policy(), + ); } } @@ -153,14 +157,20 @@ impl App { let width = tui.terminal.last_known_screen_size.width; let reflowed_lines = self.render_transcript_lines_for_reflow(width).lines; if !reflowed_lines.is_empty() { - tui.insert_history_lines(reflowed_lines); + tui.insert_history_hyperlink_lines_with_wrap_policy( + reflowed_lines, + self.history_line_wrap_policy(), + ); } } return; } let retained_lines = buffer.retained_lines.into_iter().collect::>(); - tui.insert_history_lines_with_wrap_policy(retained_lines, self.history_line_wrap_policy()); + tui.insert_history_hyperlink_lines_with_wrap_policy( + retained_lines, + self.history_line_wrap_policy(), + ); } pub(super) fn insert_history_cell_lines_with_initial_replay_buffer( @@ -190,7 +200,10 @@ impl App { } else if self.overlay.is_some() { self.deferred_history_lines.extend(display); } else { - tui.insert_history_lines_with_wrap_policy(display, self.history_line_wrap_policy()); + tui.insert_history_hyperlink_lines_with_wrap_policy( + display, + self.history_line_wrap_policy(), + ); } } } @@ -210,7 +223,7 @@ impl App { /// here would make copy, transcript overlay, and future replay paths disagree about history. pub(super) fn buffer_initial_history_replay_display_lines( buffer: &mut InitialHistoryReplayBuffer, - display: Vec>, + display: Vec, max_rows: usize, ) { buffer.retained_lines.extend(display); @@ -437,7 +450,7 @@ impl App { self.deferred_history_lines.clear(); if !reflowed_lines.is_empty() { - tui.insert_history_lines_with_wrap_policy( + tui.insert_history_hyperlink_lines_with_wrap_policy( reflowed_lines, self.history_line_wrap_policy(), ); @@ -462,7 +475,8 @@ impl App { while start > 0 { start -= 1; let cell = self.transcript_cells[start].clone(); - let lines = cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()); + let lines = cell + .display_hyperlink_lines_for_mode(width, self.chat_widget.history_render_mode()); rendered_rows += lines.len(); cell_displays.push_front(ReflowCellDisplay { lines, @@ -482,7 +496,10 @@ impl App { start -= 1; let cell = self.transcript_cells[start].clone(); cell_displays.push_front(ReflowCellDisplay { - lines: cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()), + lines: cell.display_hyperlink_lines_for_mode( + width, + self.chat_widget.history_render_mode(), + ), is_stream_continuation: cell.is_stream_continuation(), }); } @@ -492,7 +509,7 @@ impl App { for display in cell_displays { if !display.lines.is_empty() && !display.is_stream_continuation { if has_emitted_history_lines { - reflowed_lines.push(Line::from("")); + reflowed_lines.push(HyperlinkLine::new(Line::from(""))); } else { has_emitted_history_lines = true; } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index c2138ba31a..f4c8a4df79 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3906,8 +3906,9 @@ fn plain_line_cell(text: impl Into) -> Arc { Arc::new(PlainHistoryCell::new(vec![Line::from(text.into())])) as Arc } -fn rendered_line_text(line: &Line<'static>) -> String { - line.spans +fn rendered_line_text(line: &crate::terminal_hyperlinks::HyperlinkLine) -> String { + line.line + .spans .iter() .map(|span| span.content.as_ref()) .collect() @@ -4019,7 +4020,7 @@ async fn initial_replay_buffer_keeps_recent_rows_when_row_cap_present() { app.initial_history_replay_buffer .as_mut() .expect("initial replay buffer active"), - vec![Line::from(format!("line {index}"))], + vec![Line::from(format!("line {index}")).into()], /*max_rows*/ 3, ); } @@ -4925,7 +4926,7 @@ async fn queued_rollback_syncs_overlay_and_clears_deferred_history() { app.transcript_cells.clone(), app.keymap.pager.clone(), )); - app.deferred_history_lines = vec![Line::from("stale buffered line")]; + app.deferred_history_lines = vec![Line::from("stale buffered line").into()]; app.backtrack.overlay_preview_active = true; app.backtrack.nth_user_message = 1; @@ -5459,7 +5460,7 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { app.transcript_cells.clone(), crate::keymap::RuntimeKeymap::defaults().pager, )); - app.deferred_history_lines = vec![Line::from("stale buffered line")]; + app.deferred_history_lines = vec![Line::from("stale buffered line").into()]; app.has_emitted_history_lines = true; app.backtrack.primed = true; app.backtrack.overlay_preview_active = true; diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 625f8d0841..075a4a5ae2 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -245,7 +245,10 @@ impl App { let was_backtrack = self.backtrack.overlay_preview_active; if !self.deferred_history_lines.is_empty() { let lines = std::mem::take(&mut self.deferred_history_lines); - tui.insert_history_lines_with_wrap_policy(lines, self.history_line_wrap_policy()); + tui.insert_history_hyperlink_lines_with_wrap_policy( + lines, + self.history_line_wrap_policy(), + ); } self.overlay = None; self.backtrack.overlay_preview_active = false; @@ -263,8 +266,11 @@ impl App { .chat_widget .history_wrap_width(tui.terminal.last_known_screen_size.width); for cell in &self.transcript_cells { - tui.insert_history_lines_with_wrap_policy( - cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()), + tui.insert_history_hyperlink_lines_with_wrap_policy( + cell.display_hyperlink_lines_for_mode( + width, + self.chat_widget.history_render_mode(), + ), self.history_line_wrap_policy(), ); } @@ -399,7 +405,7 @@ impl App { tui.draw(u16::MAX, |frame| { let width = frame.area().width.max(1); t.sync_live_tail(width, active_key, |w| { - chat_widget.active_cell_transcript_lines(w) + chat_widget.active_cell_transcript_hyperlink_lines(w) }); t.render(frame.area(), frame.buffer); })?; diff --git a/codex-rs/tui/src/bottom_pane/app_link_view.rs b/codex-rs/tui/src/bottom_pane/app_link_view.rs index 7fe180a16f..bbd7f355c3 100644 --- a/codex-rs/tui/src/bottom_pane/app_link_view.rs +++ b/codex-rs/tui/src/bottom_pane/app_link_view.rs @@ -814,6 +814,7 @@ impl crate::render::renderable::Renderable for AppLinkView { Paragraph::new(lines) .wrap(Wrap { trim: false }) .render(inner, buf); + crate::terminal_hyperlinks::mark_url_hyperlink(buf, inner, &self.url); if actions_area.height > 0 { let actions_area = Rect { @@ -1005,7 +1006,10 @@ mod tests { if symbol.is_empty() { ' ' } else { - symbol.chars().next().unwrap_or(' ') + crate::terminal_hyperlinks::strip_osc8(symbol) + .chars() + .next() + .unwrap_or(' ') } }) .collect::() @@ -1325,7 +1329,10 @@ mod tests { if symbol.is_empty() { ' ' } else { - symbol.chars().next().unwrap_or(' ') + crate::terminal_hyperlinks::strip_osc8(symbol) + .chars() + .next() + .unwrap_or(' ') } }) .collect::() diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index 2770598fc4..ce380a867d 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -313,7 +313,7 @@ pub(crate) fn feedback_success_cell( include_logs: bool, thread_id: &str, feedback_audience: FeedbackAudience, -) -> history_cell::PlainHistoryCell { +) -> history_cell::WebHyperlinkHistoryCell { let prefix = if include_logs { "• Feedback uploaded." } else { @@ -359,7 +359,7 @@ pub(crate) fn feedback_success_cell( ]); } } - history_cell::PlainHistoryCell::new(lines) + history_cell::WebHyperlinkHistoryCell::new(lines) } fn issue_url_for_category( diff --git a/codex-rs/tui/src/bottom_pane/memories_settings_view.rs b/codex-rs/tui/src/bottom_pane/memories_settings_view.rs index 06e9b31eb8..793596b468 100644 --- a/codex-rs/tui/src/bottom_pane/memories_settings_view.rs +++ b/codex-rs/tui/src/bottom_pane/memories_settings_view.rs @@ -421,6 +421,7 @@ impl Renderable for MemoriesSettingsView { } if self.reset_confirmation.is_none() { self.docs_link.clone().render(docs_area, buf); + crate::terminal_hyperlinks::mark_url_hyperlink(buf, docs_area, MEMORIES_DOC_URL); } let hint_area = Rect { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 16ac980f35..d3e5cf68ef 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -10,7 +10,8 @@ //! visible immediately. //! //! The transcript overlay is kept in sync by `App::overlay_forward_event`, which syncs a live tail -//! during draws using `active_cell_transcript_key()` and `active_cell_transcript_lines()`. The +//! during draws using `active_cell_transcript_key()` and +//! `active_cell_transcript_hyperlink_lines()`. The //! cache key is designed to change when the active cell mutates in place or when its transcript //! output is time-dependent so the overlay can refresh its cached tail without rebuilding it on //! every draw. @@ -77,6 +78,7 @@ use crate::status::StatusHistoryHandle; use crate::status::format_directory_display; use crate::status::format_tokens_compact; use crate::status::rate_limit_snapshot_display_for_limit; +use crate::terminal_hyperlinks::HyperlinkLine; use crate::terminal_title::SetTerminalTitleResult; use crate::terminal_title::clear_terminal_title; use crate::terminal_title::set_terminal_title; @@ -1824,28 +1826,37 @@ impl ChatWidget { }) } - /// Returns the active cell's transcript lines for a given terminal width. + /// Returns the active cell's annotated transcript lines for a given terminal width. /// /// This is a convenience for the transcript overlay live-tail path, and it intentionally /// filters out empty results so the overlay can treat "nothing to render" as "no tail". Callers /// should pass the same width the overlay uses; using a different width will cause wrapping /// mismatches between the main viewport and the transcript overlay. - pub(crate) fn active_cell_transcript_lines(&self, width: u16) -> Option>> { + pub(crate) fn active_cell_transcript_hyperlink_lines( + &self, + width: u16, + ) -> Option> { let mut lines = Vec::new(); if let Some(cell) = self.transcript.active_cell.as_ref() { - lines.extend(cell.transcript_lines(width)); + lines.extend(cell.transcript_hyperlink_lines(width)); } if let Some(hook_cell) = self.active_hook_cell.as_ref() { // Compute hook lines first so hidden hooks do not add a separator. - let hook_lines = hook_cell.transcript_lines(width); + let hook_lines = hook_cell.transcript_hyperlink_lines(width); if !hook_lines.is_empty() && !lines.is_empty() { - lines.push("".into()); + lines.push(HyperlinkLine::from("")); } lines.extend(hook_lines); } (!lines.is_empty()).then_some(lines) } + #[cfg(test)] + pub(crate) fn active_cell_transcript_lines(&self, width: u16) -> Option>> { + self.active_cell_transcript_hyperlink_lines(width) + .map(crate::terminal_hyperlinks::visible_lines) + } + /// Return a reference to the widget's current config (includes any /// runtime overrides applied via TUI, e.g., model or approval policy). pub(crate) fn config_ref(&self) -> &Config { diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 55aadefb6d..ade74b8908 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -2200,7 +2200,7 @@ async fn memories_settings_popup_snapshot() { chat.open_memories_popup(); - let popup = render_bottom_popup(&chat, /*width*/ 80); + let popup = strip_osc8_for_snapshot(&render_bottom_popup(&chat, /*width*/ 80)); assert_chatwidget_snapshot!("memories_settings_popup", popup); } diff --git a/codex-rs/tui/src/history_cell/base.rs b/codex-rs/tui/src/history_cell/base.rs index e3b33aa9d1..e9ade84945 100644 --- a/codex-rs/tui/src/history_cell/base.rs +++ b/codex-rs/tui/src/history_cell/base.rs @@ -22,6 +22,35 @@ impl HistoryCell for PlainHistoryCell { plain_lines(self.lines.clone()) } } + +#[derive(Debug)] +pub(crate) struct WebHyperlinkHistoryCell { + lines: Vec>, +} + +impl WebHyperlinkHistoryCell { + pub(crate) fn new(lines: Vec>) -> Self { + Self { lines } + } +} + +impl HistoryCell for WebHyperlinkHistoryCell { + fn display_lines(&self, _width: u16) -> Vec> { + self.lines.clone() + } + + fn display_hyperlink_lines(&self, _width: u16) -> Vec { + crate::terminal_hyperlinks::annotate_web_urls(self.lines.clone()) + } + + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } + + fn raw_lines(&self) -> Vec> { + plain_lines(self.lines.clone()) + } +} #[derive(Debug)] pub(crate) struct PrefixedWrappedHistoryCell { text: Text<'static>, @@ -86,6 +115,38 @@ impl HistoryCell for CompositeHistoryCell { out } + fn display_hyperlink_lines(&self, width: u16) -> Vec { + let mut out = Vec::new(); + let mut first = true; + for part in &self.parts { + let mut lines = part.display_hyperlink_lines(width); + if !lines.is_empty() { + if !first { + out.push(HyperlinkLine::from("")); + } + out.append(&mut lines); + first = false; + } + } + out + } + + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + let mut out = Vec::new(); + let mut first = true; + for part in &self.parts { + let mut lines = part.transcript_hyperlink_lines(width); + if !lines.is_empty() { + if !first { + out.push(HyperlinkLine::from("")); + } + out.append(&mut lines); + first = false; + } + } + out + } + fn raw_lines(&self) -> Vec> { let mut out: Vec> = Vec::new(); let mut first = true; diff --git a/codex-rs/tui/src/history_cell/mcp.rs b/codex-rs/tui/src/history_cell/mcp.rs index 7b1e8eebc4..599a44f092 100644 --- a/codex-rs/tui/src/history_cell/mcp.rs +++ b/codex-rs/tui/src/history_cell/mcp.rs @@ -325,14 +325,17 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell { " • No MCP servers configured.".italic().into(), Line::from(vec![ " See the ".into(), - "\u{1b}]8;;https://developers.openai.com/codex/mcp\u{7}MCP docs\u{1b}]8;;\u{7}" - .underlined(), + crate::terminal_hyperlinks::osc8_hyperlink( + "https://developers.openai.com/codex/mcp", + "MCP docs", + ) + .underlined(), " to configure them.".into(), ]) .style(Style::default().add_modifier(Modifier::DIM)), ]; - PlainHistoryCell { lines } + PlainHistoryCell::new(lines) } #[cfg(test)] diff --git a/codex-rs/tui/src/history_cell/messages.rs b/codex-rs/tui/src/history_cell/messages.rs index 19a6ec2903..ed899f3b2e 100644 --- a/codex-rs/tui/src/history_cell/messages.rs +++ b/codex-rs/tui/src/history_cell/messages.rs @@ -268,12 +268,20 @@ impl HistoryCell for ReasoningSummaryCell { #[derive(Debug)] pub(crate) struct AgentMessageCell { - lines: Vec>, + lines: Vec, is_first_line: bool, } impl AgentMessageCell { + #[cfg(test)] pub(crate) fn new(lines: Vec>, is_first_line: bool) -> Self { + Self { + lines: plain_hyperlink_lines(lines), + is_first_line, + } + } + + pub(crate) fn new_hyperlink_lines(lines: Vec, is_first_line: bool) -> Self { Self { lines, is_first_line, @@ -283,6 +291,10 @@ impl AgentMessageCell { impl HistoryCell for AgentMessageCell { fn display_lines(&self, width: u16) -> Vec> { + visible_lines(self.display_hyperlink_lines(width)) + } + + fn display_hyperlink_lines(&self, width: u16) -> Vec { let mut wrapped = Vec::new(); for (index, line) in self.lines.iter().enumerate() { let initial_indent = if index == 0 && self.is_first_line { @@ -293,20 +305,23 @@ impl HistoryCell for AgentMessageCell { let mut subsequent_indent = Line::from(" "); subsequent_indent .spans - .extend(crate::insert_history::leading_whitespace_prefix(line).spans); - let line_wrapped = adaptive_wrap_line( - line, + .extend(crate::insert_history::leading_whitespace_prefix(&line.line).spans); + wrapped.extend(crate::terminal_hyperlinks::adaptive_wrap_hyperlink_lines( + std::slice::from_ref(line), RtOptions::new(width as usize) .initial_indent(initial_indent) .subsequent_indent(subsequent_indent), - ); - push_owned_lines(&line_wrapped, &mut wrapped); + )); } wrapped } + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } + fn raw_lines(&self) -> Vec> { - plain_lines(self.lines.clone()) + plain_lines(visible_lines(self.lines.clone())) } fn is_stream_continuation(&self) -> bool { @@ -347,22 +362,32 @@ impl AgentMarkdownCell { impl HistoryCell for AgentMarkdownCell { fn display_lines(&self, width: u16) -> Vec> { + visible_lines(self.display_hyperlink_lines(width)) + } + + fn display_hyperlink_lines(&self, width: u16) -> Vec { let Some(wrap_width) = crate::width::usable_content_width_u16(width, /*reserved_cols*/ 2) else { - return prefix_lines(vec![Line::default()], "• ".dim(), " ".into()); + return prefix_hyperlink_lines( + vec![HyperlinkLine::new(Line::default())], + "• ".dim(), + " ".into(), + ); }; - let mut lines: Vec> = Vec::new(); // Re-render markdown from source at the current width. Reserve 2 columns for the "• " / // " " prefix prepended below. - crate::markdown::append_markdown_agent_with_cwd( + let lines = crate::markdown::render_markdown_agent_with_links_and_cwd( &self.markdown_source, Some(wrap_width), Some(self.cwd.as_path()), - &mut lines, ); - prefix_lines(lines, "• ".dim(), " ".into()) + prefix_hyperlink_lines(lines, "• ".dim(), " ".into()) + } + + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) } fn raw_lines(&self) -> Vec> { @@ -377,12 +402,12 @@ impl HistoryCell for AgentMarkdownCell { /// every delta and cleared when the stream finalizes. #[derive(Debug)] pub(crate) struct StreamingAgentTailCell { - lines: Vec>, + lines: Vec, is_first_line: bool, } impl StreamingAgentTailCell { - pub(crate) fn new(lines: Vec>, is_first_line: bool) -> Self { + pub(crate) fn new(lines: Vec, is_first_line: bool) -> Self { Self { lines, is_first_line, @@ -391,10 +416,14 @@ impl StreamingAgentTailCell { } impl HistoryCell for StreamingAgentTailCell { - fn display_lines(&self, _width: u16) -> Vec> { + fn display_lines(&self, width: u16) -> Vec> { + visible_lines(self.display_hyperlink_lines(width)) + } + + fn display_hyperlink_lines(&self, _width: u16) -> Vec { // Tail lines are already rendered at the controller's current stream width. // Re-wrapping them here can split table borders and produce malformed in-flight rows. - prefix_lines( + prefix_hyperlink_lines( self.lines.clone(), if self.is_first_line { "• ".dim() @@ -405,8 +434,12 @@ impl HistoryCell for StreamingAgentTailCell { ) } + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } + fn raw_lines(&self) -> Vec> { - plain_lines(self.display_lines(u16::MAX)) + plain_lines(self.display_lines(/*width*/ u16::MAX)) } fn is_stream_continuation(&self) -> bool { diff --git a/codex-rs/tui/src/history_cell/mod.rs b/codex-rs/tui/src/history_cell/mod.rs index 12788a59f0..9cef3e4fa5 100644 --- a/codex-rs/tui/src/history_cell/mod.rs +++ b/codex-rs/tui/src/history_cell/mod.rs @@ -22,7 +22,6 @@ use crate::exec_command::strip_bash_lc_and_escape; use crate::legacy_core::config::Config; use crate::live_wrap::take_prefix_by_width; use crate::markdown::append_markdown; -use crate::markdown::append_markdown_agent_with_cwd; use crate::motion::MotionMode; use crate::motion::ReducedMotionIndicator; use crate::motion::activity_indicator; @@ -33,6 +32,11 @@ use crate::render::renderable::Renderable; use crate::session_state::ThreadSessionState; use crate::style::proposed_plan_style; use crate::style::user_message_style; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::mark_buffer_hyperlinks; +use crate::terminal_hyperlinks::plain_hyperlink_lines; +use crate::terminal_hyperlinks::prefix_hyperlink_lines; +use crate::terminal_hyperlinks::visible_lines; #[cfg(test)] use crate::test_support::PathBufExt; #[cfg(test)] @@ -189,13 +193,29 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { /// Returns copy-friendly plain logical lines for raw scrollback mode. fn raw_lines(&self) -> Vec>; + /// Returns rich visible lines plus terminal hyperlink metadata. + fn display_hyperlink_lines(&self, width: u16) -> Vec { + plain_hyperlink_lines(self.display_lines(width)) + } + fn display_lines_for_mode(&self, width: u16, mode: HistoryRenderMode) -> Vec> { match mode { - HistoryRenderMode::Rich => self.display_lines(width), + HistoryRenderMode::Rich => visible_lines(self.display_hyperlink_lines(width)), HistoryRenderMode::Raw => self.raw_lines(), } } + fn display_hyperlink_lines_for_mode( + &self, + width: u16, + mode: HistoryRenderMode, + ) -> Vec { + match mode { + HistoryRenderMode::Rich => self.display_hyperlink_lines(width), + HistoryRenderMode::Raw => plain_hyperlink_lines(self.raw_lines()), + } + } + /// Returns the number of viewport rows needed to render this cell. /// /// The default delegates to `Paragraph::line_count` with @@ -224,13 +244,22 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { self.display_lines(width) } + /// Returns transcript-overlay lines plus terminal hyperlink metadata. + /// + /// Defaults to the plain transcript representation because some cells render different + /// display and transcript content. Rich cells whose transcript mirrors their display should + /// delegate to `display_hyperlink_lines`. + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + plain_hyperlink_lines(self.transcript_lines(width)) + } + /// Returns the number of viewport rows for the transcript overlay. /// /// Uses the same `Paragraph::line_count` measurement as /// `desired_height`. Contains a workaround for a ratatui bug where /// a single whitespace-only line reports 2 rows instead of 1. fn desired_transcript_height(&self, width: u16) -> u16 { - let lines = self.transcript_lines(width); + let lines = visible_lines(self.transcript_hyperlink_lines(width)); // Workaround: ratatui's line_count returns 2 for a single // whitespace-only line. Clamp to 1 in that case. if let [line] = &lines[..] @@ -270,7 +299,8 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { impl Renderable for Box { fn render(&self, area: Rect, buf: &mut Buffer) { - let lines = self.display_lines(area.width); + let hyperlink_lines = self.display_hyperlink_lines(area.width); + let lines = visible_lines(hyperlink_lines.clone()); let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); let y = if area.height == 0 { 0 @@ -284,6 +314,7 @@ impl Renderable for Box { // entire draw area first so stale glyphs from previous frames never linger. Clear.render(area, buf); paragraph.scroll((y, 0)).render(area, buf); + mark_buffer_hyperlinks(buf, area, &hyperlink_lines, usize::from(y)); } fn desired_height(&self, width: u16) -> u16 { HistoryCell::desired_height(self.as_ref(), width) diff --git a/codex-rs/tui/src/history_cell/notices.rs b/codex-rs/tui/src/history_cell/notices.rs index 7fb994442c..d9dff4f5a0 100644 --- a/codex-rs/tui/src/history_cell/notices.rs +++ b/codex-rs/tui/src/history_cell/notices.rs @@ -71,6 +71,14 @@ impl HistoryCell for UpdateAvailableHistoryCell { Line::from("https://github.com/openai/codex/releases/latest"), ] } + + fn display_hyperlink_lines(&self, width: u16) -> Vec { + crate::terminal_hyperlinks::annotate_web_urls(self.display_lines(width)) + } + + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } } #[allow(clippy::disallowed_methods)] pub(crate) fn new_warning_event(message: String) -> PrefixedWrappedHistoryCell { @@ -129,6 +137,14 @@ impl HistoryCell for CyberPolicyNoticeCell { Line::from(TRUSTED_ACCESS_FOR_CYBER_URL), ] } + + fn display_hyperlink_lines(&self, width: u16) -> Vec { + crate::terminal_hyperlinks::annotate_web_urls(self.display_lines(width)) + } + + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } } #[derive(Debug)] diff --git a/codex-rs/tui/src/history_cell/plans.rs b/codex-rs/tui/src/history_cell/plans.rs index 6ea56195da..6b644f9e36 100644 --- a/codex-rs/tui/src/history_cell/plans.rs +++ b/codex-rs/tui/src/history_cell/plans.rs @@ -9,12 +9,12 @@ use super::*; /// preview-only during streaming. #[derive(Debug)] pub(crate) struct StreamingPlanTailCell { - lines: Vec>, + lines: Vec, is_stream_continuation: bool, } impl StreamingPlanTailCell { - pub(crate) fn new(lines: Vec>, is_stream_continuation: bool) -> Self { + pub(crate) fn new(lines: Vec, is_stream_continuation: bool) -> Self { Self { lines, is_stream_continuation, @@ -24,11 +24,19 @@ impl StreamingPlanTailCell { impl HistoryCell for StreamingPlanTailCell { fn display_lines(&self, _width: u16) -> Vec> { + visible_lines(self.lines.clone()) + } + + fn display_hyperlink_lines(&self, _width: u16) -> Vec { self.lines.clone() } + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } + fn raw_lines(&self) -> Vec> { - plain_lines(self.lines.clone()) + plain_lines(visible_lines(self.lines.clone())) } fn is_stream_continuation(&self) -> bool { @@ -58,11 +66,11 @@ pub(crate) fn new_proposed_plan(plan_markdown: String, cwd: &Path) -> ProposedPl /// Stream cells are display fragments, not source-backed history. They should be replaced by /// `ProposedPlanCell` during consolidation before relying on resize reflow for finalized history. pub(crate) fn new_proposed_plan_stream( - lines: Vec>, + lines: Vec>, is_stream_continuation: bool, ) -> ProposedPlanStreamCell { ProposedPlanStreamCell { - lines, + lines: lines.into_iter().map(Into::into).collect(), is_stream_continuation, } } @@ -85,36 +93,43 @@ pub(crate) struct ProposedPlanCell { /// terminal resize. #[derive(Debug)] pub(crate) struct ProposedPlanStreamCell { - lines: Vec>, + lines: Vec, is_stream_continuation: bool, } impl HistoryCell for ProposedPlanCell { fn display_lines(&self, width: u16) -> Vec> { - let mut lines: Vec> = Vec::new(); - lines.push(vec!["• ".dim(), "Proposed Plan".bold()].into()); - lines.push(Line::from(" ")); + visible_lines(self.display_hyperlink_lines(width)) + } - let mut plan_lines: Vec> = vec![Line::from(" ")]; + fn display_hyperlink_lines(&self, width: u16) -> Vec { + let mut lines = vec![ + HyperlinkLine::new(vec!["• ".dim(), "Proposed Plan".bold()].into()), + HyperlinkLine::new(Line::from(" ")), + ]; + + let mut plan_lines = vec![HyperlinkLine::new(Line::from(" "))]; let plan_style = proposed_plan_style(); let wrap_width = width.saturating_sub(4).max(1) as usize; - let mut body: Vec> = Vec::new(); - append_markdown_agent_with_cwd( + let mut body = crate::markdown::render_markdown_agent_with_links_and_cwd( &self.plan_markdown, Some(wrap_width), Some(self.cwd.as_path()), - &mut body, ); if body.is_empty() { - body.push(Line::from("(empty)".dim().italic())); + body.push(HyperlinkLine::new(Line::from("(empty)".dim().italic()))); } - plan_lines.extend(prefix_lines(body, " ".into(), " ".into())); - plan_lines.push(Line::from(" ")); + plan_lines.extend(prefix_hyperlink_lines(body, " ".into(), " ".into())); + plan_lines.push(HyperlinkLine::new(Line::from(" "))); lines.extend(plan_lines.into_iter().map(|line| line.style(plan_style))); lines } + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } + fn raw_lines(&self) -> Vec> { raw_lines_from_source(&self.plan_markdown) } @@ -122,11 +137,19 @@ impl HistoryCell for ProposedPlanCell { impl HistoryCell for ProposedPlanStreamCell { fn display_lines(&self, _width: u16) -> Vec> { + visible_lines(self.lines.clone()) + } + + fn display_hyperlink_lines(&self, _width: u16) -> Vec { self.lines.clone() } + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } + fn raw_lines(&self) -> Vec> { - plain_lines(self.lines.clone()) + plain_lines(visible_lines(self.lines.clone())) } fn is_stream_continuation(&self) -> bool { diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 6c83982a41..b4674cec8a 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -295,6 +295,47 @@ fn proposed_plan_cell_renders_markdown_table() { ); } +#[test] +fn proposed_plan_cell_preserves_wrapped_table_web_links() { + let destination = "https://example.com/a/very/long/path/to/a/table/artifact"; + let plan = new_proposed_plan( + format!("| Step | URL |\n| --- | --- |\n| Verify | {destination} |\n"), + &test_cwd(), + ); + + let lines = plan.display_hyperlink_lines(/*width*/ 32); + let linked_rows = lines + .iter() + .filter(|line| !line.hyperlinks.is_empty()) + .collect::>(); + + assert!(linked_rows.len() > 1); + assert!(linked_rows.iter().all(|line| { + line.hyperlinks + .iter() + .all(|link| link.destination == destination) + })); +} + +#[test] +fn composite_cell_preserves_child_web_links() { + let destination = "https://chatgpt.com/codex/settings/usage"; + let cell = CompositeHistoryCell::new(vec![ + Box::new(PlainHistoryCell::new(vec![Line::from("/status")])), + Box::new(WebHyperlinkHistoryCell::new(vec![Line::from(destination)])), + ]); + + let lines = cell.display_hyperlink_lines(/*width*/ 80); + + assert_eq!( + lines[2].hyperlinks, + vec![crate::terminal_hyperlinks::TerminalHyperlink { + columns: 0..destination.len(), + destination: destination.to_string(), + }] + ); +} + #[test] fn proposed_plan_cell_unwraps_markdown_fenced_table() { let plan = new_proposed_plan( diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 40da6ccbcc..760b516421 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -7,6 +7,11 @@ use std::fmt; use std::io; use std::io::Write; +use crate::render::line_utils::line_to_static; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::decorate_spans; +use crate::terminal_hyperlinks::plain_hyperlink_lines; +use crate::terminal_hyperlinks::remap_wrapped_line; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_line; use crate::wrapping::line_contains_url_like; @@ -85,6 +90,23 @@ pub(crate) fn insert_history_lines_with_mode_and_wrap_policy( mode: InsertHistoryMode, wrap_policy: HistoryLineWrapPolicy, ) -> io::Result<()> +where + B: Backend + Write, +{ + insert_history_hyperlink_lines_with_mode_and_wrap_policy( + terminal, + plain_hyperlink_lines(lines.iter().map(line_to_static).collect()), + mode, + wrap_policy, + ) +} + +pub(crate) fn insert_history_hyperlink_lines_with_mode_and_wrap_policy( + terminal: &mut crate::custom_terminal::Terminal, + lines: Vec, + mode: InsertHistoryMode, + wrap_policy: HistoryLineWrapPolicy, +) -> io::Result<()> where B: Backend + Write, { @@ -113,13 +135,21 @@ where let line_wrapped = match wrap_policy { HistoryLineWrapPolicy::Terminal => vec![line.clone()], HistoryLineWrapPolicy::PreWrap - if line_contains_url_like(line) && !line_has_mixed_url_and_non_url_tokens(line) => + if line_contains_url_like(&line.line) + && !line_has_mixed_url_and_non_url_tokens(&line.line) => { vec![line.clone()] } - HistoryLineWrapPolicy::PreWrap => adaptive_wrap_line( + HistoryLineWrapPolicy::PreWrap => remap_wrapped_line( line, - RtOptions::new(wrap_width).subsequent_indent(leading_whitespace_prefix(line)), + adaptive_wrap_line( + &line.line, + RtOptions::new(wrap_width) + .subsequent_indent(leading_whitespace_prefix(&line.line)), + ) + .into_iter() + .map(|line| line_to_static(&line)) + .collect(), ), }; wrapped_rows += line_wrapped @@ -249,7 +279,11 @@ pub(crate) fn leading_whitespace_prefix(line: &Line<'_>) -> Line<'static> { /// Render a single wrapped history line: clear continuation rows for wide lines, /// set foreground/background colors, and write styled spans. Caller is responsible /// for cursor positioning and any leading `\r\n`. -fn write_history_line(writer: &mut W, line: &Line, wrap_width: usize) -> io::Result<()> { +fn write_history_line( + writer: &mut W, + line: &HyperlinkLine, + wrap_width: usize, +) -> io::Result<()> { let physical_rows = line.width().max(1).div_ceil(wrap_width) as u16; if physical_rows > 1 { queue!(writer, SavePosition)?; @@ -262,11 +296,13 @@ fn write_history_line(writer: &mut W, line: &Line, wrap_width: usize) queue!( writer, SetColors(Colors::new( - line.style + line.line + .style .fg .map(std::convert::Into::into) .unwrap_or(CColor::Reset), - line.style + line.line + .style .bg .map(std::convert::Into::into) .unwrap_or(CColor::Reset) @@ -276,14 +312,20 @@ fn write_history_line(writer: &mut W, line: &Line, wrap_width: usize) // Merge line-level style into each span so that ANSI colors reflect // line styles (e.g., blockquotes with green fg). let merged_spans: Vec = line + .line .spans .iter() .map(|s| Span { - style: s.style.patch(line.style), + style: s.style.patch(line.line.style), content: s.content.clone(), }) .collect(); - write_spans(writer, merged_spans.iter()) + let merged_line = HyperlinkLine { + line: Line::from(merged_spans), + hyperlinks: line.hyperlinks.clone(), + }; + let decorated = decorate_spans(&merged_line); + write_spans(writer, decorated.iter()) } #[derive(Debug, Clone, PartialEq, Eq)] @@ -470,6 +512,19 @@ mod tests { ); } + #[test] + fn writes_semantic_web_link_without_changing_visible_text() { + let destination = "https://example.com/long/path"; + let line = crate::terminal_hyperlinks::annotate_web_urls_in_line(Line::from(destination)); + let mut actual = Vec::new(); + + write_history_line(&mut actual, &line, /*wrap_width*/ 80).expect("write history line"); + + let output = String::from_utf8(actual).expect("UTF-8 terminal output"); + assert!(output.contains("\x1b]8;;https://example.com/long/path\x07")); + assert_eq!(line.line.spans[0].content, destination); + } + #[test] fn vt100_blockquote_line_emits_green_fg() { // Set up a small off-screen terminal diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 34065b7df8..a90fd7a362 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -177,6 +177,7 @@ mod status; mod status_indicator_widget; mod streaming; mod style; +mod terminal_hyperlinks; mod terminal_palette; mod terminal_probe; mod terminal_title; diff --git a/codex-rs/tui/src/markdown.rs b/codex-rs/tui/src/markdown.rs index 0cabee0805..31b8d09a76 100644 --- a/codex-rs/tui/src/markdown.rs +++ b/codex-rs/tui/src/markdown.rs @@ -25,6 +25,7 @@ use std::ops::Range; use std::path::Path; use crate::table_detect; +use crate::terminal_hyperlinks::HyperlinkLine; /// Render markdown source to styled ratatui lines and append them to `lines`. /// @@ -56,20 +57,22 @@ pub(crate) fn append_markdown_agent( width: Option, lines: &mut Vec>, ) { - append_markdown_agent_with_cwd(markdown_source, width, /*cwd*/ None, lines); + let normalized = unwrap_markdown_fences(markdown_source); + let rendered = crate::markdown_render::render_markdown_text_with_width_and_cwd( + &normalized, + width, + /*cwd*/ None, + ); + crate::render::line_utils::push_owned_lines(&rendered.lines, lines); } -/// Render an agent message while resolving local file links relative to `cwd`. -pub(crate) fn append_markdown_agent_with_cwd( +pub(crate) fn render_markdown_agent_with_links_and_cwd( markdown_source: &str, width: Option, cwd: Option<&Path>, - lines: &mut Vec>, -) { +) -> Vec { let normalized = unwrap_markdown_fences(markdown_source); - let rendered = - crate::markdown_render::render_markdown_text_with_width_and_cwd(&normalized, width, cwd); - crate::render::line_utils::push_owned_lines(&rendered.lines, lines); + crate::markdown_render::render_markdown_lines_with_width_and_cwd(&normalized, width, cwd) } /// Strip `` ```md ``/`` ```markdown `` fences that contain tables, emitting their content as bare diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index 87861989c0..61792b464c 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -40,8 +40,12 @@ use crate::render::highlight::foreground_style_for_scopes; use crate::render::highlight::highlight_code_to_lines; use crate::render::line_utils::line_to_static; -use crate::render::line_utils::push_owned_lines; use crate::style::table_separator_style; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::annotate_web_urls_in_line; +use crate::terminal_hyperlinks::remap_wrapped_line; +use crate::terminal_hyperlinks::visible_lines; +use crate::terminal_hyperlinks::web_destination; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_line; use crate::wrapping::word_wrap_line; @@ -66,6 +70,7 @@ use std::ops::Range; use std::path::Path; use std::path::PathBuf; use std::sync::LazyLock; +use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthStr; use url::Url; @@ -136,7 +141,7 @@ impl IndentContext { /// `lines` are used for final rendering. #[derive(Clone, Debug, Default)] struct TableCell { - lines: Vec>, + lines: Vec, } // TableCell mutators inlined — called per-span during table event parsing. @@ -144,7 +149,7 @@ impl TableCell { #[inline] fn ensure_line(&mut self) { if self.lines.is_empty() { - self.lines.push(Line::default()); + self.lines.push(HyperlinkLine::new(Line::default())); } } @@ -152,13 +157,26 @@ impl TableCell { fn push_span(&mut self, span: Span<'static>) { self.ensure_line(); if let Some(line) = self.lines.last_mut() { - line.push_span(span); + line.line.push_span(span); + } + } + + fn push_annotated(&mut self, mut appended: HyperlinkLine) { + self.ensure_line(); + if let Some(line) = self.lines.last_mut() { + let shift = line.width(); + line.line.spans.append(&mut appended.line.spans); + line.hyperlinks + .extend(appended.hyperlinks.into_iter().map(|mut link| { + link.columns = link.columns.start + shift..link.columns.end + shift; + link + })); } } #[inline] fn hard_break(&mut self) { - self.lines.push(Line::default()); + self.lines.push(HyperlinkLine::new(Line::default())); } fn plain_text(&self) -> String { @@ -168,7 +186,7 @@ impl TableCell { if i > 0 { buf.push(' '); } - for span in &line.spans { + for span in &line.line.spans { let _ = write!(buf, "{}", span.content); } } @@ -220,9 +238,9 @@ impl TableState { /// `spillover_lines` are prose rows extracted from parser artifacts and should /// be routed through normal wrapping. struct RenderedTableLines { - table_lines: Vec>, + table_lines: Vec, table_lines_prewrapped: bool, - spillover_lines: Vec>, + spillover_lines: Vec, } /// Classification of a table column for width-allocation priority. @@ -287,6 +305,16 @@ pub(crate) fn render_markdown_text_with_width_and_cwd( width: Option, cwd: Option<&Path>, ) -> Text<'static> { + Text::from(visible_lines(render_markdown_lines_with_width_and_cwd( + input, width, cwd, + ))) +} + +pub(crate) fn render_markdown_lines_with_width_and_cwd( + input: &str, + width: Option, + cwd: Option<&Path>, +) -> Vec { let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); options.insert(Options::ENABLE_TABLES); @@ -338,7 +366,7 @@ where { input: &'a str, iter: I, - text: Text<'static>, + text: Vec, styles: MarkdownStyles, inline_styles: Vec