From 1ba304e66806ba4434768636ae93b5af3c266a22 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 5 Nov 2026 07:24:21 +0000 Subject: [PATCH 1/3] test(composer): a pasted paragraph's chip says its size and wraps; every line break counts (red) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leo, 2026-11-05: "it says pasted text one line, but there's a lot more than this". Red on base: a one-paragraph paste reads "Pasted text · 1 line" with a five-word preview, and \r / U+2028 lines count as one. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-1495d79516d0468d1734 --- .../composer/TextAttachmentCard.test.ts | 43 ++++++++++++++++++- webviews/test/composer-paste-text.test.tsx | 26 ++++++++++- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/webviews/src/agent-session/acpmux/composer/TextAttachmentCard.test.ts b/webviews/src/agent-session/acpmux/composer/TextAttachmentCard.test.ts index 9a81954460f1..08d4059c2689 100644 --- a/webviews/src/agent-session/acpmux/composer/TextAttachmentCard.test.ts +++ b/webviews/src/agent-session/acpmux/composer/TextAttachmentCard.test.ts @@ -1,5 +1,13 @@ import { expect, test } from "bun:test"; -import { TEXT_CARD_LINE_CHARS, TEXT_CARD_LINES, textPreview } from "./TextAttachmentCard"; +import { pasteLines } from "../conversation/promptCollapse"; +import type { Translate } from "../i18n"; +import { + pastedSize, + TEXT_CARD_LINE_CHARS, + TEXT_CARD_LINES, + TEXT_CARD_WRAP_CHARS, + textPreview, +} from "./TextAttachmentCard"; test("the card shows the first lines, from the top", () => { const text = Array.from({ length: 40 }, (_, index) => `line ${index + 1}`).join("\n"); @@ -23,3 +31,36 @@ test("empty text draws no lines", () => { expect(textPreview("")).toBe(""); expect(textPreview("\n \n")).toBe(""); }); + +// Leo, 2026-11-05: "it says pasted text one line, but there's a lot more than this". A paste's lines +// can end in \r\n, a lone \r or Unicode's line and paragraph separators, not only \n. +test("a paste's lines are counted whatever ends them", () => { + expect(pasteLines("one\ntwo\nthree")).toBe(3); + expect(pasteLines("one\r\ntwo\r\nthree\r\n")).toBe(3); + expect(pasteLines("one\rtwo\rthree")).toBe(3); + expect(pasteLines("one
two
three")).toBe(3); + expect(pasteLines("one long paragraph with no break at all")).toBe(1); +}); + +test("the card's lines break wherever the paste's do", () => { + expect(textPreview("first\rsecond
third\r\nfourth")).toBe("first\nsecond\nthird\nfourth"); +}); + +test("a wrapping card keeps enough of one long paragraph to fill its lines", () => { + const paragraph = Array.from({ length: 300 }, (_, index) => `word${index}`).join(" "); + const preview = textPreview(paragraph, TEXT_CARD_LINES, TEXT_CARD_WRAP_CHARS); + expect(preview.length).toBeGreaterThan(TEXT_CARD_LINE_CHARS); + expect(preview.length).toBeLessThanOrEqual(TEXT_CARD_WRAP_CHARS); + expect(preview.split(" ").length).toBeGreaterThan(40); +}); + +test("a paste's size says its lines (when more than one) and its characters", () => { + const t = ((key: string, values?: Record) => + key === "composer.pastedChars" + ? `${values?.count} characters` + : `${values?.count} ${values?.n === 1 ? "line" : "lines"}`) as Translate; + expect(pastedSize(t, "en", "x".repeat(4_200))).toBe("4.2K characters"); + expect(pastedSize(t, "en", Array.from({ length: 42 }, () => "x".repeat(42)).join("\n"))).toBe( + "42 lines · 1.8K characters", + ); +}); diff --git a/webviews/test/composer-paste-text.test.tsx b/webviews/test/composer-paste-text.test.tsx index a5e73605fa86..04f79eba862e 100644 --- a/webviews/test/composer-paste-text.test.tsx +++ b/webviews/test/composer-paste-text.test.tsx @@ -235,17 +235,39 @@ test("a paste over 750 words is a Pasted text chip; the prompt keeps what was ty expect(chip.querySelector(".acpmux-text-card__lines")!.textContent!.split("\n")[0]).toContain( "line 1 of the build log", ); - expect(chip.querySelector(".acpmux-text-card__label")!.textContent).toBe("Pasted text · 130 lines"); + expect(chip.querySelector(".acpmux-text-card__label")!.textContent).toBe("Pasted text · 130 lines · 3.3K characters"); // The strip view draws the same chip in the strip, its first words and its line count. await showAttachments("strip"); const strip = host.querySelector(".acpmux-attachment-pasted")!; expect(strip).not.toBeNull(); expect(strip.textContent).toContain("line 1 of the build log"); expect(strip.textContent).toContain("130 lines"); - expect(strip.querySelector(".acpmux-text-card__label")!.textContent).toBe("Pasted text · 130 lines"); + expect(strip.querySelector(".acpmux-text-card__label")!.textContent).toBe( + "Pasted text · 130 lines · 3.3K characters", + ); expect(words()).toBe("why does this fail?"); }); +// 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 +// paragraph (a dictated message) has one line: its label says its size in characters, never +// "1 line", and its card wraps the paragraph so the preview holds far more than five words. +test("a one-paragraph paste says its size, not 1 line, and its card wraps the paragraph", async () => { + const composer = await openComposer(); + const paragraph = Array.from({ length: 800 }, (_, index) => `word${index}`).join(" "); + await paste(paragraph); + expect(chips(composer).map((chip) => chip.kind)).toEqual(["pasted"]); + const chip = field().querySelector("[data-inline-attachment] .acpmux-md-inline-chip")!; + expect(chip.querySelector(".acpmux-text-card__label")!.textContent).toBe("Pasted text · 6.3K characters"); + const lines = chip.querySelector(".acpmux-text-card__lines")!; + expect(lines.hasAttribute("data-wrap")).toBe(true); + expect(lines.textContent!.split(" ").length).toBeGreaterThan(40); + await showAttachments("strip"); + const strip = host.querySelector(".acpmux-attachment-pasted")!; + expect(strip.querySelector(".acpmux-text-card__label")!.textContent).toBe("Pasted text · 6.3K characters"); + expect(strip.querySelector(".acpmux-text-card__lines")!.hasAttribute("data-wrap")).toBe(true); +}); + test("a paste over 10,000 characters with few words is a chip too", async () => { const composer = await openComposer(); const long = "abcdefghij".repeat(1001); From 4091eb24f46c7a110005386dd3a95f31ff53a389 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 5 Nov 2026 07:24:25 +0000 Subject: [PATCH 2/3] composer: the pasted text chip previews its paste and says its real size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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." The chip counted only \n lines and drew each line unwrapped in monospace, so one long paragraph (a dictated message) read "1 line" with its first five words. Now a paste's card wraps its lines in the composer's font, wider (256 px), and its label gives lines (when more than one) and characters: "Pasted text · 42 lines · 1.8K characters". pasteLines and the card count \r\n, \r, U+2028 and U+2029 breaks too. Click (back into the prompt to edit), × and sending are unchanged. Text file cards keep their monospace lines. bun test test/composer-paste-text.test.tsx src/agent-session/acpmux/composer/TextAttachmentCard.test.ts: 3 fail on base, pass here (64 pass across the paste suites). Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-1495d79516d0468d1734 --- .../acpmux/Localizable.xcstrings | 1 + .../agent-session/acpmux/composer.gallery.ts | 40 ++++++++++++++++++- .../composer/TextAttachmentCard.gallery.tsx | 2 +- .../acpmux/composer/TextAttachmentCard.tsx | 40 +++++++++++++++---- .../acpmux/composer/attachmentIntake.tsx | 8 ++-- .../acpmux/composer/inlineAttachments.tsx | 9 ++--- .../acpmux/composerAttachments.css | 15 ++++++- .../acpmux/conversation/promptCollapse.ts | 15 +++++-- .../agent-session/acpmux/markdownField.css | 5 ++- 9 files changed, 109 insertions(+), 26 deletions(-) diff --git a/webviews/src/agent-session/acpmux/Localizable.xcstrings b/webviews/src/agent-session/acpmux/Localizable.xcstrings index f0cd7d080b17..57c3f924dc60 100644 --- a/webviews/src/agent-session/acpmux/Localizable.xcstrings +++ b/webviews/src/agent-session/acpmux/Localizable.xcstrings @@ -70562,6 +70562,7 @@ } } }, + "composer.pastedChars": {"comment":"Agent pane: on a pasted text chip in the composer, the paste's size in characters, after its line count. {count} is the number as the reader's language writes it, shortened (1.8K)","extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"needs_review","value":"{count} حرف"}},"bs":{"stringUnit":{"state":"needs_review","value":"{count} znakova"}},"da":{"stringUnit":{"state":"needs_review","value":"{count} tegn"}},"de":{"stringUnit":{"state":"needs_review","value":"{count} Zeichen"}},"en":{"stringUnit":{"state":"translated","value":"{count} characters"}},"es":{"stringUnit":{"state":"needs_review","value":"{count} caracteres"}},"fr":{"stringUnit":{"state":"needs_review","value":"{count} caractères"}},"it":{"stringUnit":{"state":"needs_review","value":"{count} caratteri"}},"ja":{"stringUnit":{"state":"needs_review","value":"{count} 文字"}},"km":{"stringUnit":{"state":"needs_review","value":"{count} តួអក្សរ"}},"ko":{"stringUnit":{"state":"needs_review","value":"{count}자"}},"nb":{"stringUnit":{"state":"needs_review","value":"{count} tegn"}},"pl":{"stringUnit":{"state":"needs_review","value":"{count} znaków"}},"pt-BR":{"stringUnit":{"state":"needs_review","value":"{count} caracteres"}},"ru":{"stringUnit":{"state":"needs_review","value":"{count} символов"}},"th":{"stringUnit":{"state":"needs_review","value":"{count} อักขระ"}},"tr":{"stringUnit":{"state":"needs_review","value":"{count} karakter"}},"uk":{"stringUnit":{"state":"needs_review","value":"{count} символів"}},"vi":{"stringUnit":{"state":"needs_review","value":"{count} ký tự"}},"zh-Hans":{"stringUnit":{"state":"needs_review","value":"{count} 个字符"}},"zh-Hant":{"stringUnit":{"state":"needs_review","value":"{count} 個字元"}}}}, "composer.pastedText": { "comment": "Agent pane: the name of a composer chip that holds a long paste (shown with its line count, and in its Remove label)", "extractionState": "manual", diff --git a/webviews/src/agent-session/acpmux/composer.gallery.ts b/webviews/src/agent-session/acpmux/composer.gallery.ts index c4233442ea88..263e98c8ea8a 100644 --- a/webviews/src/agent-session/acpmux/composer.gallery.ts +++ b/webviews/src/agent-session/acpmux/composer.gallery.ts @@ -661,6 +661,26 @@ const fontSizeVariants: Record = Object.fromEntries( ), ); +/// 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 }, + (_, index) => + `Okay, but at the same time the retry helper in step ${index + 1} should back off a little longer each attempt, log why it gave up, and keep the`, +).join(" "); + +/// Pastes PARAGRAPH on the prompt and waits for its card (`card`). +async function pasteParagraph(ctx: PlayContext, card: string) { + const view = ctx.document.defaultView!; + const data = new view.DataTransfer(); + data.setData("text/plain", PARAGRAPH); + 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(card)); + if (/\b1 line\b/.test(ctx.find({ selector: card }).querySelector(".acpmux-text-card__label")?.textContent ?? "")) + throw new Error("a one-paragraph paste must say its size, never 1 line"); +} + export default agentPaneEntry({ id: "agent-pane.composer", title: "Composer", @@ -1018,7 +1038,7 @@ export default agentPaneEntry({ // Leo (2026-10-21), as in Claude: a huge paste is a chip with its first words and line count, // the prompt as typed; a click puts it back in the prompt, × removes it (pastedText.ts). "text-pasted": { - note: "A 130-line paste (780 words, over the 750-word limit): a Pasted text card (its first lines faded, Pasted text · 130 lines) over the typed prompt, × on hover.", + note: "A 130-line paste (780 words, over the 750-word limit): a Pasted text card (its first lines faded, Pasted text · 130 lines and its characters) over the typed prompt, × on hover.", ready: { draft: "Why does the build fail?" }, snapshot: chat(finished), play: async (ctx) => { @@ -1064,6 +1084,24 @@ export default agentPaneEntry({ throw new Error("the inline paste card must say its line count"); }, }, + // 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. + "text-pasted-paragraph": { + note: "One 1,100-word paragraph pasted in the inline view: the card wraps its first lines in the composer's font, faded, over Pasted text · 5.4K characters (never 1 line).", + ready: { draft: "Can you tighten this up? " }, + snapshot: chat(finished), + play: (ctx) => pasteParagraph(ctx, '.acpmux-md-inline-chip[data-preview="text"]'), + }, + "text-pasted-paragraph-strip": { + note: "The same paragraph in the strip view: a wider card, three wrapped lines over its size.", + ready: { draft: "Can you tighten this up?" }, + snapshot: chat(finished), + play: async (ctx) => { + ctx.document.defaultView!.cmuxAcpmuxComposer?.({ attachmentView: "strip" }); + await pasteParagraph(ctx, ".acpmux-attachment-pasted"); + }, + }, // Leo (2026-10-28): "double paste of long block text should reveal it instead of duplicating // the paste", "if you paste that and then paste again, then you don't have to click it. It just // does it." The same block pasted again opens its chip, as a click does (pastedChips.ts). diff --git a/webviews/src/agent-session/acpmux/composer/TextAttachmentCard.gallery.tsx b/webviews/src/agent-session/acpmux/composer/TextAttachmentCard.gallery.tsx index 15f71a72eac2..5af889d5b896 100644 --- a/webviews/src/agent-session/acpmux/composer/TextAttachmentCard.gallery.tsx +++ b/webviews/src/agent-session/acpmux/composer/TextAttachmentCard.gallery.tsx @@ -2,7 +2,7 @@ // The text attachment card (TextAttachmentCard.tsx) in the composer's strip, in the looks considered // for Leo's ask (2026-11-01): "bro this is a TERRIBLE text preview we need to do better for these // text type attachments' previews". `page` ships: the first lines in 11 px monospace from the top, -// faded at the bottom, over "Pasted text · N lines", as Claude's paste tile. The others are drawn +// faded at the bottom, over "Pasted text · N lines · N characters", as Claude's paste tile. The others are drawn // here only, by overriding the shipped card's styles: `page-body` (the lines in the body font), // `header` (the label on top, as ChatGPT's file card names it first) and `tile` (Claude's square // tile, lines and a PASTED-style label). The prompt's inline card is gallery diff --git a/webviews/src/agent-session/acpmux/composer/TextAttachmentCard.tsx b/webviews/src/agent-session/acpmux/composer/TextAttachmentCard.tsx index e02ecb64533c..03f36808ee69 100644 --- a/webviews/src/agent-session/acpmux/composer/TextAttachmentCard.tsx +++ b/webviews/src/agent-session/acpmux/composer/TextAttachmentCard.tsx @@ -4,13 +4,22 @@ // the prompt's inline chip (inlineAttachments.tsx) draw this one body inside their own button. // Leo, 2026-11-01: "bro this is a TERRIBLE text preview we need to do better for these text type // attachments' previews" (the inline chip drew the paste's first words large, centred, underlined -// and clipped). Gallery: agent-pane.text-attachment-card. +// and clipped). A paste's card wraps its lines in the composer's font, so a long paragraph fills the +// card instead of showing its first five words, and its label gives its size in lines and characters +// (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"). +// Gallery: agent-pane.text-attachment-card. import React from "react"; +import { PASTE_LINE_BREAK, pasteLines } from "../conversation/promptCollapse"; +import type { Translate } from "../i18n"; /// The most lines a card can show (the strip's 64 px card shows three, the prompt's 48 px two). export const TEXT_CARD_LINES = 4; /// The most characters of one line the card keeps; the card is narrower than this at 11 px. export const TEXT_CARD_LINE_CHARS = 120; +/// The same for a card that wraps its lines (a paste's): enough of one paragraph to fill every +/// line the card shows, wrapped at its width. +export const TEXT_CARD_WRAP_CHARS = 480; /// The first `lines` lines of `text` as the card draws them: leading blank lines skipped, each line /// cut to `chars` and its trailing space dropped. Reads only as far as it needs, so a paste of a @@ -18,23 +27,38 @@ export const TEXT_CARD_LINE_CHARS = 120; export function textPreview(text: string, lines = TEXT_CARD_LINES, chars = TEXT_CARD_LINE_CHARS): string { const out: string[] = []; let at = 0; + const breaks = new RegExp(PASTE_LINE_BREAK); while (at < text.length && out.length < lines) { - const end = text.indexOf("\n", at); - const stop = end === -1 ? text.length : end; + breaks.lastIndex = at; + const found = breaks.exec(text); + const stop = found ? found.index : text.length; const line = text.slice(at, Math.min(stop, at + chars)).replace(/\s+$/, ""); if (out.length || line.trim()) out.push(line); - at = stop + 1; + at = found ? stop + found[0].length : text.length; } return out.join("\n"); } +/// A paste's size as its label says it: its lines when it has more than one (one long paragraph is +/// "1 line" and says nothing), then its characters, in the reader's language ("42 lines · 1.8K +/// characters"). +export function pastedSize(t: Translate, language: string, text: string): string { + const lines = pasteLines(text); + const chars = t("composer.pastedChars", { + count: new Intl.NumberFormat(language, { notation: "compact", maximumFractionDigits: 1 }).format(text.length), + }); + if (lines < 2) return chars; + return `${t("transcript.pastedLines", { n: lines, count: new Intl.NumberFormat(language).format(lines) })} · ${chars}`; +} + /// The card's body: the preview lines and the label under them. Its host (a button or a span) -/// carries the chip's box, its click and its accessible name. -export function TextAttachmentCard({ text, label }: { text: string; label: string }) { +/// carries the chip's box, its click and its accessible name. `wrap` (a paste's card) wraps each line +/// at the card's width in the composer's font; a file's keeps its lines as they are, in monospace. +export function TextAttachmentCard({ text, label, wrap = false }: { text: string; label: string; wrap?: boolean }) { return ( <> -