Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
c21d021
test(cmux-next): rich copy out of the transcript and rich paste into …
teamleaderleo Nov 5, 2026
70323e4
feat(cmux-next): rich copy out of the agent transcript, rich paste in…
teamleaderleo Nov 5, 2026
aa6f50f
gallery(cmux-next composer): a pasted bulleted reply before and after…
teamleaderleo Nov 5, 2026
f7b86bc
fix(cmux-next composer): a pasted list after typed words stays a list
teamleaderleo Nov 5, 2026
f1ff596
fix(cmux-next transcript): a selection inside an equation stays mathC…
teamleaderleo Nov 5, 2026
1e437fb
fix(cmux-next transcript): a rich Copy falls back to the plain copy
teamleaderleo Nov 5, 2026
0785306
fix(cmux-next composer): Paste as Plain Text keeps one window across …
teamleaderleo Nov 5, 2026
f952d19
test(cmux-next composer): name the late Paste as Plain Text wait
teamleaderleo Nov 5, 2026
07bf1e3
fix(cmux-next composer): a pasted message with a picture keeps its im…
teamleaderleo Nov 5, 2026
23abcc7
fix(cmux-next): a picture never drops out of a rich paste or a select…
teamleaderleo Nov 5, 2026
a5ff677
fix(cmux-next transcript): a copy carries only safe link and picture …
teamleaderleo Nov 5, 2026
84f6acb
fix(cmux-next): clear the webview replay's own reds
teamleaderleo Nov 5, 2026
ada8050
test(cmux-next composer): load the Copy writer from the pane server's…
teamleaderleo Nov 5, 2026
0a37de1
fix(cmux-next transcript): code selections copy as the page's; a list…
teamleaderleo Nov 5, 2026
138bee4
fix(cmux-next transcript): copy keeps line breaks, table cells and li…
teamleaderleo Nov 5, 2026
4cb7825
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/rich-c…
teamleaderleo Nov 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion webviews/src/agent-session/acpmux/ComposerBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,10 @@ export function ComposerBox({
useImperativeHandle(
handle,
() => ({
plainPaste: () => plainPaste.current(),
plainPaste: () => {
plainPaste.current();
prompt.current?.plainPaste();
},
place: (chips) => inline.place(chips, "paste"),
sent: (ids) => inline.sent(ids),
}),
Expand Down
86 changes: 86 additions & 0 deletions webviews/src/agent-session/acpmux/MarkdownField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import React, { useCallback, useEffect, useImperativeHandle, useLayoutEffect, us
import { PASTE_FILE_CHARS } from "./pastedText";
import { escapeDefinitions, insertReplyQuote } from "./quoteReply";
import { pasteIsOnlyFileNames } from "./attachments";
import { isPasteAsPlainTextKey, PLAIN_PASTE_MS } from "./composer/pastedChips";
import { htmlHasBlocks, htmlMarkdown, looksLikeMarkdown } from "./richClipboard";
import { COMPOSER_READY_EVENT } from "./composerFocus";
import { liftInlineImages, withoutMarkdownImages } from "./inlineImages";
import { isImeKeyEvent } from "../../pages/shared/imeKeys";
Expand Down Expand Up @@ -86,6 +88,9 @@ export type MarkdownFieldHandle = {
insertTyped(text: string): void;
/// Pastes `text` as the clipboard would: through `onBeforeInput` first.
pasteText(text: string): void;
/// Paste as Plain Text is starting (the prompt's menu; ⌘⇧V the field hears itself): its paste goes
/// in as plain lines, never as the rich blocks a list or heading would make.
plainPaste(): void;
/// Replaces the prompt without reporting it as the user's edit (a new tab adopted, a chat sent).
reset(markdown?: string): void;
/// `markdown` (the field's value) as the agent gets it: the characters the user typed, with
Expand Down Expand Up @@ -328,6 +333,41 @@ function insertPlain(view: EditorView, text: string) {
view.dispatch(tr.scrollIntoView());
}

/** Each newline in `fragment`'s text, outside code, as a line break: a pasted paragraph keeps its
* lines, as a plain paste does (`plainFragment`), where Markdown would join them. */
function withLineBreaks(fragment: Fragment, view: EditorView): Fragment {
const { schema } = view.state;
const hardbreak = schema.nodes.hardbreak;
if (!hardbreak) return fragment;
const nodes: ProseNode[] = [];
fragment.forEach((node) => {
if (node.isText && node.text?.includes("\n"))
node.text.split("\n").forEach((line, index) => {
if (index > 0) nodes.push(hardbreak.create());
if (line) nodes.push(schema.text(line, node.marks));
});
else if (node.isLeaf || node.type.spec.code) nodes.push(node);
else nodes.push(node.copy(withLineBreaks(node.content, view)));
});
return Fragment.from(nodes);
}

/** A paste as Markdown when it has blocks to draw (Leo, 2026-11-05: "we don't even render bullet
* points properly in our thing when we're pasting that stuff in"): HTML with lists, headings,
* quotes or tables (a web page, Notes, Docs, Slack, a reply copied from the transcript) read as
* Markdown, else plain text that is Markdown with a list or fenced code (Claude's and ChatGPT's
* Copy). Anything else is undefined and goes in as its plain lines. */
function richPasteMarkdown(data: DataTransfer): string | undefined {
// A copy with a picture (image Markdown in its text, an <img> in its HTML) goes in as written,
// through the plain path and the image intake: the rich path has no place for the picture and
// would drop it.
const html = data.getData("text/html");
if (/<img\b/i.test(html) || /!\[[^\]\n]*\]\([^)\s]+[^)]*\)/.test(data.getData("text/plain"))) return undefined;
if (html && htmlHasBlocks(html)) return htmlMarkdown(html, { escape: true }) || undefined;
const text = withoutBreakPlaceholders(data.getData("text/plain"));
return looksLikeMarkdown(text) ? text : undefined;
}

/** The copy's text/plain: the lines as the field shows them (a block or line break a newline, an
* empty paragraph a blank line), with no markdown escapes or `<br />` placeholders. */
const copiedText = (slice: Slice) => slice.content.textBetween(0, slice.content.size, "\n");
Expand Down Expand Up @@ -494,6 +534,8 @@ const MarkdownFieldImpl = React.forwardRef<MarkdownFieldHandle, MarkdownFieldPro
const view = useRef<EditorView | undefined>(undefined);
const remark = useRef<RemarkParser | undefined>(undefined);
const literalNow = useRef(literal);
// When Paste as Plain Text last started (⌘⇧V or `plainPaste`): the next paste goes in plain.
const plainPasteAt = useRef(-Infinity);
literalNow.current = literal;
const plainInputNow = useRef(plainInput || literal);
plainInputNow.current = plainInput || literal;
Expand Down Expand Up @@ -706,6 +748,28 @@ const MarkdownFieldImpl = React.forwardRef<MarkdownFieldHandle, MarkdownFieldPro
if (!disposed && field) field.scrollTop = field.scrollHeight;
});
};
// A paste read as Markdown (`richPasteMarkdown`) goes in as the blocks typing it would make; a
// paragraph at either end joins the text around the caret. False when the
// editor is not made yet or the Markdown does not parse: the paste goes in as plain lines.
const insertMarkdown = (editorView: EditorView, markdown: string) => {
const made = editor.current;
if (!made) return false;
let parsed: ProseNode | undefined;
try {
parsed = made.action((ctx) => ctx.get(parserCtx)(markdown));
} catch {
return false;
}
if (!parsed?.content.size) return false;
// Open only at an end that is a paragraph: a pasted list or heading after typed words stays a
// list or heading, never its first item joined to the words.
const content = withLineBreaks(parsed.content, editorView);
const paragraph = editorView.state.schema.nodes.paragraph;
const open = (node: ProseNode | null) => (node?.type === paragraph ? 1 : 0);
const slice = new Slice(content, open(content.firstChild), open(content.lastChild));
editorView.dispatch(editorView.state.tr.replaceSelection(slice).scrollIntoView());
return true;
};
// Keys reach the composer first, as a textarea's keydown would.
const composerKeys = $prose(
() =>
Expand All @@ -721,6 +785,7 @@ const MarkdownFieldImpl = React.forwardRef<MarkdownFieldHandle, MarkdownFieldPro
// was selected. Its own keydown flushes only pending DOM mutations, so read the
// selection here, before the surface's keys and ProseMirror's see it.
if (!event.isComposing && event.keyCode !== 229 && !_view.composing) readDomSelection(_view);
if (!event.isComposing && isPasteAsPlainTextKey(event)) plainPasteAt.current = performance.now();
latest.current.onKeyDown?.(event);
if (event.defaultPrevented) return true;
// Browser editing shortcuts must remain reliable in literal New Tab input.
Expand Down Expand Up @@ -920,6 +985,24 @@ const MarkdownFieldImpl = React.forwardRef<MarkdownFieldHandle, MarkdownFieldPro
// an HTML one as rich nodes (Leo 2026-10-21: "line breaks ... as <br />").
// VS Code's paste is Milkdown's (a code block in its language).
const data = event.clipboardData;
// Paste as Plain Text covers the one paste that follows it.
const plainOnly = performance.now() - plainPasteAt.current < PLAIN_PASTE_MS;
plainPasteAt.current = -Infinity;
const markdown =
data &&
!plainOnly &&
!data.getData("vscode-editor-data") &&
!editorView.state.selection.$from.parent.type.spec.code
? richPasteMarkdown(data)
: undefined;
if (markdown !== undefined) {
// The surface sees the text as copied first (a huge paste is the composer's chip).
if (beforeInput(editorView, data?.getData("text/plain") || markdown, true)) return true;
if (insertMarkdown(editorView, markdown)) {
showPastedEnd(editorView.state);
return true;
}
}
if (data && !data.getData("vscode-editor-data")) {
const html = data.getData("text/html");
const text = data.getData("text/plain");
Expand Down Expand Up @@ -1356,6 +1439,9 @@ const MarkdownFieldImpl = React.forwardRef<MarkdownFieldHandle, MarkdownFieldPro
removeInline: (id, caret) => {
if (view.current) removeInline(view.current, id, caret);
},
plainPaste: () => {
plainPasteAt.current = performance.now();
},
insertCommand: (name) => {
const current = view.current;
const tr = current && commandEdit(current.state, name);
Expand Down
59 changes: 59 additions & 0 deletions webviews/src/agent-session/acpmux/composer.gallery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,32 @@ const fontSizeVariants: Record<string, AgentPaneVariant> = Object.fromEntries(
),
);

/// Pastes `types` on the prompt as the system clipboard hands them over; `plain`, after Paste as
/// Plain Text (⌘⇧V), as the prompt was before rich paste (richClipboard.ts).
async function pasteTypes(ctx: PlayContext, types: Record<string, string>, settled: string, plain = false) {
const view = ctx.document.defaultView!;
if (plain) (ctx.find({ selector: ".acpmux-md-field" }) as MarkdownFieldElement).acpmuxMarkdownField?.plainPaste();
const data = new view.DataTransfer();
for (const [type, value] of Object.entries(types)) data.setData(type, value);
const paste = new view.Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: data });
ctx.find({ selector: ".acpmux-md" }).dispatchEvent(paste);
await ctx.waitFor(() => ctx.document.querySelector(".acpmux-md")?.textContent?.includes(settled));
}

