Preserve Markdown formatting when copying transcript selections (#47896)

## Why

Transcript selections were copied as plain text, losing the formatting of rendered Markdown when pasted.

## What changed

- Retain semantic Markdown annotations through rendering, wrapping, and streaming, then serialize selected text with balanced markup.
- Preserve inline formatting, web links, headings, lists, blockquotes, and hard breaks in Markdown clipboard payloads.
- Keep code-only and other literal-only selections as plain text. In mixed selections, escape user prose and fence code, tables, and tool output to preserve their literal content.

## Testing

Add unit tests and Markdown/HTML snapshots covering partial selections, nested containers, escaping, links, resize stability, and streaming boundaries.

GitOrigin-RevId: f0476334a6bf8ad0fb57626627bd258443540852
This commit is contained in:
Felipe Coury
2026-09-24 17:55:29 +00:00
committed by copyberry
parent f6fd7f17ed
commit a33fb9751c
38 changed files with 1790 additions and 41 deletions
+3 -1
View File
@@ -868,7 +868,9 @@ impl App {
) -> Result<AppRunControl> {
self.finish_clipboard(tui);
if matches!(&event, TuiEvent::Key(_))
&& self.handle_composer_copy_event(tui, &event, tui::Tui::copy_transcript_selection)
&& self.handle_composer_copy_event(tui, &event, |tui, text| {
tui.copy_transcript_selection(text, crate::clipboard_copy::CopyFormat::PlainText)
})
{
return Ok(AppRunControl::Continue);
}
+1 -1
View File
@@ -345,7 +345,7 @@ impl App {
}
AppEvent::OpenWarnings => self.chat_widget.open_warnings(&self.transcript_cells),
AppEvent::CopyWarning(text) => {
let result = tui.copy_transcript_selection(&text);
let result = tui.copy_transcript_selection(&text, crate::clipboard_copy::CopyFormat::PlainText);
self.chat_widget.show_selection_copy_result(result);
}
AppEvent::OpenTranscriptExportFilePrompt => {
+7 -2
View File
@@ -292,7 +292,12 @@ impl App {
self.render_owned_transcript(tui, size)?;
}
if composer_ready
&& self.handle_composer_copy_event(tui, event, tui::Tui::copy_transcript_selection)
&& self.handle_composer_copy_event(tui, event, |tui, text| {
tui.copy_transcript_selection(
text,
crate::clipboard_copy::CopyFormat::PlainText,
)
})
{
return Ok(true);
}
@@ -465,7 +470,7 @@ impl App {
&self.transcript_cells,
&text,
!copy_on_select,
|text| tui.copy_transcript_selection(text),
|text, format| tui.copy_transcript_selection(text, format),
);
self.transcript_view
.show_copy_feedback(&result, text.chars().count());
+4 -1
View File
@@ -55,7 +55,10 @@ impl App {
let markdown = render_markdown_transcript(&cells)?;
match destination {
TranscriptExportDestination::Clipboard => {
let result = tui.copy_transcript_selection(&markdown);
let result = tui.copy_transcript_selection(
&markdown,
crate::clipboard_copy::CopyFormat::PlainText,
);
self.chat_widget.show_copy_result("conversation", result);
}
TranscriptExportDestination::File(path) => {
@@ -70,7 +70,10 @@ async fn blocked_copy_allows_overlay_exit_rejects_backlog_and_wakes_completion()
("café\nsecond line".into(), CopyFormat::Markdown)
);
for _ in 0..100 {
assert_eq!(tui.copy_transcript_selection("newer"), Ok(CopyStatus::Busy));
assert_eq!(
tui.copy_transcript_selection("newer", CopyFormat::PlainText),
Ok(CopyStatus::Busy)
);
}
assert!(tui.clipboard.poll().is_none());
let mut overlay = Overlay::new_transcript(
@@ -243,6 +243,7 @@ impl HistoryCell for UserHistoryCell {
lines.push(HyperlinkLine::new(Line::from("").style(style)));
for source in lines.iter_mut().filter_map(|line| line.source.as_mut()) {
source.right_reserve = 1;
source.copy_as_prose = true;
}
lines
}
+2 -1
View File
@@ -30,6 +30,7 @@ const DIRECTIVE_PREFIX: &str = "::codex-inline-vis{";
const CONTENT_REFERENCE_PREFIX: &str = "\u{e200}visualize\u{e202}";
const CONTENT_REFERENCE_SUFFIX: char = '\u{e201}';
const MAX_FRAGMENT_BYTES: u64 = 2 * 1024 * 1024;
pub(crate) const LINK_PLACEHOLDER_PREFIX: &str = "https://codex.invalid/inline-visualization/";
#[derive(Clone, Debug)]
pub(crate) struct InlineVisualizationContext {
@@ -288,7 +289,7 @@ fn link_placeholder() -> String {
let mut bytes = [0_u8; 24];
rand::rng().fill_bytes(&mut bytes);
let token = URL_SAFE_NO_PAD.encode(bytes);
format!("https://codex.invalid/inline-visualization/{token}")
format!("{LINK_PLACEHOLDER_PREFIX}{token}")
}
fn parse_directive_file(directive: &str) -> Option<Cow<'_, str>> {
@@ -359,6 +359,28 @@ fn finalized_agent_cell_replays_visualization_link() {
.iter()
.all(|destination| destination.starts_with("file://"))
);
let source = lines
.iter()
.find(|line| !line.hyperlinks.is_empty())
.and_then(|line| line.source.clone())
.expect("linked line copy source");
let start = source.text.find("file://").expect("visible file URL");
let mut selected = Vec::new();
crate::markdown_copy::SelectedLine::append(
&mut selected,
source,
start..start + "file://".len(),
"",
);
let (copied, format) = crate::markdown_copy::selection(&selected, "file://");
assert_eq!(
(copied.as_str(), format),
("file://", crate::clipboard_copy::CopyFormat::Markdown)
);
assert_eq!(
crate::clipboard_html::render_markdown(&copied),
"<p>file://</p>\n"
);
}
#[test]
+1
View File
@@ -128,6 +128,7 @@ mod config_update;
pub(crate) mod custom_terminal;
mod daybreak;
mod experimental_features;
mod markdown_copy;
mod permission_discovery;
mod pets;
mod worktree_browser;
+418
View File
@@ -0,0 +1,418 @@
//! Semantic copy annotations in rendered-byte coordinates, independent of terminal styling.
//!
//! Wrapping and selection snapshots share each logical line's annotations. Only copying
//! serializes Markdown; literal-only selections keep their original text and clipboard format.
//! Rich selections preserve selected words and semantic formatting with balanced markup,
//! rather than reproducing the complete response's source spelling (available through `/copy`).
use std::ops::Range;
use std::sync::Arc;
use pulldown_cmark::Event;
use pulldown_cmark::Tag;
use crate::clipboard_copy::CopyFormat;
use crate::terminal_hyperlinks::LogicalLineSource;
// Bound retained inline stacks and container prefixes independently of parser nesting.
pub(crate) const MAX_COPY_DEPTH: usize = 64;
// A fixed, deduplicated style order gives each nested wrapper a distinct delimiter family.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Inline {
// Keep one link around its formatted label instead of repeating its destination per style.
Link(Arc<str>),
Delimiter(&'static str),
Code,
/// Balance parser events for links whose visible text has no copied wrapper.
Ignored,
}
impl Inline {
pub(crate) fn from_event(event: &Event<'_>) -> Option<Self> {
match event {
Event::Start(Tag::Emphasis) => Some(Self::Delimiter("_")),
Event::Start(Tag::Strong) => Some(Self::Delimiter("**")),
Event::Start(Tag::Strikethrough) => Some(Self::Delimiter("~~")),
Event::Start(Tag::Link { dest_url, .. }) => Some(
if crate::terminal_hyperlinks::web_destination(dest_url).is_some()
&& !dest_url.starts_with(crate::inline_visualization::LINK_PLACEHOLDER_PREFIX)
{
Self::Link(dest_url.as_ref().into())
} else {
Self::Ignored
},
),
Event::Code(_) => Some(Self::Code),
_ => None,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct CopyLine {
pub(crate) prefix: String,
pub(crate) continuation: String,
/// Restore the containing item when a multiline selection starts in its later paragraph.
pub(crate) item_prefix: String,
pub(crate) code: bool,
pub(crate) rule: bool,
pub(crate) heading: usize,
pub(crate) hard_break: bool,
/// A visual separator inserted between sibling list items, never source content.
pub(crate) omit: bool,
runs: Vec<(Range<usize>, Vec<Inline>)>,
}
impl CopyLine {
pub(crate) fn push(&mut self, length: usize, inline: &[Inline]) {
if length == 0 {
return;
}
let mut inline = inline[..inline.len().min(MAX_COPY_DEPTH)]
.iter()
.filter(|mark| **mark != Inline::Ignored)
.cloned()
.collect::<Vec<_>>();
inline.sort();
inline.dedup();
let start = self
.runs
.last()
.map_or(/*default*/ 0, |(range, _)| range.end);
if let Some((range, previous)) = self.runs.last_mut()
&& previous == &inline
{
range.end += length;
} else {
self.runs.push((start..start + length, inline));
}
}
fn render(&self, text: &str, range: Range<usize>, depth: usize) -> String {
if self.rule && range == (0..text.len()) {
return "***".to_owned();
}
let mut out = String::new();
let first = self.runs.partition_point(|(run, _)| run.end <= range.start);
let mut runs = self.runs[first..]
.iter()
.take_while(|(run, _)| run.start < range.end)
.peekable();
while let Some((run, inline)) = runs.next() {
let start = run.start.max(range.start);
let mut end = run.end.min(range.end);
if start >= end {
continue;
}
let mark = inline.get(depth);
while let Some((next, inline)) = runs.peek()
&& next.start == end
&& inline.get(depth) == mark
&& end < range.end
{
end = next.end.min(range.end);
runs.next();
}
match mark {
Some(Inline::Code) => {
let content = &text[start..end];
let fence = fence(content, /*minimum*/ 1);
let padding =
if content.starts_with(['`', ' ']) || content.ends_with(['`', ' ']) {
if content.chars().all(|ch| ch == ' ') {
""
} else {
" "
}
} else {
""
};
append_inline(
&mut out,
&format!("{fence}{padding}{content}{padding}{fence}"),
);
}
Some(mark) => {
let body = self.render(text, start..end, depth + 1);
let trimmed = body.trim();
if trimmed.is_empty() {
out.push_str(&body);
continue;
}
out.push_str(&body[..body.len() - body.trim_start().len()]);
match mark {
Inline::Delimiter(delimiter) => {
append_inline(&mut out, &format!("{delimiter}{trimmed}{delimiter}"))
}
Inline::Link(destination) => {
let destination = destination
.replace('\\', "\\\\")
.replace('&', "&amp;")
.replace('<', "%3C")
.replace('>', "%3E")
.replace('\n', "%0A")
.replace('\r', "%0D");
append_inline(&mut out, &format!("[{trimmed}](<{destination}>)"));
}
Inline::Code | Inline::Ignored => unreachable!(),
}
out.push_str(&body[body.trim_end().len()..]);
}
None => {
let heading_end = self.heading.min(end).max(start);
out.push_str(&text[start..heading_end]);
append_inline(&mut out, &escape(&text[heading_end..end]));
}
}
}
out
}
}
// Entity decoding and omitted link wrappers can turn a delimiter's punctuation neighbor into
// a word character. Protect that boundary without changing the selected text or adding spacing.
fn append_inline(out: &mut String, text: &str) {
let word = |ch: char| !ch.is_whitespace() && !ch.is_ascii_punctuation();
let left = text.starts_with('_')
|| text.starts_with(['*', '~'])
&& text
.chars()
.next()
.and_then(|delimiter| text.trim_start_matches(delimiter).chars().next())
.is_some_and(|ch| !ch.is_alphanumeric());
if left && let Some(ch) = out.chars().last().filter(|ch| word(*ch)) {
out.pop();
out.push_str(&format!("&#{};", u32::from(ch)));
}
let right = out.ends_with('_')
|| out.ends_with(['*', '~'])
&& out
.chars()
.next_back()
.and_then(|delimiter| out.trim_end_matches(delimiter).chars().next_back())
.is_some_and(|ch| !ch.is_alphanumeric());
if right && let Some(ch) = text.chars().next().filter(|ch| word(*ch)) {
out.push_str(&format!("&#{};", u32::from(ch)));
out.push_str(&text[ch.len_utf8()..]);
} else {
out.push_str(text);
}
}
pub(crate) struct SelectedLine {
source: LogicalLineSource,
range: Range<usize>,
separator: String,
}
impl SelectedLine {
pub(crate) fn append(
lines: &mut Vec<Self>,
source: LogicalLineSource,
range: Range<usize>,
separator: &str,
) {
// Streaming can split the same logical line across history cells.
if let Some(previous) = lines.last_mut()
&& Arc::ptr_eq(&previous.source.text, &source.text)
&& previous.range.end <= range.start
{
previous.range.end = range.end;
} else {
let separator = if separator.starts_with('\n')
&& lines.last().is_some_and(|previous| {
previous.range.end == previous.source.text.len()
&& (previous.source.copy_as_prose
|| previous
.source
.copy
.as_ref()
.is_some_and(|copy| copy.hard_break))
}) {
format!(" {separator}")
} else {
separator.to_string()
};
lines.push(Self {
source,
range,
separator,
});
}
}
}
pub(crate) fn selection(lines: &[SelectedLine], plain: &str) -> (String, CopyFormat) {
let rich = lines.iter().any(|line| {
!line.range.is_empty()
&& line
.source
.copy
.as_ref()
.is_some_and(|copy| !copy.code && !copy.omit)
});
if !rich {
return (plain.to_owned(), CopyFormat::PlainText);
}
let mut out = String::new();
let mut lines = lines
.iter()
.filter(|line| !line.source.copy.as_ref().is_some_and(|copy| copy.omit))
.peekable();
let mut first = true;
let mut indentation: Option<usize> = None;
while let Some(line) = lines.next() {
if !first {
out.push_str(&line.separator);
}
let prefix = line.source.copy.as_ref().map_or("", |copy| {
if indentation.is_none() && !line.range.is_empty() && lines.peek().is_some() {
&copy.item_prefix
} else {
&copy.prefix
}
});
if !line.range.is_empty() {
let spaces = prefix
.split("> ")
.map(|part| part.len() - part.trim_start_matches(' ').len())
.sum();
indentation = Some(indentation.map_or(spaces, |previous| previous.min(spaces)));
}
let prefix = dedent(prefix, indentation.unwrap_or(/*default*/ 0));
if line.range.is_empty() {
if line.source.text.is_empty() {
out.push_str(prefix.trim_end());
}
first = false;
continue;
}
if line.source.copy_as_prose {
let text = &line.source.text[line.range.clone()];
push_prose_fragment(&mut out, &escape(text));
} else if let Some(copy) = &line.source.copy
&& !copy.code
{
if line.range.start == 0 || first && lines.peek().is_some() {
out.push_str(&prefix);
}
let body = copy.render(&line.source.text, line.range.clone(), /*depth*/ 0);
push_prose_fragment(&mut out, &body);
} else {
// Preserve whitespace and Markdown-looking tool output within mixed selections.
let mut code = line.source.text[line.range.clone()].to_owned();
let continuation = line
.source
.copy
.as_ref()
.map_or("", |copy| copy.continuation.as_str());
while let Some(next) = lines.peek()
&& !next.source.copy_as_prose
&& next.source.copy.is_some() == line.source.copy.is_some()
&& next.source.copy.as_ref().is_none_or(|copy| copy.code)
&& next
.source
.copy
.as_ref()
.map_or("", |copy| copy.prefix.as_str())
== continuation
&& next
.source
.copy
.as_ref()
.map_or("", |copy| copy.continuation.as_str())
== continuation
{
code.push_str(&next.separator);
code.push_str(&next.source.text[next.range.clone()]);
lines.next();
}
let fence = fence(&code, /*minimum*/ 3);
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
let continuation = dedent(continuation, indentation.unwrap_or(/*default*/ 0));
// A checkbox label must precede the fence on its own line.
if prefix.ends_with("[ ] ") || prefix.ends_with("[x] ") {
out.push_str(prefix.trim_end());
out.push('\n');
out.push_str(&continuation);
} else {
out.push_str(&prefix);
}
out.push_str(&format!("{fence}\n"));
for row in code.split_inclusive('\n') {
out.push_str(&continuation);
out.push_str(row);
}
if !out.ends_with('\n') {
out.push('\n');
}
out.push_str(&continuation);
out.push_str(&fence);
}
first = false;
}
out.retain(|ch| !ch.is_control() || matches!(ch, '\n' | '\t'));
(out, CopyFormat::Markdown)
}
// Remove unselected ancestor list padding without discarding blockquote markers.
fn dedent(prefix: &str, mut spaces: usize) -> String {
prefix
.split_inclusive("> ")
.map(|part| {
let remove = spaces.min(part.len() - part.trim_start_matches(' ').len());
spaces -= remove;
&part[remove..]
})
.collect()
}
fn fence(text: &str, minimum: usize) -> String {
"`".repeat(
text.split(|ch| ch != '`')
.map(str::len)
.max()
.unwrap_or(/*default*/ 0)
.saturating_add(/*rhs*/ 1)
.max(minimum),
)
}
// Append escaped prose without allowing its leading whitespace to become code indentation.
fn push_prose_fragment(out: &mut String, text: &str) {
let body = text.trim_start_matches([' ', '\t']);
for ch in text[..text.len() - body.len()].chars() {
out.push_str(if ch == '\t' { "&#9;" } else { "&#32;" });
}
out.push_str(body);
}
fn escape(text: &str) -> String {
let mut out = String::new();
let mut whitespace_prefix = true;
let mut numeric_prefix = false;
let mut number_has_trailing_space = false;
for ch in text.chars() {
let block_marker =
"=-+".contains(ch) && whitespace_prefix || matches!(ch, '.' | ')') && numeric_prefix;
if "\\`*_[]<>~#|&!".contains(ch) || block_marker {
out.push('\\');
}
out.push(ch);
if ch.is_whitespace() {
number_has_trailing_space |= numeric_prefix;
} else {
numeric_prefix = ch.is_ascii_digit()
&& (whitespace_prefix || numeric_prefix && !number_has_trailing_space);
whitespace_prefix = false;
}
}
out
}
#[cfg(test)]
#[path = "markdown_copy_tests.rs"]
mod tests;
+120
View File
@@ -0,0 +1,120 @@
//! Verify escaping and bounded copy annotations through the real renderer.
use super::*;
use crate::markdown_render::render_markdown_lines_with_width_and_cwd;
use pretty_assertions::assert_eq;
use std::path::Path;
#[test]
fn escaping_tracks_block_prefixes_without_rescanning_them() {
for (text, expected) in [
(" - item", " \\- item"),
(" 123.)", " 123\\.)"),
("12\u{2003}.", "12\u{2003}\\."),
("12 3.", "12 3."),
("\u{2003}12\t)", "\u{2003}12\t\\)"),
("12.34)", "12\\.34)"),
("word - text", "word - text"),
("\u{2003}+", "\u{2003}\\+"),
] {
assert_eq!(escape(text), expected);
}
let digits = "1".repeat(/*n*/ 20_000);
let punctuation = ".".repeat(/*n*/ 20_000);
assert_eq!(
escape(&format!("{digits}{punctuation}")),
format!("{digits}\\{punctuation}")
);
}
#[test]
fn deeply_nested_lazy_continuations_keep_bounded_relative_copy_prefixes() {
let depth = 256;
let continuation_count = 256;
let markdown = format!(
"{}text\n{}",
"- ".repeat(depth),
"a\n".repeat(continuation_count)
);
let lines =
render_markdown_lines_with_width_and_cwd(&markdown, /*width*/ None, /*cwd*/ None);
let copies = lines
.iter()
.filter_map(|line| line.source.as_ref())
.filter_map(|source| source.copy.as_ref())
.collect::<Vec<_>>();
let continuation = " ".repeat(MAX_COPY_DEPTH);
let item_prefix = format!("{}- ", " ".repeat(MAX_COPY_DEPTH - 1));
assert_eq!(copies.len(), continuation_count + 1);
assert!(
copies
.iter()
.all(|copy| copy.continuation == continuation && copy.item_prefix == item_prefix)
);
assert_eq!(copies[0].prefix, item_prefix);
assert!(copies[1..].iter().all(|copy| copy.prefix == continuation));
}
#[test]
fn code_display_padding_does_not_displace_a_retained_container() {
let depth = MAX_COPY_DEPTH + 1;
let indent = " ".repeat(depth);
let markdown = format!(
"{}before\n\n{indent}```\n{indent}code()\n{indent}```\n",
"- ".repeat(depth)
);
let lines =
render_markdown_lines_with_width_and_cwd(&markdown, /*width*/ None, /*cwd*/ None);
let prefixes = lines
.iter()
.filter_map(|line| line.source.as_ref())
.filter_map(|source| source.copy.as_ref())
.filter(|copy| copy.code)
.map(|copy| copy.prefix.clone())
.collect::<Vec<_>>();
assert_eq!(prefixes, vec![" ".repeat(MAX_COPY_DEPTH)]);
}
#[test]
fn link_runs_share_destinations_and_bound_nested_formatting() {
for destination in ["https://example.com/", "/repo/"] {
let destination = format!("{destination}{}", "a".repeat(/*n*/ 4096));
let label = format!(
"**prefix** {}middle{}",
"*a ".repeat(/*n*/ 128),
" b*".repeat(/*n*/ 128)
);
let markdown = format!("[{label}](<{destination}>)");
let lines = render_markdown_lines_with_width_and_cwd(
&markdown,
/*width*/ None,
Some(Path::new("/")),
);
let runs = lines
.iter()
.filter_map(|line| line.source.as_ref())
.filter_map(|source| source.copy.as_ref())
.flat_map(|copy| &copy.runs)
.map(|(_, inline)| inline)
.collect::<Vec<_>>();
assert!(runs.len() > 1);
assert!(runs.iter().all(|inline| inline.len() <= 5));
let links = runs
.iter()
.flat_map(|inline| inline.iter())
.filter_map(|inline| {
if let Inline::Link(destination) = inline {
Some(destination)
} else {
None
}
})
.collect::<Vec<_>>();
if destination.starts_with('/') {
assert!(links.is_empty());
continue;
}
assert!(links.len() > 1);
assert!(links.iter().all(|link| Arc::ptr_eq(links[0], link)));
}
}
+124 -4
View File
@@ -162,14 +162,24 @@ impl MarkdownStyles {
struct IndentContext {
prefix: Vec<Span<'static>>,
marker: Option<Vec<Span<'static>>>,
copy_marker: String,
is_list: bool,
}
impl IndentContext {
fn new(prefix: Vec<Span<'static>>, marker: Option<Vec<Span<'static>>>, is_list: bool) -> Self {
// Keep the canonical marker separate from depth-dependent display padding.
let copy_marker = marker
.iter()
.flatten()
.map(|span| span.content.as_ref())
.collect::<String>()
.trim_start()
.replace('•', "-");
Self {
prefix,
marker,
copy_marker,
is_list,
}
}
@@ -396,6 +406,7 @@ struct LinkState {
/// can collapse to this canonical target without losing descriptive labels.
local_target_display: Option<String>,
local_label_spans: Vec<Span<'static>>,
local_label_inline: Vec<Vec<crate::markdown_copy::Inline>>,
}
fn should_render_link_destination(dest_url: &str) -> bool {
@@ -415,6 +426,8 @@ struct Writer<'a, 'policy> {
text: Vec<HyperlinkLine>,
styles: MarkdownStyles,
inline_styles: Vec<Style>,
copy_inline: Vec<crate::markdown_copy::Inline>,
copy_line: crate::markdown_copy::CopyLine,
indent_stack: Vec<IndentContext>,
list_indices: Vec<Option<u64>>,
list_needs_blank_before_next_item: Vec<bool>,
@@ -455,6 +468,8 @@ impl<'a, 'policy> Writer<'a, 'policy> {
text: Vec::new(),
styles: MarkdownStyles::default(),
inline_styles: Vec::new(),
copy_inline: Vec::new(),
copy_line: Default::default(),
indent_stack: Vec::new(),
list_indices: Vec::new(),
list_needs_blank_before_next_item: Vec::new(),
@@ -499,6 +514,17 @@ impl<'a, 'policy> Writer<'a, 'policy> {
I: Iterator<Item = (Event<'a>, Range<usize>)>,
{
self.prepare_for_event(&event);
let copy_inline = crate::markdown_copy::Inline::from_event(&event);
if let Some(inline) = &copy_inline {
self.copy_inline.push(inline.clone());
}
let close_inline = matches!(
event,
Event::Code(_)
| Event::End(
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough | TagEnd::Link
)
);
match event {
Event::Start(tag) => self.start_tag(tag, range, iter),
Event::End(tag) => self.end_tag(tag, range),
@@ -517,6 +543,7 @@ impl<'a, 'policy> Writer<'a, 'policy> {
self.push_blank_line();
}
self.push_line(Line::from("———"));
self.copy_line.rule = true;
self.needs_newline = true;
}
Event::Html(html) => self.html(html, /*inline*/ false),
@@ -533,6 +560,9 @@ impl<'a, 'policy> Writer<'a, 'policy> {
}
}
}
if close_inline {
self.copy_inline.pop();
}
}
fn prepare_for_event(&mut self, event: &Event<'a>) {
@@ -623,7 +653,15 @@ impl<'a, 'policy> Writer<'a, 'policy> {
fn end_tag(&mut self, tag: TagEnd, range: Range<usize>) {
match tag {
TagEnd::Paragraph => self.end_paragraph(),
TagEnd::Paragraph => {
// At a streaming EOF, the parser omits a trailing hard-break event. Retain it
// before this line is committed and a later delta supplies the next line.
self.copy_line.hard_break |= self
.input
.get(..range.end)
.is_some_and(|prefix| prefix.ends_with(" \n") || prefix.ends_with(" \r\n"));
self.end_paragraph();
}
TagEnd::Heading(_) => self.end_heading(),
TagEnd::BlockQuote => self.end_blockquote(),
TagEnd::CodeBlock => self.end_codeblock(range),
@@ -696,7 +734,9 @@ impl<'a, 'policy> Writer<'a, 'policy> {
HeadingLevel::H6 => self.styles.h6,
};
let content = format!("{} ", "#".repeat(level as usize));
let heading = content.len();
self.push_line(Line::from(vec![Span::styled(content, heading_style)]));
self.copy_line.heading = heading;
self.push_inline_style(heading_style);
self.needs_newline = false;
}
@@ -865,6 +905,7 @@ impl<'a, 'policy> Writer<'a, 'policy> {
self.push_table_cell_hard_break();
return;
}
self.copy_line.hard_break = true;
self.push_line(Line::default());
}
@@ -923,6 +964,14 @@ impl<'a, 'policy> Writer<'a, 'policy> {
if separate {
let index = self.text.len();
self.push_blank_line();
if let Some(line) = self.text.get_mut(index) {
let mut source =
crate::terminal_hyperlinks::LogicalLineSource::from_line(&line.line);
let mut copy = crate::markdown_copy::CopyLine::default();
copy.omit = true;
source.copy = Some(std::sync::Arc::new(copy));
line.source = Some(source);
}
if self.text.len() > index
&& let Some(list) = self.uniform_lists.last_mut()
{
@@ -1073,6 +1122,7 @@ impl<'a, 'policy> Writer<'a, 'policy> {
self.push_prewrapped_line(line, pending_marker_line);
} else {
self.push_hyperlink_line(line);
self.copy_line.code = true;
self.flush_current_line();
}
pending_marker_line = false;
@@ -1981,6 +2031,7 @@ impl<'a, 'policy> Writer<'a, 'policy> {
None
},
local_label_spans: Vec::new(),
local_label_inline: Vec::new(),
destination: dest_url,
});
}
@@ -2050,8 +2101,14 @@ impl<'a, 'policy> Writer<'a, 'policy> {
self.push_line(Line::default());
}
if show_label {
for label_span in link.local_label_spans {
for (label_span, inline) in link
.local_label_spans
.into_iter()
.zip(link.local_label_inline)
{
let previous = std::mem::replace(&mut self.copy_inline, inline);
self.push_span(label_span);
self.copy_inline = previous;
}
self.push_span(" (".into());
}
@@ -2075,6 +2132,13 @@ impl<'a, 'policy> Writer<'a, 'policy> {
fn push_local_link_label_span(&mut self, span: Span<'static>) {
if let Some(link) = self.link.as_mut() {
link.local_label_spans.push(span);
link.local_label_inline.push(
self.copy_inline[..self
.copy_inline
.len()
.min(crate::markdown_copy::MAX_COPY_DEPTH)]
.to_vec(),
);
}
}
@@ -2093,10 +2157,13 @@ impl<'a, 'policy> Writer<'a, 'policy> {
fn flush_current_line(&mut self) {
if let Some(mut line) = self.current_line_content.take() {
let style = self.current_line_style;
let mut source = crate::terminal_hyperlinks::LogicalLineSource::from_line(&line.line);
source.copy = Some(std::sync::Arc::new(std::mem::take(&mut self.copy_line)));
// NB we don't wrap code in code blocks, in order to preserve whitespace for copy/paste.
if !self.current_line_in_code_block
&& let Some(width) = self.wrap_width
{
line.source = Some(source);
let opts = RtOptions::new(width)
.initial_indent(self.current_initial_indent.clone().into())
.subsequent_indent(self.current_subsequent_indent.clone().into());
@@ -2108,8 +2175,6 @@ impl<'a, 'policy> Writer<'a, 'policy> {
}
} else {
let mut spans = self.current_initial_indent.clone();
let mut source =
crate::terminal_hyperlinks::LogicalLineSource::from_line(&line.line);
source.prefix_bytes = spans.iter().map(|span| span.content.len()).sum();
source.continuation_indent = self.current_subsequent_indent.clone().into();
line.source = Some(source);
@@ -2152,6 +2217,16 @@ impl<'a, 'policy> Writer<'a, 'policy> {
};
let mut spans = self.prefix_spans(pending_marker_line);
let mut source = crate::terminal_hyperlinks::LogicalLineSource::from_line(&line.line);
let mut copy = crate::markdown_copy::CopyLine::default();
copy.prefix = self.copy_prefix(pending_marker_line);
copy.continuation = self.copy_prefix(/*pending_marker_line*/ false);
copy.item_prefix = self.copy_prefix(/*pending_marker_line*/ true);
copy.code = true;
source.copy = Some(std::sync::Arc::new(copy));
source.prefix_bytes = spans.iter().map(|span| span.content.len()).sum();
source.continuation_indent = self.prefix_spans(/*pending_marker_line*/ false).into();
line.source = Some(source);
let shift = Self::spans_display_width(&spans);
spans.append(&mut line.line.spans);
for hyperlink in &mut line.hyperlinks {
@@ -2174,6 +2249,14 @@ impl<'a, 'policy> Writer<'a, 'policy> {
self.current_initial_indent = self.prefix_spans(was_pending);
self.current_subsequent_indent = self.prefix_spans(/*pending_marker_line*/ false);
self.current_line_style = style;
self.copy_line = crate::markdown_copy::CopyLine::default();
self.copy_line.prefix = self.copy_prefix(was_pending);
self.copy_line.continuation = self.copy_prefix(/*pending_marker_line*/ false);
self.copy_line.item_prefix = self.copy_prefix(/*pending_marker_line*/ true);
self.copy_line.code = self.in_code_block;
// Heading markers are already visible; retain them as Markdown syntax.
self.copy_line
.push(line.spans.iter().map(|span| span.content.len()).sum(), &[]);
self.current_line_content = Some(HyperlinkLine::new(line));
self.current_line_in_code_block = self.in_code_block;
self.line_ends_with_local_link_target = false;
@@ -2195,6 +2278,7 @@ impl<'a, 'policy> Writer<'a, 'policy> {
self.push_line(Line::default());
}
if let Some(line) = self.current_line_content.as_mut() {
self.copy_line.push(span.content.len(), &self.copy_inline);
line.push_span(
span,
self.link.as_ref().map(|link| link.destination.as_str()),
@@ -2207,6 +2291,15 @@ impl<'a, 'policy> Writer<'a, 'policy> {
self.push_line(Line::default());
}
if let Some(line) = self.current_line_content.as_mut() {
self.copy_line.push(
appended
.line
.spans
.iter()
.map(|span| span.content.len())
.sum(),
&self.copy_inline,
);
let shift = line.width();
line.line.spans.append(&mut appended.line.spans);
line.hyperlinks
@@ -2249,6 +2342,33 @@ impl<'a, 'policy> Writer<'a, 'policy> {
self.text.push(line);
}
// Markdown needs relative container indentation, not the display's fixed-depth padding.
fn copy_prefix(&self, pending_marker_line: bool) -> String {
// Code's display padding is not a Markdown container. Keep only the innermost semantic
// containers so lazy continuation lines cannot each retain an arbitrarily deep prefix.
let containers =
&self.indent_stack[..self.indent_stack.len() - usize::from(self.in_code_block)];
let containers = &containers[containers
.len()
.saturating_sub(crate::markdown_copy::MAX_COPY_DEPTH)..];
let last_list = containers.iter().rposition(|context| context.is_list);
let mut prefix = String::new();
for (index, context) in containers.iter().enumerate() {
if context.is_list {
let marker = &context.copy_marker;
let width = marker.find('[').unwrap_or(marker.len());
if pending_marker_line && Some(index) == last_list {
prefix.push_str(marker);
} else {
prefix.extend(std::iter::repeat_n(' ', width));
}
} else {
prefix.extend(context.prefix.iter().map(|span| span.content.as_ref()));
}
}
prefix
}
fn prefix_spans(&self, pending_marker_line: bool) -> Vec<Span<'static>> {
let mut prefix: Vec<Span<'static>> = Vec::new();
let last_marker_index = if pending_marker_line {
@@ -21,11 +21,19 @@ impl Writer<'_, '_> {
let checkbox = if checked { "☑" } else { "☐" };
span.content = format!("{prefix}{checkbox} ").into();
context.prefix = vec![Span::from(" ".repeat(Self::spans_display_width(marker)))];
context
.copy_marker
.push_str(if checked { "[x] " } else { "[ ] " });
}
if self.current_line_content.is_some() {
// Loose lists start their paragraph before the task marker arrives.
self.current_initial_indent = self.prefix_spans(/*pending_marker_line*/ true);
self.current_subsequent_indent = self.prefix_spans(/*pending_marker_line*/ false);
self.copy_line.prefix = self.copy_prefix(/*pending_marker_line*/ true);
self.copy_line.continuation = self.copy_prefix(/*pending_marker_line*/ false);
self.copy_line
.item_prefix
.clone_from(&self.copy_line.prefix);
}
}
}
+1 -1
View File
@@ -450,7 +450,7 @@ impl TranscriptOverlay {
&self.cells,
&text,
!copy_on_select,
|text| tui.copy_transcript_selection(text),
|text, format| tui.copy_transcript_selection(text, format),
);
if resume_following
&& matches!(result, Ok(crate::clipboard_copy::CopyStatus::Pending(_)))
+11 -1
View File
@@ -1,10 +1,13 @@
//! A conservative fast path for one open, top-level, language-tagged code fence.
use crate::markdown_copy::CopyLine;
use crate::render::highlight::MAX_HIGHLIGHT_LINE_BYTES;
use crate::render::highlight::StreamingCodeHighlighter;
use crate::render::highlight::syntax_theme_revision;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::LogicalLineSource;
use ratatui::text::Span;
use std::sync::Arc;
/// An append-only fence whose original source is identical to pulldown-cmark's code text.
pub(super) struct OpenCodeFence {
@@ -100,12 +103,19 @@ impl OpenCodeFence {
let (highlighter, lines) = highlighter.append(committed_source)?;
self.highlighter = Some(highlighter);
self.source_len = raw_source.len();
let mut copy = CopyLine::default();
copy.code = true;
let copy = Arc::new(copy);
let lines = lines
.into_iter()
.map(|mut line| {
let mut source = LogicalLineSource::from_line(&line);
source.copy = Some(Arc::clone(&copy));
// The canonical writer installs an empty indent span for top-level fences.
line.spans.insert(/*index*/ 0, Span::default());
HyperlinkLine::new(line)
let mut line = HyperlinkLine::new(line);
line.source = Some(source);
line
})
.collect();
Some((self, lines))
@@ -23,6 +23,9 @@ pub(crate) enum LineWrapPolicy {
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct LogicalLineSource {
pub(crate) text: Arc<str>,
pub(crate) copy: Option<Arc<crate::markdown_copy::CopyLine>>,
/// Literal user text remains prose when copied alongside rendered Markdown.
pub(crate) copy_as_prose: bool,
/// Span styles share the text's byte coordinates, including whitespace omitted by wrapping.
pub(crate) styles: Arc<[(Range<usize>, Style)]>,
/// Row style, applied beneath explicit span styles just like ratatui's `Line`.
@@ -42,6 +45,8 @@ impl LogicalLineSource {
let end = text.len();
Self {
text: text.into(),
copy: None,
copy_as_prose: false,
styles: vec![(0..end, Style::default())].into(),
line_style: Style::default(),
span_style: Style::default(),
@@ -100,6 +105,8 @@ impl LogicalLineSource {
let end = displayed.end.clamp(source_start, source_end);
Self {
text: Arc::clone(&self.text),
copy: self.copy.clone(),
copy_as_prose: self.copy_as_prose,
styles: Arc::clone(&self.styles),
line_style: self.line_style,
span_style: self.span_style,
+4
View File
@@ -592,6 +592,10 @@ impl TranscriptView {
#[path = "transcript_view_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "transcript_view/markdown_copy_tests.rs"]
mod markdown_copy_tests;
#[cfg(test)]
#[path = "transcript_view/copy_on_select_tests.rs"]
mod copy_on_select_tests;
@@ -21,7 +21,7 @@ fn pending_copy_feedback_survives_expiry_and_selection_replacement() {
&cells,
&selected,
/*clear_selection*/ true,
|_| Ok(CopyStatus::Pending(1)),
|_, _format| Ok(CopyStatus::Pending(1)),
);
view.show_copy_feedback(&pending, selected.chars().count());
view.show_copy_feedback(&Ok(CopyStatus::Busy), /*characters*/ 0);
@@ -80,7 +80,7 @@ fn copy_on_select_waits_for_release_and_retains_selection() {
&cells,
&copied,
/*clear_selection*/ false,
|_| Ok(CopyStatus::Pending(1)),
|_, _format| Ok(CopyStatus::Pending(1)),
)
.unwrap();
assert_eq!(
@@ -130,10 +130,15 @@ fn copying_selection_at_bottom_does_not_show_back_to_bottom() {
view.begin_selection(&cells, /*column*/ 0, /*row*/ 2, /*clicks*/ 2);
view.end_drag();
let selected = view.selected_text(&cells).expect("selected word");
view.copy_selected_text_with(&cells, &selected, /*clear_selection*/ true, |text| {
assert_eq!(text, "needle");
Ok(crate::clipboard_copy::CopyStatus::Confirmed)
})
view.copy_selected_text_with(
&cells,
&selected,
/*clear_selection*/ true,
|text, _format| {
assert_eq!(text, "needle");
Ok(crate::clipboard_copy::CopyStatus::Confirmed)
},
)
.expect("successful copy");
let after = paint(&mut view, &cells, /*width*/ 40);
@@ -221,9 +226,12 @@ fn copying_live_selection_retains_the_displayed_revision() {
view.sync_live_tail(/*width*/ 40, /*key*/ None, |_| {
Some(vec![HyperlinkLine::from("changed live text")])
});
view.copy_selected_text_with(&cells, &selected, /*clear_selection*/ true, |_| {
Ok(crate::clipboard_copy::CopyStatus::Confirmed)
})
view.copy_selected_text_with(
&cells,
&selected,
/*clear_selection*/ true,
|_, _format| Ok(crate::clipboard_copy::CopyStatus::Confirmed),
)
.unwrap();
let buffer = paint(&mut view, &cells, /*width*/ 40);
assert!(!view.is_following());
@@ -0,0 +1,688 @@
use super::*;
use crate::clipboard_copy::CopyFormat;
use crate::history_cell::AgentMarkdownCell;
use crate::history_cell::HistoryCell;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use std::path::Path;
#[test]
fn selected_markdown_preserves_lists_and_inline_code() {
let cells: Vec<Arc<dyn HistoryCell>> = vec![Arc::new(AgentMarkdownCell::new(
"- setting `closeRequested_`,\n- removing the tailer from `tailers_`,\n- clearing its live/discovery state.".into(),
Path::new("/"),
))];
let mut view = TranscriptView::default();
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 32, /*height*/ 24,
);
view.render(area, &mut Buffer::empty(area), &cells);
// Exercise the public gesture path rather than manufacturing metadata.
use crossterm::event::KeyModifiers;
use crossterm::event::MouseButton;
use crossterm::event::MouseEvent;
use crossterm::event::MouseEventKind;
view.handle_mouse(
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 2,
row: 0,
modifiers: KeyModifiers::NONE,
},
&cells,
);
view.handle_mouse(
MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 31,
row: 23,
modifiers: KeyModifiers::NONE,
},
&cells,
);
let plain = view.selected_text(&cells).unwrap();
view.copy_selected_text_with(
&cells,
&plain,
/*clear_selection*/ true,
|text, format| {
assert_eq!(format, CopyFormat::Markdown);
insta::assert_snapshot!(text);
insta::assert_snapshot!(
"selected_list_html",
crate::clipboard_html::render_markdown(text)
);
Ok(crate::clipboard_copy::CopyStatus::Confirmed)
},
)
.unwrap();
}
fn markdown_layout(markdown: &str, width: u16) -> TextLayout {
let cell = AgentMarkdownCell::new(markdown.into(), Path::new("/"));
TextLayout::new(
cell.retained_hyperlink_lines(width, /*detailed*/ false),
width,
)
}
fn payload(layout: &TextLayout, range: std::ops::Range<usize>) -> (String, CopyFormat) {
let mut lines = Vec::new();
layout.copy_lines(range.clone(), "", &mut lines);
crate::markdown_copy::selection(&lines, &layout.text()[range])
}
#[test]
fn partial_markup_is_balanced_and_preserves_selected_characters() {
for (source, selected, expected) in [
("before `closeRequested_` after", "Requested", "`Requested`"),
("before ``a`b`` after", "a`", "`` a` ``"),
("**hello café界**", "café界", "**café界**"),
("before * first *", "first", "first"),
("before **hello world** after", " world", "&#32;**world**"),
(
"**strong *nested* text**",
"strong nested text",
"**strong _nested_ text**",
),
(
r"escaped \![label](https://example.com)",
"escaped !label",
r"escaped \![label](<https://example.com>)",
),
(
"[documentation](https://example.com)",
"ment", // codespell:ignore ment
"[ment](<https://example.com>)", // codespell:ignore ment
),
("[**label**](/repo/file.rs)", "label", "**label**"),
(
"escaped \\*literal\\* &amp; `code`",
"*literal* & code",
"\\*literal\\* \\& `code`",
),
] {
let layout = markdown_layout(source, /*width*/ 80);
let start = layout.text().find(selected).expect(source);
assert_eq!(
payload(&layout, start..start + selected.len()),
(expected.into(), CopyFormat::Markdown),
"{source}"
);
}
}
#[test]
fn inline_boundaries_preserve_selected_text_and_formatting() {
let mut copies = Vec::new();
for (source, selected, expected_html) in [
(
"_a **b**_**c**",
"a bc",
"<p><em>a</em> <strong><em>b</em>c</strong></p>\n",
),
(
"_a **b**_**c**",
"bc",
"<p><strong><em>b</em>c</strong></p>\n",
),
(
"*a **b***[**c**](/repo)",
"a bc",
"<p><em>a</em> <strong><em>b</em>c</strong></p>\n",
),
("_*a*_[_b_](/repo)", "ab", "<p><em>ab</em></p>\n"),
(
"*[a **b**](https://example.com) c*",
"a b",
"<p><a href=\"https://example.com\"><em>a</em> <strong><em>b</em></strong></a></p>\n",
),
(
"a*b*c a**b**c",
"abc abc",
"<p>a<em>b</em>c a<strong>b</strong>c</p>\n",
),
("&#97;_b_&#99;", "abc", "<p>a<em>b</em>c</p>\n"),
("&#97;__b__&#99;", "abc", "<p>a<strong>b</strong>c</p>\n"),
("&#769;_b_", "\u{301}b", "<p>\u{301}<em>b</em></p>\n"),
("&#97;*!*&#98;", "a!b", "<p>a<em>!</em>b</p>\n"),
("&#97;*&#12290;*&#98;", "a。b", "<p>a<em>。</em>b</p>\n"),
(
"&#97;**_b_**&#99;",
"abc",
"<p>a<strong><em>b</em></strong>c</p>\n",
),
(
"&#97;**`b`**&#99;",
"abc",
"<p>a<strong><code>b</code></strong>c</p>\n",
),
("_a_[b](/repo)", "ab", "<p><em>a</em>b</p>\n"),
(
"&#32;&#32;&#32;&#32;**hello**",
" hello",
"<p> <strong>hello</strong></p>\n",
),
] {
let layout = markdown_layout(source, /*width*/ 80);
let start = layout.text().find(selected).expect(source);
let (copied, format) = payload(&layout, start..start + selected.len());
assert_eq!(format, CopyFormat::Markdown);
let html = crate::clipboard_html::render_markdown(&copied);
assert_eq!(html, expected_html, "{source:?} => {copied:?}");
copies.push(format!(
"{source}\nSelected: {selected:?}\nMarkdown: {copied}\n{html}"
));
}
insta::assert_snapshot!(copies.join("\n"));
}
#[test]
fn lists_keep_nesting_and_paragraphs_without_sibling_spacers() {
let source = "3. first item long enough to wrap\n4. second item\n - nested `code`\n\n another paragraph\n - next nested\n5. final item\n\nOutside.";
let mut copies = Vec::new();
for width in [24, 80] {
let layout = markdown_layout(source, width);
let copied = payload(&layout, 0..layout.text().len()).0;
assert_eq!(
payload(&layout.rewrap(/*width*/ 14), 0..layout.text().len()).0,
copied
);
copies.push(copied);
}
assert_eq!(copies[0], copies[1]);
insta::assert_snapshot!(copies[0]);
}
#[test]
fn code_only_is_literal_and_mixed_code_has_safe_fences() {
let layout = markdown_layout(
"Prose\n\n````text\n *literal* <tag>\n```\n````\n\nAfter",
/*width*/ 80,
);
let selected = " *literal* <tag>\n```";
let start = layout.text().find(selected).unwrap();
assert_eq!(
payload(&layout, start..start + selected.len()),
(selected.into(), CopyFormat::PlainText)
);
let (copy, format) = payload(&layout, 0..layout.text().len());
assert_eq!(format, CopyFormat::Markdown);
assert!(copy.contains("````\n *literal* <tag>\n```\n````"));
insta::assert_snapshot!(crate::clipboard_html::render_markdown(&copy));
}
#[test]
fn mixed_plain_output_remains_literal_in_html() {
let layout = markdown_layout("**Answer**", /*width*/ 40);
let literal = "*log* <tag>\n- not a list";
let output = TextLayout::new(
literal.lines().map(HyperlinkLine::from).collect(),
/*width*/ 40,
);
let mut lines = Vec::new();
layout.copy_lines(0..layout.text().len(), "", &mut lines);
output.copy_lines(0..output.text().len(), "\n", &mut lines);
let (copy, format) = crate::markdown_copy::selection(&lines, "unused");
assert_eq!(format, CopyFormat::Markdown);
assert_eq!(
crate::clipboard_html::render_markdown(&copy),
"<p><strong>Answer</strong></p>\n<pre><code>*log* &lt;tag&gt;\n- not a list\n</code></pre>\n"
);
assert_eq!(
payload(&output, 0..output.text().len()),
(literal.into(), CopyFormat::PlainText)
);
}
#[test]
fn mixed_user_prose_preserves_literal_text_without_code_fences() {
let cell = crate::history_cell::new_user_prompt(
" Explain *literal*\nnext <tag>".into(),
Vec::new(),
Vec::new(),
vec!["https://example.com/image.png".into()],
);
let user = TextLayout::new(
cell.retained_hyperlink_lines(/*width*/ 24, /*detailed*/ false),
/*width*/ 24,
);
assert_eq!(
payload(&user, 0..user.text().len()),
(user.text().into(), CopyFormat::PlainText)
);
let user = user.rewrap(/*width*/ 12);
let answer = markdown_layout("**Answer**", /*width*/ 24);
let tool = TextLayout::new(vec![HyperlinkLine::from("*log* <tag>")], /*width*/ 24);
let mut copies = Vec::new();
for layouts in [[&user, &answer, &tool], [&answer, &tool, &user]] {
let mut lines = Vec::new();
for layout in layouts {
layout.copy_lines(0..layout.text().len(), "\n\n", &mut lines);
}
let (text, format) = crate::markdown_copy::selection(&lines, "unused");
assert_eq!(format, CopyFormat::Markdown);
copies.push(crate::clipboard_html::render_markdown(&text));
}
insta::assert_snapshot!(copies.join("\n---\n"));
}
#[test]
fn copied_selection_keeps_its_revision_and_format_across_resize() {
use crate::clipboard_copy::CopyStatus;
let cell: Arc<dyn HistoryCell> = Arc::new(AgentMarkdownCell::new(
"before `selected` after".into(),
Path::new("/"),
));
let mut cells = vec![cell];
let mut view = TranscriptView::default();
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 4,
);
view.render(area, &mut Buffer::empty(area), &cells);
let layout = view.layout(&cells, /*index*/ 0).unwrap();
let start = layout.text().find("selected").unwrap();
view.begin_selection(
&cells,
layout.column_for_offset(start + 8),
/*row*/ 0,
/*clicks*/ 1,
);
view.extend_selection(layout.column_for_offset(start), /*row*/ 0);
view.end_drag();
assert_eq!(view.selected_text(&cells).as_deref(), Some("selected"));
cells[0] = Arc::new(AgentMarkdownCell::new("replacement".into(), Path::new("/")));
let narrow = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 12, /*height*/ 4,
);
view.render(narrow, &mut Buffer::empty(narrow), &cells);
assert_eq!(view.selected_text(&cells).as_deref(), Some("selected"));
for clear_selection in [false, true] {
view.copy_selected_text_with(&cells, "selected", clear_selection, |text, format| {
assert_eq!((text, format), ("`selected`", CopyFormat::Markdown));
Ok(CopyStatus::Pending(1))
})
.unwrap();
assert_eq!(view.selected_text(&cells).as_deref(), Some("selected"));
assert_eq!(
view.finish_copy(
&cells,
&(1, Ok(CopyStatus::Confirmed)),
/*current*/ true
),
Some(false)
);
assert_eq!(view.has_selection_range(), !clear_selection);
}
}
#[test]
fn literal_punctuation_is_not_reinterpreted_and_selected_breaks_survive() {
for (source, expected_html) in [
("&amp;amp;", "<p>&amp;amp;</p>\n"),
(r"1\) literal", "<p>1) literal</p>\n"),
("foo\n\\=\\=\\=", "<p>foo\n===</p>\n"),
] {
let layout = markdown_layout(source, /*width*/ 80);
let copy = payload(&layout, 0..layout.text().len()).0;
assert_eq!(crate::clipboard_html::render_markdown(&copy), expected_html);
}
let layout = markdown_layout("first\n\nsecond", /*width*/ 80);
assert_eq!(payload(&layout, 5..layout.text().len()).0, "\n\nsecond");
}
#[test]
fn explicit_hard_breaks_survive_only_when_selected() {
let layout = markdown_layout("first \nsecond", /*width*/ 80);
assert_eq!(payload(&layout, 0..5).0, "first");
assert_eq!(payload(&layout, 0..6).0, "first \n");
assert_eq!(
crate::clipboard_html::render_markdown(&payload(&layout, 0..layout.text().len()).0),
"<p>first<br />\nsecond</p>\n"
);
}
#[test]
fn task_lists_and_transformed_tables_keep_their_meaning() {
for (source, expected) in [
("- [x] done\n- [ ] next", "- [x] done\n- [ ] next"),
("1. [x] done\n2. [ ] next", "1. [x] done\n2. [ ] next"),
] {
let layout = markdown_layout(source, /*width*/ 80);
assert_eq!(payload(&layout, 0..layout.text().len()).0, expected);
}
let layout = markdown_layout(
"Prose\n\n| A | B |\n| - | - |\n| x | y |",
/*width*/ 80,
);
let copied = payload(&layout, 0..layout.text().len()).0;
let html = crate::clipboard_html::render_markdown(&copied);
assert!(html.contains("<pre><code>"), "{copied}\n{html}");
}
#[test]
fn link_entities_are_not_decoded_twice() {
let layout = markdown_layout(
"[label](https://example.com/?x=&amp;amp;)",
/*width*/ 80,
);
let copy = payload(&layout, 0..5).0;
assert_eq!(
crate::clipboard_html::render_markdown(&copy),
"<p><a href=\"https://example.com/?x=&amp;amp;\">label</a></p>\n"
);
}
#[test]
fn selected_local_link_keeps_one_visible_destination() {
let layout = markdown_layout("Open [**label**](/repo/file.rs).", /*width*/ 80);
let copied = payload(&layout, 0..layout.text().len()).0;
assert_eq!(copied, "Open **label** (repo/file.rs).");
assert_eq!(
crate::clipboard_html::render_markdown(&copied),
"<p>Open <strong>label</strong> (repo/file.rs).</p>\n"
);
}
#[test]
fn complete_rules_keep_structure_and_partial_rules_keep_selected_text() {
let mut copies = Vec::new();
for (source, partial) in [
("Before\n\n---\n\nAfter", "—"),
("Before\n\n> ---\n\nAfter", "> —"),
] {
let layout = markdown_layout(source, /*width*/ 80);
let copied = payload(&layout, 0..layout.text().len()).0;
copies.push(format!(
"{copied}\n{}",
crate::clipboard_html::render_markdown(&copied)
));
let start = layout.text().find('—').unwrap();
assert_eq!(payload(&layout, start..start + "—".len()).0, partial);
}
insta::assert_snapshot!(copies.join("\n---\n"));
}
#[test]
fn table_spillover_keeps_prose_outside_the_literal_table() {
let layout = markdown_layout(
"**Before**\n\n| A | B |\n| --- | --- |\n| one | two |\nHTML block:\n<div>Prose after the table.</div>\n\nAfter",
/*width*/ 80,
);
let copied = payload(&layout, 0..layout.text().len()).0;
insta::assert_snapshot!(format!(
"{copied}\n{}",
crate::clipboard_html::render_markdown(&copied)
));
}
#[test]
fn partial_prose_keeps_leading_whitespace_outside_code_blocks() {
let mut copies = Vec::new();
for source in [
"prefix suffix",
"prefix\tsuffix",
"**prefix suffix**",
"> prefix suffix",
] {
let layout = markdown_layout(source, /*width*/ 80);
let start = layout.text().find("prefix").unwrap() + "prefix".len();
let copied = payload(&layout, start..layout.text().len()).0;
copies.push(format!(
"{copied}\n{}",
crate::clipboard_html::render_markdown(&copied)
));
}
insta::assert_snapshot!(copies.join("\n---\n"));
}
#[test]
fn nested_tables_keep_their_copy_containers() {
let mut copies = Vec::new();
for source in [
"Before\n\n> | A | B |\n> | --- | --- |\n> | one | two |",
"Before\n\n- intro\n\n | A | B |\n | --- | --- |\n | one | two |",
] {
let layout = markdown_layout(source, /*width*/ 80);
let copied = payload(&layout, 0..layout.text().len()).0;
copies.push(format!(
"{copied}\n{}",
crate::clipboard_html::render_markdown(&copied)
));
}
insta::assert_snapshot!(copies.join("\n---\n"));
}
#[test]
fn selected_hard_breaks_survive_surrounding_blocks() {
let source = "First paragraph.\n\nSecond paragraph with a hard break: \nnext line.\n\n> quoted **words** \n> next quote line\n\nFinal paragraph.";
let mut stream = crate::streaming::controller::StreamController::new(
Some(78),
Path::new("/"),
crate::history_cell::HistoryRenderMode::Rich,
);
let mut cells: Vec<Arc<dyn HistoryCell>> = Vec::new();
for chunk in source.split_inclusive('\n') {
stream.push(chunk);
while let Some(cell) = stream.on_commit_tick_batch(/*max_lines*/ 1).0 {
cells.push(cell.into());
}
}
if let Some(cell) = stream.finalize().0 {
cells.push(cell.into());
}
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 40,
);
let mut view = TranscriptView::default();
view.render(area, &mut Buffer::empty(area), &cells);
view.begin_selection(&cells, /*column*/ 2, /*row*/ 0, /*clicks*/ 1);
view.extend_selection(/*column*/ 79, /*row*/ 39);
let plain = view.selected_text(&cells).unwrap();
view.copy_selected_text_with(&cells, &plain, /*clear_selection*/ true, |text, format| {
assert_eq!(format, CopyFormat::Markdown);
assert_eq!(
crate::clipboard_html::render_markdown(text),
"<p>First paragraph.</p>\n<p>Second paragraph with a hard break:<br />\nnext line.</p>\n<blockquote>\n<p>quoted <strong>words</strong><br />\nnext quote line</p>\n</blockquote>\n<p>Final paragraph.</p>\n"
);
Ok(crate::clipboard_copy::CopyStatus::Confirmed)
}).unwrap();
}
#[test]
fn selected_open_code_fence_keeps_streamed_rows_in_one_block() {
let mut stream = crate::streaming::controller::StreamController::new(
/*width*/ Some(78),
Path::new("/"),
crate::history_cell::HistoryRenderMode::Rich,
);
let mut cells: Vec<Arc<dyn HistoryCell>> = Vec::new();
for chunk in ["Prose\n\n```rust\nlet a = 1;\n", "let b = 2;\n"] {
stream.push(chunk);
while let Some(cell) = stream.on_commit_tick_batch(/*max_lines*/ 1).0 {
cells.push(cell.into());
}
}
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 10,
);
let mut view = TranscriptView::default();
view.render(area, &mut Buffer::empty(area), &cells);
view.begin_selection(&cells, /*column*/ 2, /*row*/ 0, /*clicks*/ 1);
view.extend_selection(/*column*/ 79, /*row*/ 9);
let plain = view.selected_text(&cells).unwrap();
view.copy_selected_text_with(
&cells,
&plain,
/*clear_selection*/ true,
|text, format| {
assert_eq!(format, CopyFormat::Markdown);
insta::assert_snapshot!(crate::clipboard_html::render_markdown(text), @r#"
<p>Prose</p>
<pre><code>let a = 1;
let b = 2;
</code></pre>
"#);
Ok(crate::clipboard_copy::CopyStatus::Confirmed)
},
)
.unwrap();
}
#[test]
fn cross_cell_selection_keeps_boundary_breaks() {
let first = markdown_layout("first", /*width*/ 40);
let second = markdown_layout("**second**", /*width*/ 40);
for (left, right, expected) in [(5..5, 0..6, "\n**second**"), (0..5, 0..0, "first\n")] {
let mut lines = Vec::new();
first.copy_lines(left, "", &mut lines);
second.copy_lines(right, "\n", &mut lines);
assert_eq!(
crate::markdown_copy::selection(&lines, "unused").0,
expected
);
}
let rule = markdown_layout("---", /*width*/ 40);
let mut lines = Vec::new();
first.copy_lines(0..first.text().len(), "", &mut lines);
rule.copy_lines(0..rule.text().len(), "\n", &mut lines);
let copied = crate::markdown_copy::selection(&lines, "unused").0;
assert_eq!(
crate::clipboard_html::render_markdown(&copied),
"<p>first</p>\n<hr />\n"
);
}
#[test]
fn adjacent_top_level_code_blocks_keep_blank_lines_and_boundaries() {
let layout = markdown_layout(
"Prose\n\n```\none\n\ninside\n```\n\n```\ntwo\n```",
/*width*/ 80,
);
let copied = payload(&layout, 0..layout.text().len()).0;
insta::assert_snapshot!(format!(
"Markdown:\n{copied}\n\nHTML:\n{}",
crate::clipboard_html::render_markdown(&copied)
));
}
#[test]
fn adjacent_code_containers_remain_separate() {
for (source, expected_html) in [
(
"Prose\n\n> ```\n> quoted\n> ```\n\n```\nunquoted\n```",
"<p>Prose</p>\n<blockquote>\n<pre><code>quoted\n</code></pre>\n</blockquote>\n<pre><code>unquoted\n</code></pre>\n",
),
(
"Prose\n\n- ```\n one\n ```\n- ```\n two\n ```",
"<p>Prose</p>\n<ul>\n<li>\n<pre><code>one\n</code></pre>\n</li>\n<li>\n<pre><code>two\n</code></pre>\n</li>\n</ul>\n",
),
(
"- before\n\n ```text\n let value = 1;\n ```\n\n after\n\n> quoted \n> next",
"<ul>\n<li>\n<p>before</p>\n<pre><code>let value = 1;\n</code></pre>\n<p>after</p>\n</li>\n</ul>\n<blockquote>\n<p>quoted<br />\nnext</p>\n</blockquote>\n",
),
] {
let layout = markdown_layout(source, /*width*/ 80);
let copied = payload(&layout, 0..layout.text().len()).0;
assert_eq!(
crate::clipboard_html::render_markdown(&copied),
expected_html,
"{copied}"
);
}
}
#[test]
fn selected_list_and_code_containers_keep_their_structure() {
for (source, start, end, expected) in [
(
"- parent\n - nested `code`",
"nested",
"code",
"- nested `code`",
),
(
"1. first\n2. **second**",
"irst",
"second",
"1. irst\n2. **second**",
),
(
"10. first\n\n second paragraph\n11. next",
"first",
"next",
"10. first\n\n second paragraph\n11. next",
),
(
"1. intro\n\n more words\n2. next",
"words",
"next",
"1. words\n2. next",
),
(
"- outer A\n - inner A\n- outer B\n - inner B",
"inner A",
"inner B",
"- inner A\n- outer B\n - inner B",
),
(
"> - outer\n> > - nested `code`",
"nested",
"code",
"> > - nested `code`",
),
(
"10. [x] first\n\n paragraph\n11. [ ] next",
"first",
"next",
"10. [x] first\n\n paragraph\n11. [ ] next",
),
(
"10. before\n\n ```\n code\n ```\n\n after",
"before",
"after",
"10. before\n\n ```\n code\n ```\n\n after",
),
(
"- outer\n - before\n\n ```\n code\n ```\n\n after",
"before",
"after",
"- before\n\n ```\n code\n ```\n\n after",
),
(
"Before\n\n indented_code()\n\nAfter",
"Before",
"After",
"Before\n\n```\nindented_code()\n```\n\nAfter",
),
(
"10. intro\n\n ```\n code\n ```\n\n after",
"code",
"after",
"10. ```\n code\n ```\n\n after",
),
(
"- [x] intro\n\n code\n\n after",
"code",
"after",
"- [x]\n ```\n code\n ```\n\n after",
),
(
"> - intro\n>\n> ```\n> code\n> ```\n>\n> after",
"code",
"after",
"> - ```\n> code\n> ```\n>\n> after",
),
] {
let layout = markdown_layout(source, /*width*/ 80);
let start = layout.text().find(start).expect(source);
let end = layout.text().rfind(end).expect(source) + end.len();
let copied = payload(&layout, start..end).0;
assert_eq!(
crate::clipboard_html::render_markdown(&copied),
crate::clipboard_html::render_markdown(expected),
"{copied:?} from {source:?}"
);
}
}
@@ -186,9 +186,12 @@ fn paused_header_reservation_survives_copy_until_scroll_or_resize() {
);
let selected = view.selected_text(&cells).expect("selected word");
assert_eq!(selected, "done");
view.copy_selected_text_with(&cells, &selected, /*clear_selection*/ true, |_| {
Ok(crate::clipboard_copy::CopyStatus::Confirmed)
})
view.copy_selected_text_with(
&cells,
&selected,
/*clear_selection*/ true,
|_, _format| Ok(crate::clipboard_copy::CopyStatus::Confirmed),
)
.expect("confirmed copy");
let bookmark = view.bookmark(&cells);
view.set_presentation(/*detailed*/ true, HistoryRenderMode::Rich);
@@ -38,7 +38,7 @@ fn right_click_retains_selection_until_confirmed_and_preserves_reading_position(
&cells,
&selected,
/*clear_selection*/ true,
|copied| {
|copied, _format| {
assert_eq!(copied, selected);
result.clone()
},
+33 -9
View File
@@ -159,7 +159,10 @@ impl TranscriptView {
})
}
pub(crate) fn selected_text(&mut self, cells: &[Arc<dyn HistoryCell>]) -> Option<String> {
fn selected_ranges(
&mut self,
cells: &[Arc<dyn HistoryCell>],
) -> Option<Vec<(Arc<TextLayout>, std::ops::Range<usize>)>> {
if !self.has_selection_range() {
return None;
}
@@ -176,16 +179,12 @@ impl TranscriptView {
if start == end {
return None;
}
let mut text = String::new();
let mut previous: Option<Arc<TextLayout>> = None;
let mut ranges = Vec::new();
for index in start.index..=end.index {
let layout = self.layout(cells, index)?;
if layout.row_count() == 0 {
continue;
}
if let Some(previous) = &previous {
text.push_str(previous.separator_after(&layout));
}
let begin = if index == start.index {
start.offset
} else {
@@ -196,7 +195,19 @@ impl TranscriptView {
} else {
layout.text().len()
};
text.push_str(layout.text().get(begin..finish)?);
ranges.push((layout, begin..finish));
}
Some(ranges)
}
pub(crate) fn selected_text(&mut self, cells: &[Arc<dyn HistoryCell>]) -> Option<String> {
let mut text = String::new();
let mut previous: Option<Arc<TextLayout>> = None;
for (layout, range) in self.selected_ranges(cells)? {
if let Some(previous) = &previous {
text.push_str(previous.separator_after(&layout));
}
text.push_str(layout.text().get(range)?);
previous = Some(layout);
}
text.retain(|ch| !ch.is_control() || matches!(ch, '\n' | '\t'));
@@ -210,9 +221,22 @@ impl TranscriptView {
cells: &[Arc<dyn HistoryCell>],
text: &str,
clear_selection: bool,
copy: impl FnOnce(&str) -> Result<crate::clipboard_copy::CopyStatus, String>,
copy: impl FnOnce(
&str,
crate::clipboard_copy::CopyFormat,
) -> Result<crate::clipboard_copy::CopyStatus, String>,
) -> Result<crate::clipboard_copy::CopyStatus, String> {
let result = copy(text);
let mut lines = Vec::new();
let mut previous: Option<Arc<TextLayout>> = None;
for (layout, range) in self.selected_ranges(cells).unwrap_or_default() {
let separator = previous
.as_ref()
.map_or("", |previous| previous.separator_after(&layout));
layout.copy_lines(range, separator, &mut lines);
previous = Some(layout);
}
let (text, format) = crate::markdown_copy::selection(&lines, text);
let result = copy(&text, format);
match result {
Ok(crate::clipboard_copy::CopyStatus::Confirmed) => {
if clear_selection {
@@ -98,7 +98,7 @@ fn copy_shortcuts_clear_selection_only_after_confirmed_delivery() {
&cells,
&text,
/*clear_selection*/ true,
|text| {
|text, _format| {
assert_eq!(text, "selected\tcafé");
Err("clipboard unavailable".to_string())
},
@@ -118,7 +118,7 @@ fn copy_shortcuts_clear_selection_only_after_confirmed_delivery() {
&cells,
&text,
/*clear_selection*/ true,
|text| {
|text, _format| {
assert_eq!(text, "selected\tcafé");
Ok(status)
},
@@ -169,7 +169,7 @@ fn command_c_ignores_release_and_copies_only_an_active_selection() {
panic!("Cmd+C must copy the transcript selection");
};
assert_eq!(text, "selected café");
view.copy_selected_text_with(&cells, &text, /*clear_selection*/ true, |_| {
view.copy_selected_text_with(&cells, &text, /*clear_selection*/ true, |_, _format| {
Ok(crate::clipboard_copy::CopyStatus::Confirmed)
})
.unwrap();
@@ -648,7 +648,7 @@ fn pending_copy_only_finishes_the_original_selection() {
&cells,
&selected,
/*clear_selection*/ true,
|_| Ok(CopyStatus::Pending(1)),
|_, _format| Ok(CopyStatus::Pending(1)),
)
.unwrap();
view.follow_pending_copy();
@@ -0,0 +1,25 @@
---
source: tui/src/transcript_view/markdown_copy_tests.rs
expression: "format!(\"Markdown:\\n{copied}\\n\\nHTML:\\n{}\",\ncrate::clipboard_html::render_markdown(&copied))"
---
Markdown:
Prose
```
one
inside
```
```
two
```
HTML:
<p>Prose</p>
<pre><code>one
inside
</code></pre>
<pre><code>two
</code></pre>
@@ -0,0 +1,10 @@
---
source: tui/src/transcript_view/markdown_copy_tests.rs
assertion_line: 88
expression: "crate::clipboard_html::render_markdown(&copy)"
---
<p>Prose</p>
<pre><code> *literal* &lt;tag&gt;
```
</code></pre>
<p>After</p>
@@ -0,0 +1,26 @@
---
source: tui/src/transcript_view/markdown_copy_tests.rs
assertion_line: 348
expression: "copies.join(\"\\n---\\n\")"
---
Before
***
After
<p>Before</p>
<hr />
<p>After</p>
---
Before
>
> ***
After
<p>Before</p>
<blockquote>
<hr />
</blockquote>
<p>After</p>
@@ -0,0 +1,79 @@
---
source: tui/src/transcript_view/markdown_copy_tests.rs
assertion_line: 139
expression: "copies.join(\"\\n\")"
---
_a **b**_**c**
Selected: "a bc"
Markdown: _a_ **_b_&#99;**
<p><em>a</em> <strong><em>b</em>c</strong></p>
_a **b**_**c**
Selected: "bc"
Markdown: **_b_&#99;**
<p><strong><em>b</em>c</strong></p>
*a **b***[**c**](/repo)
Selected: "a bc"
Markdown: _a_ **_b_&#99;**
<p><em>a</em> <strong><em>b</em>c</strong></p>
_*a*_[_b_](/repo)
Selected: "ab"
Markdown: _ab_
<p><em>ab</em></p>
*[a **b**](https://example.com) c*
Selected: "a b"
Markdown: [_a_ **_b_**](<https://example.com>)
<p><a href="https://example.com"><em>a</em> <strong><em>b</em></strong></a></p>
a*b*c a**b**c
Selected: "abc abc"
Markdown: &#97;_b_&#99; a**b**c
<p>a<em>b</em>c a<strong>b</strong>c</p>
&#97;_b_&#99;
Selected: "abc"
Markdown: &#97;_b_&#99;
<p>a<em>b</em>c</p>
&#97;__b__&#99;
Selected: "abc"
Markdown: a**b**c
<p>a<strong>b</strong>c</p>
&#769;_b_
Selected: "\u{301}b"
Markdown: &#769;_b_
<p>́<em>b</em></p>
&#97;*!*&#98;
Selected: "a!b"
Markdown: &#97;_\!_&#98;
<p>a<em>!</em>b</p>
&#97;*&#12290;*&#98;
Selected: "a。b"
Markdown: &#97;_。_&#98;
<p>a<em>。</em>b</p>
&#97;**_b_**&#99;
Selected: "abc"
Markdown: &#97;**_b_**&#99;
<p>a<strong><em>b</em></strong>c</p>
&#97;**`b`**&#99;
Selected: "abc"
Markdown: &#97;**`b`**&#99;
<p>a<strong><code>b</code></strong>c</p>
_a_[b](/repo)
Selected: "ab"
Markdown: _a_&#98;
<p><em>a</em>b</p>
&#32;&#32;&#32;&#32;**hello**
Selected: " hello"
Markdown: &#32;&#32;&#32;&#32;**hello**
<p> <strong>hello</strong></p>
@@ -0,0 +1,14 @@
---
source: tui/src/transcript_view/markdown_copy_tests.rs
assertion_line: 76
expression: "copies[0]"
---
3. first item long enough to wrap
4. second item
- nested `code`
another paragraph
- next nested
5. final item
Outside.
@@ -0,0 +1,19 @@
---
source: tui/src/transcript_view/markdown_copy_tests.rs
expression: "copies.join(\"\\n---\\n\")"
---
<p>[Image #1]</p>
<p> Explain *literal*<br />
next &lt;tag&gt;</p>
<p><strong>Answer</strong></p>
<pre><code>*log* &lt;tag&gt;
</code></pre>
---
<p><strong>Answer</strong></p>
<pre><code>*log* &lt;tag&gt;
</code></pre>
<p>[Image #1]</p>
<p> Explain *literal*<br />
next &lt;tag&gt;</p>
@@ -0,0 +1,40 @@
---
source: tui/src/transcript_view/markdown_copy_tests.rs
assertion_line: 393
expression: "copies.join(\"\\n---\\n\")"
---
Before
> ```
> A B
> ━━━━━ ━━━━━
> one two
> ```
<p>Before</p>
<blockquote>
<pre><code> A B
━━━━━ ━━━━━
one two
</code></pre>
</blockquote>
---
Before
- intro
```
A B
━━━━━ ━━━━━
one two
```
<p>Before</p>
<ul>
<li>
<p>intro</p>
<pre><code> A B
━━━━━ ━━━━━
one two
</code></pre>
</li>
</ul>
@@ -0,0 +1,19 @@
---
source: tui/src/transcript_view/markdown_copy_tests.rs
assertion_line: 379
expression: "copies.join(\"\\n---\\n\")"
---
&#32;&#32;&#32;&#32;suffix
<p> suffix</p>
---
&#9;suffix
<p> suffix</p>
---
&#32;&#32;&#32;&#32;**suffix**
<p> <strong>suffix</strong></p>
---
&#32;&#32;&#32;&#32;suffix
<p> suffix</p>
@@ -0,0 +1,10 @@
---
source: tui/src/transcript_view/markdown_copy_tests.rs
assertion_line: 27
expression: "crate::clipboard_html::render_markdown(text)"
---
<ul>
<li>setting <code>closeRequested_</code>,</li>
<li>removing the tailer from <code>tailers_</code>,</li>
<li>clearing its live/discovery state.</li>
</ul>
@@ -0,0 +1,8 @@
---
source: tui/src/transcript_view/markdown_copy_tests.rs
assertion_line: 26
expression: text
---
- setting `closeRequested_`,
- removing the tailer from `tailers_`,
- clearing its live/discovery state.
@@ -0,0 +1,24 @@
---
source: tui/src/transcript_view/markdown_copy_tests.rs
assertion_line: 358
expression: "format!(\"{copied}\\n{}\", crate::clipboard_html::render_markdown(&copied))"
---
**Before**
```
A B
━━━━━ ━━━━━
one two
```
HTML block:
\<div\>Prose after the table.\</div\>
After
<p><strong>Before</strong></p>
<pre><code> A B
━━━━━ ━━━━━
one two
</code></pre>
<p>HTML block:
&lt;div&gt;Prose after the table.&lt;/div&gt;</p>
<p>After</p>
+23
View File
@@ -179,6 +179,29 @@ impl TextLayout {
}
}
pub(super) fn copy_lines(
&self,
range: Range<usize>,
separator: &str,
output: &mut Vec<crate::markdown_copy::SelectedLine>,
) {
let mut offset = 0;
for (index, line) in self.logical.iter().enumerate() {
let end = offset + line.origin.range.len();
if range.start <= end && range.end >= offset {
let selected = line.origin.range.start + range.start.saturating_sub(offset)
..line.origin.range.start + range.end.min(end) - offset;
crate::markdown_copy::SelectedLine::append(
output,
line.origin.clone(),
selected,
if index == 0 { separator } else { "\n" },
);
}
offset = end + 1;
}
}
pub(super) fn text(&self) -> &str {
&self.text
}
+6 -2
View File
@@ -5,8 +5,12 @@ use crate::clipboard_copy::CopyFormat;
use crate::clipboard_copy::CopyStatus;
impl Tui {
pub(crate) fn copy_transcript_selection(&mut self, text: &str) -> Result<CopyStatus, String> {
pub(crate) fn copy_transcript_selection(
&mut self,
text: &str,
format: CopyFormat,
) -> Result<CopyStatus, String> {
self.clipboard
.copy(text.into(), CopyFormat::PlainText, self.frame_requester())
.copy(text.into(), format, self.frame_requester())
}
}