From 7a8092a4479ad401c36b58580f1a3aeafe9ce890 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 4 Sep 2026 20:25:03 +0000 Subject: [PATCH] Preserve Markdown formatting when copying TUI responses (#42847) ## What changed - Add rendered HTML alongside the original Markdown when copying a whole response through the native clipboard, so rich-text destinations preserve headings, lists, tables, code, and other formatting. - Keep code-block, blockquote, transcript, terminal, and WSL clipboard copies as plain text. - Escape raw HTML, omit remote image sources, and render unsupported link destinations as visible text when producing clipboard HTML. ## Testing - Add coverage for rich formatting, local and unsafe links, inert images, fenced tables, copy selection formats, and clipboard fallbacks. GitOrigin-RevId: c6f5edd88b65f9e9b1ddc5618442868cf47ee560 --- MODULE.bazel.lock | 1 + codex-rs/Cargo.lock | 7 + codex-rs/core/tests/suite/turn_state.rs | 6 + codex-rs/tui/Cargo.toml | 2 +- codex-rs/tui/src/app/event_dispatch.rs | 4 +- codex-rs/tui/src/app_event.rs | 1 + codex-rs/tui/src/chatwidget/interaction.rs | 27 +++- .../src/chatwidget/tests/slash_commands.rs | 33 ++++- .../tui/src/chatwidget/transcript_export.rs | 5 +- codex-rs/tui/src/clipboard_copy.rs | 140 ++++++++++++------ codex-rs/tui/src/clipboard_html.rs | 49 ++++++ codex-rs/tui/src/clipboard_html_tests.rs | 77 ++++++++++ codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/markdown.rs | 2 +- codex-rs/tui/src/session_log_tests.rs | 2 + ...ard_preserves_local_link_destinations.snap | 12 ++ ...n_clipboard_preserves_rich_formatting.snap | 17 +++ 17 files changed, 329 insertions(+), 57 deletions(-) create mode 100644 codex-rs/tui/src/clipboard_html.rs create mode 100644 codex-rs/tui/src/clipboard_html_tests.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__clipboard_html__tests__markdown_clipboard_preserves_local_link_destinations.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__clipboard_html__tests__markdown_clipboard_preserves_rich_formatting.snap diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 55590eafc2..a0a27c646a 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1502,6 +1502,7 @@ "ptr_meta_0.3.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ptr_meta_derive\",\"optional\":true,\"req\":\"=0.3.2\"}],\"features\":{\"default\":[\"derive\",\"std\"],\"derive\":[\"ptr_meta_derive\"],\"std\":[]}}", "ptr_meta_derive_0.3.2": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"proc-macro\"],\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"proc-macro\"],\"name\":\"quote\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"derive\",\"full\",\"parsing\",\"printing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^3.0\"}],\"features\":{}}", "publicsuffix_2.3.0": "{\"dependencies\":[{\"features\":[\"inline-more\"],\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15.1\"},{\"name\":\"idna\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"psl-types\",\"req\":\"^2.0.11\"},{\"kind\":\"dev\",\"name\":\"rspec\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"unicase\",\"optional\":true,\"req\":\"^2.6.0\"}],\"features\":{\"anycase\":[\"unicase\"],\"default\":[\"punycode\"],\"punycode\":[\"idna\"],\"std\":[]}}", + "pulldown-cmark-escape_0.10.1": "{\"dependencies\":[],\"features\":{\"simd\":[]}}", "pulldown-cmark_0.10.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"name\":\"bitflags\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"getopts\",\"optional\":true,\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"name\":\"memchr\",\"req\":\"^2.5\"},{\"name\":\"pulldown-cmark-escape\",\"optional\":true,\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.6\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.61\"},{\"name\":\"unicase\",\"req\":\"^2.6\"}],\"features\":{\"default\":[\"getopts\",\"html\"],\"gen-tests\":[],\"html\":[\"pulldown-cmark-escape\"],\"simd\":[\"pulldown-cmark-escape?/simd\"]}}", "pxfm_0.1.27": "{\"dependencies\":[{\"name\":\"num-traits\",\"req\":\"^0.2.3\"}],\"features\":{}}", "pyo3-build-config_0.28.3": "{\"dependencies\":[{\"name\":\"python3-dll-a\",\"optional\":true,\"req\":\"^0.2.12\"},{\"kind\":\"build\",\"name\":\"python3-dll-a\",\"optional\":true,\"req\":\"^0.2.12\"},{\"name\":\"target-lexicon\",\"req\":\"^0.13.3\"},{\"kind\":\"build\",\"name\":\"target-lexicon\",\"req\":\"^0.13.3\"}],\"features\":{\"abi3\":[],\"abi3-py310\":[\"abi3-py311\"],\"abi3-py311\":[\"abi3-py312\"],\"abi3-py312\":[\"abi3-py313\"],\"abi3-py313\":[\"abi3-py314\"],\"abi3-py314\":[\"abi3\"],\"abi3-py37\":[\"abi3-py38\"],\"abi3-py38\":[\"abi3-py39\"],\"abi3-py39\":[\"abi3-py310\"],\"default\":[],\"extension-module\":[],\"generate-import-lib\":[\"dep:python3-dll-a\"],\"resolve-config\":[]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index bfceb074d4..3fe6a2c5f6 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -11444,9 +11444,16 @@ checksum = "76979bea66e7875e7509c4ec5300112b316af87fa7a252ca91c448b32dfe3993" dependencies = [ "bitflags 2.13.1", "memchr", + "pulldown-cmark-escape", "unicase", ] +[[package]] +name = "pulldown-cmark-escape" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd348ff538bc9caeda7ee8cad2d1d48236a1f443c1fa3913c6a02fe0043b1dd3" + [[package]] name = "pxfm" version = "0.1.27" diff --git a/codex-rs/core/tests/suite/turn_state.rs b/codex-rs/core/tests/suite/turn_state.rs index ce306dbe96..17b7537b3f 100644 --- a/codex-rs/core/tests/suite/turn_state.rs +++ b/codex-rs/core/tests/suite/turn_state.rs @@ -125,6 +125,12 @@ async fn websocket_turn_state_persists_within_turn_and_resets_after() -> Result< let mut builder = test_codex(); let test = builder.build_with_websocket_server(&server).await?; // Phase 1: startup prewarm uses the connection without generating a response. + // Wait before submitting a turn, which changes the session's sandbox policy. + tokio::time::timeout( + std::time::Duration::from_secs(/*secs*/ 10), + server.wait_for_request(/*connection_index*/ 0, /*request_index*/ 0), + ) + .await?; // Phase 2: the first turn mints state for its same-turn tool follow-up. test.submit_turn("run the echo command").await?; // Phase 3: the follow-up replays that state on the same physical connection. diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 8a8533b781..9dfded194b 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -80,7 +80,7 @@ image = { workspace = true, features = ["jpeg", "png", "gif", "webp"] } itertools = { workspace = true } lazy_static = { workspace = true } pathdiff = { workspace = true } -pulldown-cmark = { workspace = true } +pulldown-cmark = { workspace = true, features = ["html"] } rand = { workspace = true } ratatui = { workspace = true, features = [ "scrolling-regions", diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 1d5771bb62..3d27521d5d 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -205,8 +205,8 @@ impl App { self.chat_widget.maybe_send_next_queued_input(); } } - AppEvent::CopySelection { text, label } => { - self.chat_widget.copy_selection(text, label); + AppEvent::CopySelection { text, label, format } => { + self.chat_widget.copy_selection(text, label, format); } AppEvent::ClearUi { name } => { self.clear_terminal_ui(tui, /*redraw_header*/ false)?; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 7c5bed7b19..08b5a199be 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -353,6 +353,7 @@ pub(crate) enum AppEvent { CopySelection { text: Arc, label: String, + format: crate::clipboard_copy::CopyFormat, }, /// Persist a submitted prompt in the cross-session message history. diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 015a69807f..39379f7d17 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -2,6 +2,7 @@ use super::*; use crate::bottom_pane::BottomPaneView; +use crate::clipboard_copy::CopyFormat; impl ChatWidget { pub(crate) fn set_agents_navigation_enabled(&mut self, enabled: bool) { @@ -283,9 +284,11 @@ impl ChatWidget { false } - /// Copy the last agent response (raw markdown) to the system clipboard. + /// Copy the last response as Markdown with HTML for rich-text destinations. pub(crate) fn copy_last_agent_markdown(&mut self) { - self.copy_last_agent_markdown_with(crate::clipboard_copy::copy_to_clipboard); + self.copy_last_agent_markdown_with(|text| { + crate::clipboard_copy::copy_to_clipboard(text, CopyFormat::Markdown) + }); } /// Inner implementation with an injectable clipboard backend for testing. @@ -327,6 +330,7 @@ impl ChatWidget { let mut choices = vec![( "Whole response".to_string(), Arc::::from(markdown.as_str()), + CopyFormat::Markdown, )]; let source = self .transcript @@ -343,21 +347,27 @@ impl ChatWidget { |language| format!("{language} code"), ), content, + CopyFormat::PlainText, )), crate::markdown::CopyTarget::Quote(content) => { let content: String = content .split_inclusive('\n') .map(|line| crate::git_action_directives::strip_line_directives(line).0) .collect(); - (!content.trim().is_empty()) - .then(|| ("Blockquote".to_string(), Arc::from(content))) + (!content.trim().is_empty()).then(|| { + ( + "Blockquote".to_string(), + Arc::from(content), + CopyFormat::PlainText, + ) + }) } }), ); let items = choices .into_iter() - .map(|(label, text)| { + .map(|(label, text, format)| { let description = text .lines() .find(|line| !line.trim().is_empty()) @@ -369,6 +379,7 @@ impl ChatWidget { tx.send(AppEvent::CopySelection { text: Arc::clone(&text), label: label.clone(), + format, }); })], dismiss_on_select: true, @@ -386,8 +397,10 @@ impl ChatWidget { self.defer_input_until_settings_applied(); } - pub(crate) fn copy_selection(&mut self, text: Arc, label: String) { - self.copy_selection_with(&text, &label, crate::clipboard_copy::copy_to_clipboard); + pub(crate) fn copy_selection(&mut self, text: Arc, label: String, format: CopyFormat) { + self.copy_selection_with(&text, &label, |text| { + crate::clipboard_copy::copy_to_clipboard(text, format) + }); } pub(super) fn copy_selection_with( diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index de6eb5f405..07a3444767 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -113,7 +113,7 @@ fn next_copy_selection( rx: &mut tokio::sync::mpsc::UnboundedReceiver, ) -> (String, String) { let selection = match rx.try_recv() { - Ok(AppEvent::CopySelection { text, label }) => (text.to_string(), label), + Ok(AppEvent::CopySelection { text, label, .. }) => (text.to_string(), label), other => panic!("expected selected clipboard content, got {other:?}"), }; assert_matches!(rx.try_recv(), Ok(AppEvent::SettingsSelectionClosed)); @@ -2089,6 +2089,37 @@ async fn slash_copy_picker_numeric_shortcuts_copy_whole_response_and_exact_code( ); } +#[tokio::test] +async fn slash_copy_picker_renders_only_whole_responses_as_markdown() { + use crate::clipboard_copy::CopyFormat; + + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let markdown = "**Intro**\n\n```text\n# literal *code*\n```\n\n> **Quote**\n"; + chat.transcript.last_agent_markdown = Some(markdown.to_string()); + for (key, expected) in [ + ('1', (markdown, "Whole response", CopyFormat::Markdown)), + ( + '2', + ("# literal *code*\n", "text code", CopyFormat::PlainText), + ), + ('3', ("**Quote**\n", "Blockquote", CopyFormat::PlainText)), + ] { + chat.dispatch_command(SlashCommand::Copy); + chat.handle_key_event(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE)); + let event = rx.try_recv().expect("selected clipboard content"); + let AppEvent::CopySelection { + text, + label, + format, + } = event + else { + panic!("expected selected clipboard content, got {event:?}"); + }; + assert_eq!((text.as_ref(), label.as_str(), format), expected); + assert_matches!(rx.try_recv(), Ok(AppEvent::SettingsSelectionClosed)); + } +} + #[tokio::test] async fn slash_copy_picker_preserves_nested_blockquote_markdown_and_code() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/transcript_export.rs b/codex-rs/tui/src/chatwidget/transcript_export.rs index 9253cc9b06..50a6fc5b84 100644 --- a/codex-rs/tui/src/chatwidget/transcript_export.rs +++ b/codex-rs/tui/src/chatwidget/transcript_export.rs @@ -5,7 +5,10 @@ use crate::app_event::TranscriptExportDestination; impl ChatWidget { pub(crate) fn copy_transcript_to_clipboard(&mut self, markdown: &str) { - match crate::clipboard_copy::copy_to_clipboard(markdown) { + match crate::clipboard_copy::copy_to_clipboard( + markdown, + crate::clipboard_copy::CopyFormat::PlainText, + ) { Ok(lease) => { self.clipboard_lease = lease; self.add_info_message( diff --git a/codex-rs/tui/src/clipboard_copy.rs b/codex-rs/tui/src/clipboard_copy.rs index 1ad369d24b..cf43d301b0 100644 --- a/codex-rs/tui/src/clipboard_copy.rs +++ b/codex-rs/tui/src/clipboard_copy.rs @@ -15,8 +15,8 @@ //! so callers can store it for the lifetime of the TUI. On other platforms the lease //! is always `None`. //! -//! The module is intentionally narrow: text copy only, user-facing error strings, -//! no reusable clipboard abstraction. Image paste lives in `clipboard_paste`. +//! Markdown copies also offer HTML on the native clipboard. Terminal and WSL +//! fallbacks retain the original text. Image paste lives in `clipboard_paste`. use base64::Engine; use std::io::Write; @@ -28,6 +28,13 @@ const OSC52_MAX_RAW_BYTES: usize = 100_000; static STDERR_SUPPRESSION_MUTEX: std::sync::OnceLock> = std::sync::OnceLock::new(); +/// Whether copied text should also have a rendered HTML representation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CopyFormat { + PlainText, + Markdown, +} + /// Copy text to the system clipboard. /// /// Over SSH, uses terminal-mediated copy so the text reaches the *local* @@ -37,9 +44,13 @@ static STDERR_SUPPRESSION_MUTEX: std::sync::OnceLock> = /// copy, if needed. /// /// OSC 52 is supported by kitty, WezTerm, iTerm2, Ghostty, and others. -pub(crate) fn copy_to_clipboard(text: &str) -> Result, String> { +pub(crate) fn copy_to_clipboard( + text: &str, + format: CopyFormat, +) -> Result, String> { copy_to_clipboard_with( text, + format, CopyEnvironment { ssh_session: is_ssh_session(), wsl_session: is_wsl_session(), @@ -93,10 +104,11 @@ struct CopyEnvironment { fn copy_to_clipboard_with( text: &str, + format: CopyFormat, environment: CopyEnvironment, tmux_copy_fn: impl Fn(&str) -> Result<(), String>, osc52_copy_fn: impl Fn(&str) -> Result<(), String>, - arboard_copy_fn: impl Fn(&str) -> Result, String>, + arboard_copy_fn: impl Fn(&str, Option<&str>) -> Result, String>, wsl_copy_fn: impl Fn(&str) -> Result<(), String>, ) -> Result, String> { if environment.ssh_session { @@ -119,7 +131,11 @@ fn copy_to_clipboard_with( }); } - match arboard_copy_fn(text) { + let html = match format { + CopyFormat::PlainText => None, + CopyFormat::Markdown => Some(crate::clipboard_html::render_markdown(text)), + }; + match arboard_copy_fn(text, html.as_deref()) { Ok(lease) => Ok(lease), Err(native_err) => { if environment.wsl_session { @@ -222,8 +238,8 @@ fn is_wsl_session() -> bool { /// triggers `os_log` / `NSLog` output on stderr. Because the TUI owns the /// terminal, that stray output corrupts the display. We temporarily redirect /// fd 2 to `/dev/null` around the call to keep the screen clean. -#[cfg(all(not(target_os = "android"), not(target_os = "linux")))] -fn arboard_copy(text: &str) -> Result, String> { +#[cfg(not(target_os = "android"))] +fn arboard_copy(text: &str, html: Option<&str>) -> Result, String> { #[cfg(target_os = "macos")] let _stderr_lock = STDERR_SUPPRESSION_MUTEX .get_or_init(|| std::sync::Mutex::new(())) @@ -232,30 +248,26 @@ fn arboard_copy(text: &str) -> Result, String> { let _guard = SuppressStderr::new(); let mut clipboard = arboard::Clipboard::new().map_err(|e| format!("clipboard unavailable: {e}"))?; - clipboard - .set_text(text) - .map_err(|e| format!("failed to set clipboard text: {e}"))?; - Ok(None) -} - -/// Run arboard with stderr suppressed. -/// -/// On Linux/X11 and some Wayland setups, clipboard contents are served by the -/// process that last wrote them. Keep the `Clipboard` alive so the copied text -/// remains pasteable while the TUI is running. -#[cfg(target_os = "linux")] -fn arboard_copy(text: &str) -> Result, String> { - let _guard = SuppressStderr::new(); - let mut clipboard = - arboard::Clipboard::new().map_err(|e| format!("clipboard unavailable: {e}"))?; - clipboard - .set_text(text) - .map_err(|e| format!("failed to set clipboard text: {e}"))?; - Ok(Some(ClipboardLease::native_linux(clipboard))) + match html { + Some(html) => clipboard + .set_html(html, Some(text)) + .or_else(|_| clipboard.set_text(text)), + None => clipboard.set_text(text), + } + .map_err(|e| format!("failed to set clipboard text: {e}"))?; + // Linux clipboard owners must stay alive until the user pastes. + #[cfg(target_os = "linux")] + { + Ok(Some(ClipboardLease::native_linux(clipboard))) + } + #[cfg(not(target_os = "linux"))] + { + Ok(None) + } } #[cfg(target_os = "android")] -fn arboard_copy(_text: &str) -> Result, String> { +fn arboard_copy(_text: &str, _html: Option<&str>) -> Result, String> { Err("native clipboard unavailable on Android".to_string()) } @@ -506,6 +518,7 @@ mod tests { use std::cell::Cell; use super::CopyEnvironment; + use super::CopyFormat; use super::OSC52_MAX_RAW_BYTES; use super::copy_to_clipboard_with; use super::osc52_sequence; @@ -598,17 +611,19 @@ mod tests { let native_calls = Cell::new(0_u8); let wsl_calls = Cell::new(0_u8); let result = copy_to_clipboard_with( - "hello", + "**hello**", + CopyFormat::Markdown, remote_environment(), |_| { tmux_calls.set(tmux_calls.get() + 1); Ok(()) }, - |_| { + |text| { + assert_eq!(text, "**hello**"); osc_calls.set(osc_calls.get() + 1); Ok(()) }, - |_| { + |_, _| { native_calls.set(native_calls.get() + 1); Ok(None) }, @@ -633,6 +648,7 @@ mod tests { let wsl_calls = Cell::new(0_u8); let result = copy_to_clipboard_with( "hello", + CopyFormat::PlainText, remote_environment(), |_| { tmux_calls.set(tmux_calls.get() + 1); @@ -642,7 +658,7 @@ mod tests { osc_calls.set(osc_calls.get() + 1); Err("blocked".into()) }, - |_| { + |_, _| { native_calls.set(native_calls.get() + 1); Ok(None) }, @@ -670,6 +686,7 @@ mod tests { let wsl_calls = Cell::new(0_u8); let result = copy_to_clipboard_with( "hello", + CopyFormat::PlainText, remote_tmux_environment(), |_| { tmux_calls.set(tmux_calls.get() + 1); @@ -679,7 +696,7 @@ mod tests { osc_calls.set(osc_calls.get() + 1); Ok(()) }, - |_| { + |_, _| { native_calls.set(native_calls.get() + 1); Ok(None) }, @@ -704,6 +721,7 @@ mod tests { let wsl_calls = Cell::new(0_u8); let result = copy_to_clipboard_with( "hello", + CopyFormat::PlainText, remote_tmux_environment(), |_| { tmux_calls.set(tmux_calls.get() + 1); @@ -713,7 +731,7 @@ mod tests { osc_calls.set(osc_calls.get() + 1); Ok(()) }, - |_| { + |_, _| { native_calls.set(native_calls.get() + 1); Ok(None) }, @@ -734,10 +752,11 @@ mod tests { fn ssh_inside_tmux_reports_tmux_and_osc52_errors_when_both_fail() { let result = copy_to_clipboard_with( "hello", + CopyFormat::PlainText, remote_tmux_environment(), |_| Err("tmux unavailable".into()), |_| Err("osc blocked".into()), - |_| Ok(None), + |_, _| Ok(None), |_| Ok(()), ); @@ -793,13 +812,14 @@ mod tests { let wsl_calls = Cell::new(0_u8); let result = copy_to_clipboard_with( "hello", + CopyFormat::PlainText, local_wsl_environment(), |_| Ok(()), |_| { osc_calls.set(osc_calls.get() + 1); Ok(()) }, - |_| { + |_, _| { native_calls.set(native_calls.get() + 1); Ok(Some(super::ClipboardLease::test())) }, @@ -815,20 +835,47 @@ mod tests { assert_eq!(wsl_calls.get(), 0); } + #[test] + fn local_copy_offers_html_only_for_markdown() { + for (format, html) in [ + ( + CopyFormat::Markdown, + Some("

hello

\n"), + ), + (CopyFormat::PlainText, None), + ] { + let result = copy_to_clipboard_with( + "**hello**", + format, + local_environment(), + |_| panic!("native copy should succeed"), + |_| panic!("native copy should succeed"), + |text, actual_html| { + assert_eq!((text, actual_html), ("**hello**", html)); + Ok(None) + }, + |_| panic!("native copy should succeed"), + ); + assert!(matches!(result, Ok(None))); + } + } + #[test] fn local_non_wsl_falls_back_to_osc52_when_native_fails() { let osc_calls = Cell::new(0_u8); let native_calls = Cell::new(0_u8); let wsl_calls = Cell::new(0_u8); let result = copy_to_clipboard_with( - "hello", + "**hello**", + CopyFormat::Markdown, local_environment(), |_| Ok(()), - |_| { + |text| { + assert_eq!(text, "**hello**"); osc_calls.set(osc_calls.get() + 1); Ok(()) }, - |_| { + |_, _| { native_calls.set(native_calls.get() + 1); Err("native unavailable".into()) }, @@ -852,6 +899,7 @@ mod tests { let wsl_calls = Cell::new(0_u8); let result = copy_to_clipboard_with( "hello", + CopyFormat::PlainText, local_tmux_environment(), |_| { tmux_calls.set(tmux_calls.get() + 1); @@ -861,7 +909,7 @@ mod tests { osc_calls.set(osc_calls.get() + 1); Ok(()) }, - |_| { + |_, _| { native_calls.set(native_calls.get() + 1); Err("native unavailable".into()) }, @@ -885,13 +933,14 @@ mod tests { let wsl_calls = Cell::new(0_u8); let result = copy_to_clipboard_with( "hello", + CopyFormat::PlainText, local_wsl_environment(), |_| Ok(()), |_| { osc_calls.set(osc_calls.get() + 1); Ok(()) }, - |_| { + |_, _| { native_calls.set(native_calls.get() + 1); Err("native unavailable".into()) }, @@ -914,13 +963,14 @@ mod tests { let wsl_calls = Cell::new(0_u8); let result = copy_to_clipboard_with( "hello", + CopyFormat::PlainText, local_wsl_environment(), |_| Ok(()), |_| { osc_calls.set(osc_calls.get() + 1); Ok(()) }, - |_| { + |_, _| { native_calls.set(native_calls.get() + 1); Err("native unavailable".into()) }, @@ -943,13 +993,14 @@ mod tests { let wsl_calls = Cell::new(0_u8); let result = copy_to_clipboard_with( "hello", + CopyFormat::PlainText, local_environment(), |_| Ok(()), |_| { osc_calls.set(osc_calls.get() + 1); Err("osc blocked".into()) }, - |_| { + |_, _| { native_calls.set(native_calls.get() + 1); Err("native unavailable".into()) }, @@ -978,13 +1029,14 @@ mod tests { let wsl_calls = Cell::new(0_u8); let result = copy_to_clipboard_with( "hello", + CopyFormat::PlainText, local_wsl_environment(), |_| Ok(()), |_| { osc_calls.set(osc_calls.get() + 1); Err("osc blocked".into()) }, - |_| { + |_, _| { native_calls.set(native_calls.get() + 1); Err("native unavailable".into()) }, diff --git a/codex-rs/tui/src/clipboard_html.rs b/codex-rs/tui/src/clipboard_html.rs new file mode 100644 index 0000000000..383126365e --- /dev/null +++ b/codex-rs/tui/src/clipboard_html.rs @@ -0,0 +1,49 @@ +//! Portable HTML for copying Markdown responses into rich-text applications. +//! +//! Raw HTML is escaped, and destinations use the TUI's web-link policy. Link +//! destinations that cannot be linked remain visible as escaped text. The original +//! Markdown remains the plain-text clipboard representation. Images render only +//! their alt text so pasting cannot trigger remote image requests. + +use pulldown_cmark::Event; +use pulldown_cmark::Options; +use pulldown_cmark::Parser; +use pulldown_cmark::Tag; +use pulldown_cmark::TagEnd; + +pub(crate) fn render_markdown(markdown: &str) -> String { + let normalized = crate::markdown::unwrap_markdown_fences(markdown); + let options = Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES; + let mut link_destinations = Vec::new(); + let events = Parser::new_ext(&normalized, options).filter_map(|event| { + Some(match event { + Event::Html(text) | Event::InlineHtml(text) => Event::Text(text), + Event::Start(Tag::Image { .. }) | Event::End(TagEnd::Image) => return None, + Event::Start(mut tag) => { + if let Tag::Link { dest_url, .. } = &mut tag { + if let Some(destination) = crate::terminal_hyperlinks::web_destination(dest_url) + { + *dest_url = destination.into(); + link_destinations.push(/*value*/ None); + } else { + link_destinations.push(Some(dest_url.clone())); + return None; + } + } + Event::Start(tag) + } + Event::End(TagEnd::Link) => match link_destinations.pop().flatten() { + Some(destination) => Event::Text(format!(" ({destination})").into()), + None => Event::End(TagEnd::Link), + }, + event => event, + }) + }); + let mut html = String::new(); + pulldown_cmark::html::push_html(&mut html, events); + html +} + +#[cfg(test)] +#[path = "clipboard_html_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/clipboard_html_tests.rs b/codex-rs/tui/src/clipboard_html_tests.rs new file mode 100644 index 0000000000..2c478244dd --- /dev/null +++ b/codex-rs/tui/src/clipboard_html_tests.rs @@ -0,0 +1,77 @@ +use super::render_markdown; +use pretty_assertions::assert_eq; + +#[test] +fn markdown_clipboard_preserves_rich_formatting() { + let markdown = "# Findings\n\nRead [the source](https://example.com/source?q=1&v=2).\n\n\ + - **Bold**, *italic*, ~~removed~~, and `inline `\n\n\ + > A quoted finding.\n\n\ + ```rust\nfn main() { println!(\"\"); }\n```\n\n\ + | Item | Result |\n| --- | --- |\n| Copy | Formatted |\n"; + insta::assert_snapshot!(render_markdown(markdown)); +} + +#[test] +fn markdown_clipboard_preserves_local_link_destinations() { + let markdown = r#"[parser](/repo/src/parser.rs:42) + +[**Windows parser**](C:\repo\src\parser.rs:42:7) + +[`relative parser`](../src/parser.rs#L42-L48) + +[file URL](file:///repo/src/parser.rs#L42) + +[escaped path](/repo/a&b/<parser>.rs:42) + +[Unicode path]() + +[![diagram](https://example.com/diagram.png)](/repo/diagram.rs:7) + +[web link](https://example.com/source) and [another file](src/lib.rs:9) +"#; + insta::assert_snapshot!(render_markdown(markdown)); +} + +#[test] +fn markdown_clipboard_images_are_inert() { + assert_eq!( + render_markdown( + "![tracking pixel](https://example.com/pixel)\n\n\ + [![**Preview** & details](https://example.com/image.png)](https://example.com/page)" + ), + "

tracking pixel

\n\ +

Preview & details

\n" + ); +} + +#[test] +fn markdown_clipboard_escapes_raw_html_and_rejects_unsafe_destinations() { + assert_eq!( + render_markdown("\n\nHello !"), + "<script>alert(1)</script>\n

Hello <img src=x onerror=alert(1)>!

\n" + ); + for (destination, visible_destination) in [ + ("javascript:alert(1)", "javascript:alert(1)"), + ("JaVaScRiPt:alert(1)", "JaVaScRiPt:alert(1)"), + ("javascript:alert(1)", "javascript:alert(1)"), + ("data:text/html,unsafe", "data:text/html,unsafe"), + ( + "javascript:alert("<>&")", + "javascript:alert(\"<>&\")", + ), + ] { + assert_eq!( + render_markdown(&format!("[link]({destination}) ![image]({destination})")), + format!("

link ({visible_destination}) image

\n") + ); + } +} + +#[test] +fn markdown_clipboard_unwraps_table_fences_like_the_tui() { + let table = "| Item | Result |\n| --- | --- |\n| Copy | Formatted |\n"; + assert_eq!( + render_markdown(&format!("```markdown\n{table}```")), + render_markdown(table) + ); +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index c89d979ead..121f13aba1 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -114,6 +114,7 @@ mod branch_summary; mod chatwidget; mod cli; mod clipboard_copy; +mod clipboard_html; mod clipboard_paste; mod collaboration_modes; mod color; diff --git a/codex-rs/tui/src/markdown.rs b/codex-rs/tui/src/markdown.rs index 8c6ce25b2f..8880a23194 100644 --- a/codex-rs/tui/src/markdown.rs +++ b/codex-rs/tui/src/markdown.rs @@ -240,7 +240,7 @@ pub(crate) fn extract_copy_targets(markdown_source: &str) -> Vec { /// The fence unwrapping is intentionally conservative: it buffers the entire fence body before /// deciding, and an unclosed fence at end-of-input is re-emitted with its opening line so partial /// streams degrade to code display. -fn unwrap_markdown_fences<'a>(markdown_source: &'a str) -> Cow<'a, str> { +pub(crate) fn unwrap_markdown_fences<'a>(markdown_source: &'a str) -> Cow<'a, str> { // Zero-copy fast path: most messages contain no fences at all. if !markdown_source.contains("```") && !markdown_source.contains("~~~") { return Cow::Borrowed(markdown_source); diff --git a/codex-rs/tui/src/session_log_tests.rs b/codex-rs/tui/src/session_log_tests.rs index fce6f9a1da..5e0e918262 100644 --- a/codex-rs/tui/src/session_log_tests.rs +++ b/codex-rs/tui/src/session_log_tests.rs @@ -12,10 +12,12 @@ fn inbound_fallback_records_only_variant_names() { AppEvent::CopySelection { text: Arc::from("private plan without parentheses"), label: "private label".to_string(), + format: crate::clipboard_copy::CopyFormat::Markdown, }, AppEvent::CopySelection { text: Arc::from("private code(with parentheses)"), label: "private label".to_string(), + format: crate::clipboard_copy::CopyFormat::PlainText, }, AppEvent::ConsolidateProposedPlan("private tuple payload".to_string()), AppEvent::SettingsSelectionClosed, diff --git a/codex-rs/tui/src/snapshots/codex_tui__clipboard_html__tests__markdown_clipboard_preserves_local_link_destinations.snap b/codex-rs/tui/src/snapshots/codex_tui__clipboard_html__tests__markdown_clipboard_preserves_local_link_destinations.snap new file mode 100644 index 0000000000..01ed189daf --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__clipboard_html__tests__markdown_clipboard_preserves_local_link_destinations.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/clipboard_html_tests.rs +expression: render_markdown(markdown) +--- +

parser (/repo/src/parser.rs:42)

+

Windows parser (C:\repo\src\parser.rs:42:7)

+

relative parser (../src/parser.rs#L42-L48)

+

file URL (file:///repo/src/parser.rs#L42)

+

escaped path (/repo/a&b/<parser>.rs:42)

+

Unicode path (/repo/my project/解析.rs:42)

+

diagram (/repo/diagram.rs:7)

+

web link and another file (src/lib.rs:9)

diff --git a/codex-rs/tui/src/snapshots/codex_tui__clipboard_html__tests__markdown_clipboard_preserves_rich_formatting.snap b/codex-rs/tui/src/snapshots/codex_tui__clipboard_html__tests__markdown_clipboard_preserves_rich_formatting.snap new file mode 100644 index 0000000000..5486bbb177 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__clipboard_html__tests__markdown_clipboard_preserves_rich_formatting.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/clipboard_html_tests.rs +expression: render_markdown(markdown) +--- +

Findings

+

Read the source.

+
    +
  • Bold, italic, removed, and inline <code>
  • +
+
+

A quoted finding.

+
+
fn main() { println!("<hello>"); }
+
+ + +
ItemResult
CopyFormatted