/// A bulleted reply as Claude's and ChatGPT's Copy put it on the clipboard (Leo's screenshots).
const BULLETED_REPLY = [
"Continuing the landing pass:",
"",
"- **#21620** merged; no red of its own",
"- **#21621** typecheck fixed where `vp check` failed",
"- **#21200** waiting on review",
].join("\n");

/// HTML from a web page or Notes with a heading, a list and code.
const HEADING_AND_CODE_HTML =
"<meta charset='utf-8'><h2>Retry plan</h2><ul><li>Retry only on <b>429</b> and <b>503</b></li><li>Back off from 200 ms</li></ul><pre><code class='language-ts'>await retry(send, { attempts: 3 });</code></pre>";

/// One long paragraph, as dictation or a chat message gives it: about 1,100 words on a single line.
const PARAGRAPH = Array.from(
{ length: 40 },
Expand Down Expand Up @@ -1084,6 +1110,39 @@ export default agentPaneEntry({
throw new Error("the inline paste card must say its line count");
},
},
// Leo (2026-11-05): "we don't even render bullet points properly in our thing when we're pasting
// that stuff in". Before: the bulleted reply goes in as literal "- " lines (Paste as Plain Text
// keeps that); after: an ordinary paste draws the same bullets typing "- " makes.
"paste-bulleted-reply-plain": {
note: 'Before, and still Paste as Plain Text (⌘⇧V): a bulleted reply copied from a chat goes in as its literal lines, each starting with "- " and **bold** as typed.',
snapshot: chat(finished),
play: async (ctx) => {
await pasteTypes(ctx, { "text/plain": BULLETED_REPLY }, "#21200", true);
if (ctx.document.querySelector(".acpmux-md li")) throw new Error("Paste as Plain Text must not draw a list");
},
},
"paste-bulleted-reply": {
note: "After: the same paste draws its intro line and three bullets, bold and inline code as the reply had them.",
snapshot: chat(finished),
play: async (ctx) => {
await pasteTypes(ctx, { "text/plain": BULLETED_REPLY }, "#21200");
if (ctx.document.querySelectorAll(".acpmux-md ul > li").length !== 3)
throw new Error("a pasted bulleted reply must draw three bullets");
},
},
"paste-heading-and-code": {
note: "HTML from a web page or Notes with a heading, a list and code: a heading, two bullets and a code block in the prompt.",
snapshot: chat(finished),
play: async (ctx) => {
await pasteTypes(
ctx,
{ "text/html": HEADING_AND_CODE_HTML, "text/plain": "Retry plan\nRetry only on 429 and 503" },
"attempts",
);
if (!ctx.document.querySelector(".acpmux-md h2") || !ctx.document.querySelector(".acpmux-md pre"))
throw new Error("a pasted heading and code block must draw as a heading and a code block");
},
},
// Leo (2026-11-05): "it says pasted text one line, but there's a lot more than this ... you're
// previewing one, two, three, four, five words out of a multi-hundred-word pasted text bit". One
// long dictated paragraph: the card wraps it in the composer's font and says its size.
Expand Down
5 changes: 3 additions & 2 deletions webviews/src/agent-session/acpmux/composer/pastedChips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import type { MadeFile } from "./attachmentIntake";
import { isLargePaste, PASTE_FILE_CHARS, PASTED_TEXT_NAME, pastedTextAttachment, pastedTextFile } from "../pastedText";

/// How long after Paste as Plain Text starts its paste still counts as that one: the menu's goes
/// through the host (`pane.edit`), which runs the web view's own paste.
const PLAIN_PASTE_MS = 2000;
/// through the host (`pane.edit`), which runs the web view's own paste. The prompt field keeps its
/// paste plain for the same window (MarkdownField.tsx `plainPasteAt`).
export const PLAIN_PASTE_MS = 2000;

/// Paste and Match Style's key (⌘⇧V, the app's Edit menu, MainMenu.swift). A system menu key
/// equivalent, not an app action: the bridge sends no keycap for it (shortcuts.ts), and the page
Expand Down
7 changes: 4 additions & 3 deletions webviews/src/agent-session/acpmux/conversation/CardCopy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,18 @@
// is hovered (CardCopy.css). Its highlight is the button's own box, never the card's.
import { createContext, useContext } from "react";
import { useT } from "../i18n";
import { copyTextResult } from "./clipboard";
import { CopyButton } from "./CopyButton";
import { copyMarkdownResult } from "../richClipboard";
import "./CardCopy.css";

/// How the corner draws. `faint`, the default: a 14px glyph at 45% until the card is hovered, a
/// check when copied. The others are gallery versions kept beside it (CardCopy.gallery.tsx):
/// `quiet`, a 12px glyph at 30%; `word`, the default glyph that says "Copied" beside its check.
export type CardCopyLook = "faint" | "quiet" | "word";
export const CardCopyLookContext = createContext<CardCopyLook>("faint");
/// What puts the text on the clipboard: the page's clipboard, or a gallery stage's stand-in.
export const CardCopyWriterContext = createContext<(text: string) => Promise<boolean>>(copyTextResult);
/// What puts the text on the clipboard: the page's clipboard, as a message's Copy does (its Markdown
/// and its HTML, richClipboard.ts), or a gallery stage's stand-in.
export const CardCopyWriterContext = createContext<(text: string) => Promise<boolean>>(copyMarkdownResult);

export function CardCopy({
text,
Expand Down
3 changes: 3 additions & 0 deletions webviews/src/agent-session/acpmux/conversation/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { Reveal } from "./RevealedMarkdown";
import { ArxivMark, GitHubMark, Globe, ImageIcon } from "./icons";
import { FileKindIcon } from "../chips/FileKindIcon";
import { MathDisplay, MathInline } from "./Math";
import { installSelectionCopy } from "./selectionCopy";
import { fenceStep } from "./fenceRule";
import { closesFence, codeSpans, fenceOpening } from "../slashCommands";
import { normalizeMath } from "./mathDelimiters";
Expand Down Expand Up @@ -1323,6 +1324,8 @@ export function Markdown({
skills,
commonmark = false,
}: MarkdownProps) {
// A selection copied out of a message keeps its formatting (selectionCopy.ts); once per page.
installSelectionCopy();
const parser = useRef<IncrementalMarkdown | null>(null);
parser.current ??= new IncrementalMarkdown();
// A sent prompt (`commonmark`) is whole, never streamed: it parses at once, its code read as send
Expand Down
15 changes: 13 additions & 2 deletions webviews/src/agent-session/acpmux/conversation/MessageActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { AlertCircle, Check, DoubleCheck, Pin, Retry, Speaker, Spinner, StopRead
import { canReadAloud, readAloud, useReadingAloud } from "./readAloud";
import { exactTime, nextRelativeTime, nextReplyTime, relativeTime, replyTime } from "./relativeTime";
import { CopyButton } from "./CopyButton";
import { copyMarkdownResult } from "../richClipboard";
import { hasMessageMenuHost, openMessageMenu, openForkMenu } from "./messageMenu";
import { RetryModelMenu } from "./RetryModelMenu";
import { RowReplyButton } from "./SelectionReply";
Expand Down Expand Up @@ -82,10 +83,20 @@ export function MessageDot() {
}

/// Copy through CopyButton (its name as a tooltip, a check once the clipboard took the text), at the
/// row's compact size.
/// row's compact size. The message goes as Claude's and ChatGPT's do: its Markdown as the plain text
/// and its HTML beside it, so Notes, Slack, Docs and Mail paste its headings, bullets and code
/// (richClipboard.ts).
export function MessageCopy({ text }: { text: string }) {
const t = useT();
return <CopyButton text={text} label={t("turn.copy")} className="cv-iconbtn cv-iconbtn--compact" size={ROW_ICON} />;
return (
<CopyButton
text={text}
label={t("turn.copy")}
className="cv-iconbtn cv-iconbtn--compact"
size={ROW_ICON}
copy={copyMarkdownResult}
/>
);
}

/// Use the live host action when the page has one: unlike the turn context's legacy callback, it
Expand Down
Loading
Loading