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
This commit is contained in:
Felipe Coury
2026-09-04 20:38:08 +00:00
committed by copyberry
parent 9c4253ffc1
commit 7a8092a447
17 changed files with 329 additions and 57 deletions
+1
View File
@@ -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\":[]}}",
+7
View File
@@ -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"
+6
View File
@@ -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.
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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)?;
+1
View File
@@ -353,6 +353,7 @@ pub(crate) enum AppEvent {
CopySelection {
text: Arc<str>,
label: String,
format: crate::clipboard_copy::CopyFormat,
},
/// Persist a submitted prompt in the cross-session message history.
+20 -7
View File
@@ -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::<str>::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<str>, label: String) {
self.copy_selection_with(&text, &label, crate::clipboard_copy::copy_to_clipboard);
pub(crate) fn copy_selection(&mut self, text: Arc<str>, 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(
@@ -113,7 +113,7 @@ fn next_copy_selection(
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
) -> (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;
@@ -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(
+96 -44
View File
@@ -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::Mutex<()>> =
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<std::sync::Mutex<()>> =
/// copy, if needed.
///
/// OSC 52 is supported by kitty, WezTerm, iTerm2, Ghostty, and others.
pub(crate) fn copy_to_clipboard(text: &str) -> Result<Option<ClipboardLease>, String> {
pub(crate) fn copy_to_clipboard(
text: &str,
format: CopyFormat,
) -> Result<Option<ClipboardLease>, 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<Option<ClipboardLease>, String>,
arboard_copy_fn: impl Fn(&str, Option<&str>) -> Result<Option<ClipboardLease>, String>,
wsl_copy_fn: impl Fn(&str) -> Result<(), String>,
) -> Result<Option<ClipboardLease>, 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<Option<ClipboardLease>, String> {
#[cfg(not(target_os = "android"))]
fn arboard_copy(text: &str, html: Option<&str>) -> Result<Option<ClipboardLease>, 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<Option<ClipboardLease>, 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<Option<ClipboardLease>, 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<Option<ClipboardLease>, String> {
fn arboard_copy(_text: &str, _html: Option<&str>) -> Result<Option<ClipboardLease>, 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("<p><strong>hello</strong></p>\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())
},
+49
View File
@@ -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;
+77
View File
@@ -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 <code>`\n\n\
> A quoted finding.\n\n\
```rust\nfn main() { println!(\"<hello>\"); }\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&amp;b/&lt;parser&gt;.rs:42)
[Unicode path](</repo/my project/解析.rs:42>)
[![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)"
),
"<p>tracking pixel</p>\n\
<p><a href=\"https://example.com/page\"><strong>Preview</strong> &amp; details</a></p>\n"
);
}
#[test]
fn markdown_clipboard_escapes_raw_html_and_rejects_unsafe_destinations() {
assert_eq!(
render_markdown("<script>alert(1)</script>\n\nHello <img src=x onerror=alert(1)>!"),
"&lt;script&gt;alert(1)&lt;/script&gt;\n<p>Hello &lt;img src=x onerror=alert(1)&gt;!</p>\n"
);
for (destination, visible_destination) in [
("javascript:alert(1)", "javascript:alert(1)"),
("JaVaScRiPt:alert(1)", "JaVaScRiPt:alert(1)"),
("jav&#x61;script:alert(1)", "javascript:alert(1)"),
("data:text/html,unsafe", "data:text/html,unsafe"),
(
"javascript:alert(&quot;&lt;&gt;&amp;&quot;)",
"javascript:alert(\"&lt;&gt;&amp;\")",
),
] {
assert_eq!(
render_markdown(&format!("[link]({destination}) ![image]({destination})")),
format!("<p>link ({visible_destination}) image</p>\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)
);
}
+1
View File
@@ -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;
+1 -1
View File
@@ -240,7 +240,7 @@ pub(crate) fn extract_copy_targets(markdown_source: &str) -> Vec<CopyTarget> {
/// 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);
+2
View File
@@ -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,
@@ -0,0 +1,12 @@
---
source: tui/src/clipboard_html_tests.rs
expression: render_markdown(markdown)
---
<p>parser (/repo/src/parser.rs:42)</p>
<p><strong>Windows parser</strong> (C:\repo\src\parser.rs:42:7)</p>
<p><code>relative parser</code> (../src/parser.rs#L42-L48)</p>
<p>file URL (file:///repo/src/parser.rs#L42)</p>
<p>escaped path (/repo/a&amp;b/&lt;parser&gt;.rs:42)</p>
<p>Unicode path (/repo/my project/解析.rs:42)</p>
<p>diagram (/repo/diagram.rs:7)</p>
<p><a href="https://example.com/source">web link</a> and another file (src/lib.rs:9)</p>
@@ -0,0 +1,17 @@
---
source: tui/src/clipboard_html_tests.rs
expression: render_markdown(markdown)
---
<h1>Findings</h1>
<p>Read <a href="https://example.com/source?q=1&amp;v=2">the source</a>.</p>
<ul>
<li><strong>Bold</strong>, <em>italic</em>, <del>removed</del>, and <code>inline &lt;code&gt;</code></li>
</ul>
<blockquote>
<p>A quoted finding.</p>
</blockquote>
<pre><code class="language-rust">fn main() { println!("&lt;hello&gt;"); }
</code></pre>
<table><thead><tr><th>Item</th><th>Result</th></tr></thead><tbody>
<tr><td>Copy</td><td>Formatted</td></tr>
</tbody></table>