mirror of
https://github.com/openai/codex.git
synced 2026-10-21 07:42:01 +00:00
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:
Generated
+1
@@ -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\":[]}}",
|
||||
|
||||
Generated
+7
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@@ -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&b/<parser>.rs:42)
|
||||
|
||||
[Unicode path](</repo/my project/解析.rs:42>)
|
||||
|
||||
[](/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(
|
||||
"\n\n\
|
||||
[](https://example.com/page)"
|
||||
),
|
||||
"<p>tracking pixel</p>\n\
|
||||
<p><a href=\"https://example.com/page\"><strong>Preview</strong> & 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)>!"),
|
||||
"<script>alert(1)</script>\n<p>Hello <img src=x onerror=alert(1)>!</p>\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}) ")),
|
||||
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)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+12
@@ -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&b/<parser>.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>
|
||||
+17
@@ -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&v=2">the source</a>.</p>
|
||||
<ul>
|
||||
<li><strong>Bold</strong>, <em>italic</em>, <del>removed</del>, and <code>inline <code></code></li>
|
||||
</ul>
|
||||
<blockquote>
|
||||
<p>A quoted finding.</p>
|
||||
</blockquote>
|
||||
<pre><code class="language-rust">fn main() { println!("<hello>"); }
|
||||
</code></pre>
|
||||
<table><thead><tr><th>Item</th><th>Result</th></tr></thead><tbody>
|
||||
<tr><td>Copy</td><td>Formatted</td></tr>
|
||||
</tbody></table>
|
||||
Reference in New Issue
Block a user