From e99eb7de3d8f7c19cac10e58930ebf65ba82344b Mon Sep 17 00:00:00 2001 From: Christoph Leiter Date: Thu, 6 Aug 2026 13:27:38 +0200 Subject: [PATCH] fix(app): keep line breaks and indentation in copied code (#2935) * fix(app): keep line breaks and indentation in copied code #2930 strips the pre/code tags from a partly selected code block, which is right for the Markdown syntax but leaves the code itself going through Turndown as prose. Turndown collapses whitespace, so two selected lines arrive as one, the indentation is gone, and Markdown-significant characters come back escaped. Divert a partial code selection before that: copy the selected text verbatim. Fully selected regions are untouched and still produce a fence, which is what hasSelectedAllContents was already deciding. The html half needs pre for anything multi-line -- HTML collapses newlines too, so a rich paste target would lose the lines the same way. A single line stays undecorated, matching the rule that a partial selection does not carry its container's formatting. Two trailing-newline fixes ride along, since both make a paste run code nobody asked to run. Newlines between rendered lines are their own text nodes, so a selection that overshoots the end of a line carries one; strip it. The block's Copy button was handed the raw fence body, which ends in a newline; hand it the rendered text instead. * fix(app): strip every trailing blank line from copied code The Copy button stripped one trailing newline, so a fence whose author left a blank line before the closing delimiter still put an executable newline on the clipboard. Pasting that into a terminal runs the last line, which is the thing the strip existed to prevent. Both copy paths now share one regex in markup.ts and remove every trailing break, plus any indentation that followed the last one. Reported by Greptile on #2935. Co-Authored-By: Claude Opus 5 * fix(app): infer code copy format from selection boundary --------- Co-authored-by: Claude Opus 5 Co-authored-by: Mohamed Boudra --- .../browser/assistant-selection-copy.spec.ts | 143 ++++++++++- .../content.browser.test.ts | 237 +++++++++++++++++- .../assistant-selection-copy/content.web.ts | 71 ++++++ .../src/assistant-selection-copy/markup.ts | 10 + .../src/components/highlighted-code-block.tsx | 6 +- packages/app/src/utils/rich-clipboard.test.ts | 36 +++ packages/app/src/utils/rich-clipboard.ts | 39 +++ 7 files changed, 528 insertions(+), 14 deletions(-) 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,