diff --git a/packages/app/e2e/browser/assistant-selection-copy.spec.ts b/packages/app/e2e/browser/assistant-selection-copy.spec.ts index 4dcbfd47b..bcf90ae23 100644 --- a/packages/app/e2e/browser/assistant-selection-copy.spec.ts +++ b/packages/app/e2e/browser/assistant-selection-copy.spec.ts @@ -36,6 +36,14 @@ const ASSISTANT_MARKDOWN = [ "", "```typescript", 'const answer = "yes";', + " return answer;", + "```", + "", + "After code.", + "", + "```bash", + "echo trailing", + "", "```", "", "````text", @@ -52,10 +60,14 @@ const ASSISTANT_MARKDOWN = [ const EXPECTED_WHOLE_SELECTION_MARKDOWN = ASSISTANT_MARKDOWN.replace( "", "[https://autolink.example.com](https://autolink.example.com)", -).replace( - "3. Outer three\n\n 7. Inner seven\n 8. Inner eight\n 9. Inner nine", - "3. Outer three\n 7. Inner seven\n 8. Inner eight\n 9. Inner nine", -); +) + .replace( + "3. Outer three\n\n 7. Inner seven\n 8. Inner eight\n 9. Inner nine", + "3. Outer three\n 7. Inner seven\n 8. Inner eight\n 9. Inner nine", + ) + // Rendering drops the blank line before a closing fence, so copying cannot bring it + // back. The fence is here to cover a body that ends in more than one newline. + .replace("```bash\necho trailing\n\n```", "```bash\necho trailing\n```"); interface ClipboardContent { html: string; @@ -80,6 +92,19 @@ async function selectAssistantMessage(page: Page): Promise { }); } +async function selectAssistantElement(page: Page, selector: string): Promise { + const assistantMessage = page.getByTestId("assistant-message").filter({ + hasText: "Direct matches:", + }); + await assistantMessage.locator(selector).evaluate((element) => { + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(element); + selection?.removeAllRanges(); + selection?.addRange(range); + }); +} + async function selectAssistantText(page: Page, text: string): Promise { await selectAssistantTextRange(page, text, text); } @@ -134,10 +159,66 @@ async function selectAssistantTextRange( ); } +/** + * Select from `startText` into the middle of the next rendered code line. + * + * Highlighted code splits a line across one text node per syntax token and puts the + * newline in a node of its own, so this is a partial selection that crosses a line + * break — the shape whose whitespace Turndown would collapse. + */ +async function selectAssistantAcrossCodeLines( + page: Page, + startText: string, + endText: string, +): Promise { + const assistantMessage = page.getByTestId("assistant-message").filter({ + hasText: "Direct matches:", + }); + await assistantMessage.evaluate( + (element, selectedRange) => { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let startNode: Node | null = null; + let startOffset = -1; + let textNode = walker.nextNode(); + while (textNode) { + if (!startNode) { + const offset = textNode.textContent?.indexOf(selectedRange.startText) ?? -1; + if (offset >= 0) { + startNode = textNode; + startOffset = offset; + } + textNode = walker.nextNode(); + continue; + } + const endOffset = textNode.textContent?.indexOf(selectedRange.endText) ?? -1; + if (endOffset >= 0) { + const range = document.createRange(); + range.setStart(startNode, startOffset); + range.setEnd(textNode, endOffset + selectedRange.endText.length); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + return; + } + textNode = walker.nextNode(); + } + throw new Error( + `Could not find a code range: ${selectedRange.startText} — ${selectedRange.endText}`, + ); + }, + { startText, endText }, + ); +} + async function copySelection(page: Page): Promise { await page.keyboard.press("ControlOrMeta+c"); } +/** The Copy button writes text/plain only, so the rich reader would throw on it. */ +async function readPlainClipboard(page: Page): Promise { + return page.evaluate(() => navigator.clipboard.readText()); +} + async function readRichClipboard(page: Page): Promise { return page.evaluate(async () => { const items = await navigator.clipboard.read(); @@ -329,6 +410,60 @@ test("copying an assistant selection preserves Markdown structure and links", as expect(columnSliceClipboard.html).not.toContain(""); expect(columnSliceClipboard.html).toContain("ready"); expect(columnSliceClipboard.html).toContain("obsolete"); + + // A partial selection loses its `pre` wrapper, so the code reaches Turndown as + // prose unless it is diverted first: the line break and the indentation go, and + // Markdown-significant characters get escaped. + await selectAssistantAcrossCodeLines(page, "const", "return"); + await copySelection(page); + + const codeLinesClipboard = await readRichClipboard(page); + expect(codeLinesClipboard.plainText).toBe('const answer = "yes";\n return'); + expect(codeLinesClipboard.html).toContain('
');
+
+    // Selecting inside code expresses an intent to copy code, even when the selection
+    // contains every character in the block.
+    await selectAssistantElement(
+      page,
+      '[data-paseo-markdown-language="typescript"] [data-paseo-markdown-tag="code"]',
+    );
+    await copySelection(page);
+
+    const completeCodeClipboard = await readRichClipboard(page);
+    expect(completeCodeClipboard.plainText).toBe('const answer = "yes";\n  return answer;');
+
+    // Crossing the block boundary expresses an intent to carry its Markdown structure.
+    await selectAssistantAcrossCodeLines(page, "const", "After code.");
+    await copySelection(page);
+
+    const crossedCodeClipboard = await readRichClipboard(page);
+    expect(crossedCodeClipboard.plainText).toBe(
+      '```typescript\nconst answer = "yes";\n  return answer;\n```\n\nAfter code.',
+    );
+
+    // Selecting a whole line runs off its end into the newline node. A trailing
+    // newline auto-executes the line when it is pasted into a terminal.
+    await selectAssistantAcrossCodeLines(page, "const", "  ");
+    await copySelection(page);
+
+    expect((await readRichClipboard(page)).plainText).toBe('const answer = "yes";');
+
+    // The block's own Copy button had the same hazard: a Markdown fence body ends in
+    // a newline, and the button copied it raw.
+    const typescriptFence = assistantMessage.locator('[data-paseo-markdown-language="typescript"]');
+    // The button is opacity 0 / pointerEvents none until the fence is hovered.
+    await typescriptFence.hover();
+    await typescriptFence.locator("[data-paseo-markdown-ignore]").click();
+
+    expect(await readPlainClipboard(page)).toBe('const answer = "yes";\n  return answer;');
+
+    // A blank line before the closing fence leaves the body ending in two newlines,
+    // so stripping only the terminal one still hands the terminal an executable line.
+    const bashFence = assistantMessage.locator('[data-paseo-markdown-language="bash"]');
+    await bashFence.hover();
+    await bashFence.locator("[data-paseo-markdown-ignore]").click();
+
+    expect(await readPlainClipboard(page)).toBe("echo trailing");
   } finally {
     await agent.cleanup();
   }
diff --git a/packages/app/src/assistant-selection-copy/content.browser.test.ts b/packages/app/src/assistant-selection-copy/content.browser.test.ts
index ea156fa8e..f46505c2b 100644
--- a/packages/app/src/assistant-selection-copy/content.browser.test.ts
+++ b/packages/app/src/assistant-selection-copy/content.browser.test.ts
@@ -155,17 +155,24 @@ describe("assistant selection copy ranges", () => {
     },
   );
 
-  it.each([
-    { tag: "strong", expected: "**bold text**" },
-    { tag: "code", expected: "`inline code`" },
-  ])("retains $tag delimiters when its complete contents are selected", ({ tag, expected }) => {
+  it("retains strong delimiters when its complete contents are selected", () => {
     const message = mountFixture();
-    const element = fixtureElement(message, `[data-paseo-markdown-tag="${tag}"]`);
+    const element = fixtureElement(message, '[data-paseo-markdown-tag="strong"]');
     const content = createAssistantSelectionClipboardContent(
       selectText(element, 0, textNode(element).length),
     );
-    expect(content?.plainText).toBe(expected);
-    expect(content?.html).toContain(`<${tag}>`);
+    expect(content?.plainText).toBe("**bold text**");
+    expect(content?.html).toContain("");
+  });
+
+  it("copies complete inline code without delimiters when the selection stays inside", () => {
+    const message = mountFixture();
+    const element = fixtureElement(message, '[data-paseo-markdown-tag="code"]');
+    const content = createAssistantSelectionClipboardContent(
+      selectText(element, 0, textNode(element).length),
+    );
+    expect(content?.plainText).toBe("inline code");
+    expect(content?.html).not.toContain("");
   });
 
   it("keeps a complete inline node but drops formatting from a partial node at the other edge", () => {
@@ -228,14 +235,226 @@ describe("assistant selection copy ranges", () => {
     expect(content?.html).not.toContain("
");
   });
 
-  it("retains the fence and language when the complete code block is selected", () => {
+  it("copies code without a fence when every character is selected inside the block", () => {
     const message = mountFixture();
     const blockCode = fixtureElement(
       message,
       '[data-paseo-markdown-tag="pre"] [data-paseo-markdown-tag="code"]',
     );
     expect(copiedMarkdown(selectText(blockCode, 0, textNode(blockCode).length))).toBe(
-      "```ts\nconst answer = true;\n```",
+      "const answer = true;",
+    );
+  });
+});
+
+/**
+ * The fixture above holds a code block in one text node. The real renderer splits a
+ * highlighted line across one text node per syntax token and emits each newline as a
+ * node of its own (`highlighted-code-block.tsx`), and it puts the hover Copy button
+ * inside the `pre`. Line breaks and indentation only survive if the code skips
+ * Turndown, so those cases need a fixture shaped like the real thing.
+ */
+function highlightedFixture(language: string | null): string {
+  const languageAttribute =
+    language === null ? "" : ` data-paseo-markdown-language="${escapeAttribute(language)}"`;
+  return [
+    '
', + `
`, + '', + "const answer = 1;", + "\n", + " if (answer) {", + "\n", + " doThing();", + "", + '
Copy
', + "
", + '
After the block.
', + "
", + ].join(""); +} + +function escapeAttribute(value: string): string { + const holder = document.createElement("div"); + holder.setAttribute("data-value", value); + return holder.outerHTML.slice('
".length); +} + +function mountHighlighted(language: string | null = "typescript"): HTMLElement { + const host = document.createElement("div"); + host.innerHTML = highlightedFixture(language); + document.body.append(host); + const message = host.querySelector('[data-testid="assistant-message"]'); + if (!message) { + throw new Error("Expected assistant message fixture"); + } + return message; +} + +/** The single text node whose content is exactly `text`. */ +function tokenText(root: Node, text: string): Text { + const matches = allTextNodes(root, text); + if (matches.length !== 1) { + throw new Error(`Expected exactly one text node "${text}", found ${matches.length}`); + } + return matches[0]; +} + +function allTextNodes(root: Node, text: string): Text[] { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const matches: Text[] = []; + while (walker.nextNode()) { + if (walker.currentNode.textContent === text) { + matches.push(walker.currentNode as Text); + } + } + return matches; +} + +/** The standalone `\n` node that ends rendered code line `index`. */ +function lineBreak(root: Node, index: number): Text { + const breaks = allTextNodes(root, "\n"); + const node = breaks[index]; + if (!node) { + throw new Error(`Expected a line break at index ${index}, found ${breaks.length}`); + } + return node; +} + +function copyBetween(start: [Text, number], end: [Text, number]) { + const range = document.createRange(); + range.setStart(start[0], start[1]); + range.setEnd(end[0], end[1]); + const selection = window.getSelection(); + if (!selection) { + throw new Error("Expected browser selection"); + } + selection.removeAllRanges(); + selection.addRange(range); + return createAssistantSelectionClipboardContent(selection); +} + +/** Select from the start of one text node to the end of another. */ +function copyAcross(root: Node, startText: string, endText: string) { + return copyBetween([tokenText(root, startText), 0], [tokenText(root, endText), endText.length]); +} + +describe("assistant selection copy inside highlighted code", () => { + it("keeps the line break and the indentation of a partial multi-line selection", () => { + const message = mountHighlighted(); + + const content = copyAcross(message, "const", " {"); + + expect(content?.plainText).toBe("const answer = 1;\n if (answer) {"); + }); + + it("keeps multi-line code as a block in the html half so the lines survive", () => { + const message = mountHighlighted(); + + const content = copyAcross(message, "const", " {"); + + expect(content?.html).toBe( + '
const answer = 1;\n  if (answer) {
', + ); + }); + + it("does not escape Markdown characters inside copied code", () => { + const message = mountHighlighted(); + + const content = copyBetween([tokenText(message, " = 1;"), 1], [tokenText(message, " = 1;"), 5]); + + expect(content?.plainText).toBe("= 1;"); + }); + + it("handles a common ancestor that is an element rather than a text node", () => { + const message = mountHighlighted(); + + // Spanning two token spans makes `commonAncestorContainer` the `code` element. + const content = copyBetween( + [tokenText(message, "const"), 1], + [tokenText(message, " answer"), 4], + ); + + expect(content?.plainText).toBe("onst ans"); + }); + + it("drops a trailing line break swept in from the end of a line", () => { + const message = mountHighlighted(); + + const content = copyBetween([tokenText(message, " = 1;"), 1], [lineBreak(message, 0), 1]); + + expect(content?.plainText).toBe("= 1;"); + }); + + it("drops a trailing line break together with the indentation after it", () => { + const message = mountHighlighted(); + + const content = copyBetween([tokenText(message, "const"), 0], [tokenText(message, " if"), 2]); + + expect(content?.plainText).toBe("const answer = 1;"); + }); + + it("keeps line breaks that are not at the end of the selection", () => { + const message = mountHighlighted(); + + const content = copyBetween([tokenText(message, "const"), 0], [tokenText(message, " if"), 4]); + + expect(content?.plainText).toBe("const answer = 1;\n if"); + }); + + it("leaves the hover copy button out of the copied code", () => { + const message = mountHighlighted(); + + const content = copyAcross(message, " answer", "Copy"); + + expect(content?.plainText).toBe(" answer = 1;\n if (answer) {\n doThing();"); + expect(content?.plainText).not.toContain("Copy"); + }); + + it("does not treat a selection inside the copy button as code", () => { + const message = mountHighlighted(); + + expect(copyAcross(message, "Copy", "Copy")).toBeNull(); + }); + + it("omits the language class when the fence carries no info string", () => { + const message = mountHighlighted(null); + + const content = copyAcross(message, "const", " {"); + + expect(content?.html).toBe( + '
const answer = 1;\n  if (answer) {
', + ); + }); + + it("escapes a fence info string that would break out of the class attribute", () => { + const message = mountHighlighted('ts" onload="x'); + + const content = copyAcross(message, "const", " {"); + + expect(content?.html).toContain('class="language-ts" onload="x"'); + expect(content?.html).not.toContain('onload="x"'); + }); + + it("copies a fully selected multi-line region as code when the selection stays inside", () => { + const message = mountHighlighted(); + const blockCode = fixtureElement( + message, + '[data-paseo-markdown-tag="pre"] [data-paseo-markdown-tag="code"]', + ); + + expect(copiedMarkdown(selectNodeContents(blockCode))).toBe( + "const answer = 1;\n if (answer) {\n doThing();", + ); + }); + + it("retains the fence when a complete code block is selected across its boundary", () => { + const message = mountHighlighted(); + + const content = copyAcross(message, "const", "After the block."); + + expect(content?.plainText).toBe( + "```typescript\nconst answer = 1;\n if (answer) {\n doThing();\n```\n\nAfter the block.", ); }); }); diff --git a/packages/app/src/assistant-selection-copy/content.web.ts b/packages/app/src/assistant-selection-copy/content.web.ts index 28d50e648..bde50cf6c 100644 --- a/packages/app/src/assistant-selection-copy/content.web.ts +++ b/packages/app/src/assistant-selection-copy/content.web.ts @@ -1,6 +1,7 @@ import TurndownService from "turndown"; import { gfm } from "turndown-plugin-gfm"; import { + createCodeClipboardContent, createMarkdownClipboardContent, type MarkdownClipboardContent, } from "@/utils/rich-clipboard"; @@ -11,9 +12,12 @@ import { MARKDOWN_COPY_LIST_START_ATTRIBUTE, MARKDOWN_COPY_TAG_ATTRIBUTE, MARKDOWN_COPY_UNWRAP_ATTRIBUTE, + TRAILING_CODE_LINE_BREAKS, } from "./markup"; const ASSISTANT_MESSAGE_SELECTOR = '[data-testid="assistant-message"]'; +const CODE_BLOCK_SELECTOR = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="pre"]`; +const CODE_REGION_SELECTOR = `${CODE_BLOCK_SELECTOR}, [${MARKDOWN_COPY_TAG_ATTRIBUTE}="code"]`; const turndown = new TurndownService({ bulletListMarker: "-", @@ -65,6 +69,11 @@ export function createAssistantSelectionClipboardContent( return null; } + const partialCode = createPartialCodeContent(range, startMessage); + if (partialCode) { + return partialCode; + } + const container = document.createElement("div"); const selected = cloneMarkdownSelection(range, startMessage); container.append(selected); @@ -78,6 +87,68 @@ export function createAssistantSelectionClipboardContent( return { ...content, html: flattenClipboardListMarkup(content.html) }; } +/** + * Partly-selected code copies as the code itself, never through Turndown. + * + * `removeUnselectedSemantics` strips the `pre`/`code` tags from a partial selection, + * which is right for the Markdown syntax but leaves the code being serialized as + * prose: Turndown collapses the indentation and the line breaks, and escapes + * Markdown-significant characters inside it. Two selected lines arrive as one. + * + * A selection contained inside code always copies as code, even when it contains every + * character. A selection that crosses the code boundary stays on the Markdown path so + * a complete block retains its fence. + */ +function createPartialCodeContent(range: Range, message: Element): MarkdownClipboardContent | null { + const region = closestCodeRegion(range.commonAncestorContainer, message); + if (!region) { + return null; + } + + const code = selectedCodeText(range); + if (!code) { + return null; + } + + // `closest` stops at the inner `code` span, but the fence language lives on the + // outer `pre` wrapper. + const fence = region.closest(CODE_BLOCK_SELECTOR); + + // Only multi-line code earns markup in the html half, and only because HTML would + // otherwise collapse the newlines. A single line stays undecorated, like every + // other partial selection. + const block = Boolean(fence) && code.includes("\n"); + return createCodeClipboardContent(code, { + language: fence?.getAttribute(MARKDOWN_COPY_LANGUAGE_ATTRIBUTE), + block, + }); +} + +function closestCodeRegion(node: Node, message: Element): Element | null { + const element = node instanceof Element ? node : node.parentElement; + const region = element?.closest(CODE_REGION_SELECTOR) ?? null; + if (!region || !message.contains(region)) { + return null; + } + // A selection sitting entirely inside the block's own hover Copy button is not + // code. `cloneContents` of a single text node drops the ignore marker with the + // element that carried it, so this has to be asked of the live DOM. + return element?.closest(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`) ? null : region; +} + +function selectedCodeText(range: Range): string { + const fragment = range.cloneContents(); + // The hover Copy button lives inside the `pre`, so a selection that reaches the + // end of the block sweeps it up too. + for (const ignored of fragment.querySelectorAll(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`)) { + ignored.remove(); + } + // Deliberately untrimmed — leading indentation is part of the code. Trailing line + // breaks are the exception: newlines are their own text nodes between rendered + // lines, so a selection that overshoots the end of a line sweeps one in. + return (fragment.textContent ?? "").replace(TRAILING_CODE_LINE_BREAKS, ""); +} + function flattenClipboardListMarkup(html: string): string { const container = document.createElement("div"); container.innerHTML = html; diff --git a/packages/app/src/assistant-selection-copy/markup.ts b/packages/app/src/assistant-selection-copy/markup.ts index 9191b850a..59a372a53 100644 --- a/packages/app/src/assistant-selection-copy/markup.ts +++ b/packages/app/src/assistant-selection-copy/markup.ts @@ -5,6 +5,16 @@ export const MARKDOWN_COPY_LIST_START_ATTRIBUTE = "data-paseo-markdown-list-star export const MARKDOWN_COPY_LANGUAGE_ATTRIBUTE = "data-paseo-markdown-language"; export const MARKDOWN_COPY_ALIGN_ATTRIBUTE = "data-paseo-markdown-align"; +/** + * Trailing line breaks, with any indentation that followed the last one. + * + * Both ways of copying code strip these, for the same reason: pasting a trailing + * newline into a terminal runs the last line. A fence body always ends in one, and + * ends in several when the author left blank lines before the closing fence; a + * selection picks one up whenever it overshoots the end of a rendered line. + */ +export const TRAILING_CODE_LINE_BREAKS = /(\r?\n[ \t]*)+$/; + export const markdownCopyDataSet = { blockquote: { paseoMarkdownTag: "blockquote" }, br: { paseoMarkdownTag: "br" }, diff --git a/packages/app/src/components/highlighted-code-block.tsx b/packages/app/src/components/highlighted-code-block.tsx index 6506bf0ae..86fc3801a 100644 --- a/packages/app/src/components/highlighted-code-block.tsx +++ b/packages/app/src/components/highlighted-code-block.tsx @@ -14,6 +14,7 @@ import { highlightToKeyedLines, type KeyedLine } from "@/utils/highlight-cache"; import { markdownCopyCodeBlockDataSet, markdownCopyDataSet, + TRAILING_CODE_LINE_BREAKS, } from "@/assistant-selection-copy/markup"; interface HighlightedCodeBlockProps { @@ -82,7 +83,10 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({ const handlePointerEnter = useCallback(() => setIsHovered(true), []); const handlePointerLeave = useCallback(() => setIsHovered(false), []); const controlsVisible = isHovered || isNative || isCompact; - const getCode = useCallback(() => code, [code]); + // Copy the code without its trailing blank lines. A fence body ends in a newline, + // and ends in more than one when the author left a blank line before the closing + // fence; pasting any of them into a terminal runs the last line. + const getCode = useCallback(() => code.replace(TRAILING_CODE_LINE_BREAKS, ""), [code]); return ( { }); }); +describe("createCodeClipboardContent", () => { + it("keeps the code verbatim and escapes it for the html half", () => { + const content = createCodeClipboardContent('if (a < b && c > "d") {', { block: false }); + + expect(content.plainText).toBe('if (a < b && c > "d") {'); + expect(content.html).toContain("if (a < b && c >"); + }); + + it("leaves a single line undecorated but wraps multi-line code in pre", () => { + expect(createCodeClipboardContent("one", { block: false }).html).not.toContain(""); + expect(createCodeClipboardContent("one\ntwo", { block: true }).html).toContain( + "
one\ntwo
", + ); + }); + + it("adds a language class only when the info string carries one", () => { + expect(createCodeClipboardContent("x\ny", { block: true, language: " " }).html).toContain( + "
",
+    );
+    expect(createCodeClipboardContent("x\ny", { block: true, language: "ts" }).html).toContain(
+      '
',
+    );
+  });
+
+  it("escapes an info string that would break out of the class attribute", () => {
+    const content = createCodeClipboardContent("x\ny", {
+      block: true,
+      language: 'ts" onload="alert(1)',
+    });
+
+    expect(content.html).toContain('class="language-ts" onload="alert(1)"');
+    expect(content.html).not.toContain('onload="alert(1)"');
+  });
+});
+
 describe("writeMarkdownToRichClipboard", () => {
   it("writes plain text and html when a rich clipboard writer is available", async () => {
     const clipboard = createRecordingClipboard();
diff --git a/packages/app/src/utils/rich-clipboard.ts b/packages/app/src/utils/rich-clipboard.ts
index 427e443f4..28a86e06a 100644
--- a/packages/app/src/utils/rich-clipboard.ts
+++ b/packages/app/src/utils/rich-clipboard.ts
@@ -26,6 +26,45 @@ export function createMarkdownClipboardContent(markdown: string): MarkdownClipbo
   };
 }
 
+export interface CodeClipboardOptions {
+  language?: string | null;
+  /** Wrap in `pre`/`code` so the line structure survives. */
+  block: boolean;
+}
+
+/**
+ * Clipboard payload for a selection that lies inside rendered code.
+ *
+ * `plainText` is the code exactly as it was selected, so it pastes straight into a
+ * shell or an editor.
+ *
+ * The html half stays undecorated for a single line, matching the rule that a partial
+ * selection does not carry its container's formatting. Multi-line code is the
+ * exception and needs `pre`: HTML collapses newlines, so anything else loses the line
+ * structure in a rich target exactly the way the plain half would.
+ *
+ * The fence info string is agent-authored, so the language is escaped as an attribute
+ * value rather than interpolated raw.
+ */
+export function createCodeClipboardContent(
+  code: string,
+  options: CodeClipboardOptions,
+): MarkdownClipboardContent {
+  const escapedCode = markdownRenderer.utils.escapeHtml(code);
+  if (!options.block) {
+    return { plainText: code, html: `${escapedCode}` };
+  }
+
+  const language = options.language?.trim();
+  const className = language
+    ? ` class="language-${markdownRenderer.utils.escapeHtml(language)}"`
+    : "";
+  return {
+    plainText: code,
+    html: `
${escapedCode}
`, + }; +} + export async function writeMarkdownToRichClipboard( markdown: string, environment: MarkdownClipboardEnvironment,