Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions webviews/src/agent-session/acpmux/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 39 additions & 1 deletion webviews/src/agent-session/acpmux/composer.gallery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,26 @@ const fontSizeVariants: Record<string, AgentPaneVariant> = 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",
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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<string, string | number>) =>
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",
);
});
40 changes: 32 additions & 8 deletions webviews/src/agent-session/acpmux/composer/TextAttachmentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,37 +4,61 @@
// 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
/// megabyte costs the card no more than a short one.
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 (
<>
<span className="acpmux-text-card__lines" aria-hidden="true" dir="auto">
{textPreview(text)}
<span className="acpmux-text-card__lines" aria-hidden="true" dir="auto" data-wrap={wrap ? "" : undefined}>
{textPreview(text, TEXT_CARD_LINES, wrap ? TEXT_CARD_WRAP_CHARS : TEXT_CARD_LINE_CHARS)}
</span>
<span className="acpmux-text-card__label">{label}</span>
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import { useScrollEdges } from "../summary/scrollEdges";
import { isVideoAttachment, isVideoName, useVideoInfo, useVideoReading } from "./videoAttachments";
import { useAttachedVideoPlayer, VideoAttachmentTile } from "./VideoAttachmentTile";
import { FileAttachment } from "../FileAttachment";
import { TextAttachmentCard } from "./TextAttachmentCard";
import { pastedSize, TextAttachmentCard } from "./TextAttachmentCard";

const ATTACHMENT_LABELS = {
attachments: "composer.attachments",
Expand Down Expand Up @@ -1282,12 +1282,10 @@ export function AttachmentChip({
// A huge paste (pastedText.ts): its first words and line count; a click puts it back in the prompt.
if (pasted) {
const text = attachment.text ?? "";
const lines = pasteLines(text);
const count = new Intl.NumberFormat(language).format(lines);
const expand = onExpand ? () => onExpand(attachment.id) : undefined;
const label = `${name} · ${t(ATTACHMENT_LABELS.pastedLines, { n: lines, count })}`;
const label = `${name} · ${pastedSize(t, language, text)}`;
const accessible = `${attachment.name || pasteName(text)}, ${label}`;
const body = <TextAttachmentCard text={text} label={label} />;
const body = <TextAttachmentCard text={text} label={label} wrap />;
return menu(
expand,
<div className="acpmux-attachment acpmux-attachment-pasted acpmux-text-card">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ import {
} from "../attachments";
import { FileDoc, ImageIcon, PlayTriangle, Spinner } from "../conversation/icons";
import { usePaneLanguage, useT } from "../i18n";
import { pasteLines, pasteName } from "../conversation/promptCollapse";
import { pasteName } from "../conversation/promptCollapse";
import { imageLabel } from "../imageLabel";
import type { MarkdownFieldHandle } from "../MarkdownField";
import { AttachmentChip, RetryButton, UploadProgress, type PendingAttachment } from "./attachmentIntake";
import { keptThumb } from "./imagePool";
import { reordered } from "./stripReorder";
import { TextAttachmentCard } from "./TextAttachmentCard";
import { pastedSize, TextAttachmentCard } from "./TextAttachmentCard";

type Options = {
field: RefObject<MarkdownFieldHandle | null>;
Expand Down Expand Up @@ -354,8 +354,7 @@ const InlineChip = memo(function InlineChip({
// in the prompt at its place.
if (attachment.kind === "pasted") {
const text = attachment.text ?? "";
const lines = pasteLines(text);
const label = `${t("composer.pastedText")} · ${t("transcript.pastedLines", { n: lines, count: new Intl.NumberFormat(language).format(lines) })}`;
const label = `${t("composer.pastedText")} · ${pastedSize(t, language, text)}`;
return (
<span
className="acpmux-md-inline-chip acpmux-text-card"
Expand All @@ -371,7 +370,7 @@ const InlineChip = memo(function InlineChip({
aria-label={`${attachment.name || pasteName(text)}, ${label}`}
onClick={() => onExpand(id)}
>
<TextAttachmentCard text={text} label={label} />
<TextAttachmentCard text={text} label={label} wrap />
</button>
{remove}
</span>
Expand Down
15 changes: 14 additions & 1 deletion webviews/src/agent-session/acpmux/composerAttachments.css
Original file line number Diff line number Diff line change
Expand Up @@ -570,7 +570,7 @@
/* A text attachment (a huge paste, pastedText.ts, or a text file) as a small page of its text, as
Claude and ChatGPT draw a long paste (composer/TextAttachmentCard.tsx): its first lines in 11 px
monospace, left-aligned from the top, fading out over the bottom, with one quiet label under them
("Pasted text · 25 lines"). The strip's card is the thumbnails' 64 px tall; the prompt's inline
("Pasted text · 25 lines · 1.2K characters"). The strip's card is the thumbnails' 64 px tall; the prompt's inline
one (markdownField.css) is 48. A pasted card is a button that puts the text back in the prompt;
× removes it. Leo, 2026-11-01: "bro this is a TERRIBLE text preview we need to do better for
these text type attachments' previews". Gallery: agent-pane.text-attachment-card. */
Expand All @@ -581,6 +581,10 @@
height: var(--acpmux-attach-height);
overflow: hidden;
}
/* A paste's card is wider than a file's: its lines wrap, and its label carries its size. */
.acpmux-attachment-pasted:not([data-text-kind="file"]) {
width: 256px;
}
.acpmux-attachment-pasted-open {
flex: 1;
min-width: 0;
Expand Down Expand Up @@ -629,6 +633,15 @@ button.acpmux-attachment-pasted-open:hover {
-webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 13px), transparent);
mask-image: linear-gradient(to bottom, #000 calc(100% - 13px), transparent);
}
/* A paste's card wraps in the composer's own font (TextAttachmentCard `wrap`): one long paragraph
fills its lines instead of its first five words cut at the edge (Leo, 2026-11-05: "you're
previewing one, two, three, four, five words out of a multi-hundred-word pasted text bit"). A
file's card keeps its lines unwrapped in monospace, as code reads. */
.acpmux-text-card__lines[data-wrap] {
font-family: inherit;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.acpmux-text-card__label {
flex: none;
overflow: hidden;
Expand Down
15 changes: 12 additions & 3 deletions webviews/src/agent-session/acpmux/conversation/promptCollapse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,12 +121,21 @@ export function promptParts(text: string, ranges: readonly PasteRange[]): Prompt
return parts;
}

/// A line break as pasted text can carry it: \n, \r\n, a lone \r (classic Mac text), or Unicode's
/// line and paragraph separators (U+2028, U+2029), which rich-text editors put for a soft return.
/// The prompt field's own paste turns \r into \n; a VS Code paste, a drop and a file keep theirs.
export const PASTE_LINE_BREAK = /\r\n|[\n\r\u2028\u2029]/g;

/// A paste chip's name: the paste's first words, on one line.
export function pasteName(text: string): string {
const line = text.split("\n").find((candidate) => candidate.trim()) ?? "";
const line = text.split(PASTE_LINE_BREAK).find((candidate) => candidate.trim()) ?? "";
const words = line.trim().replace(/\s+/g, " ");
return words.length > 40 ? `${sliceWhole(words, 39).trimEnd()}…` : words;
}

/// A paste's line count (a final line break ends its last line, not a new one).
export const pasteLines = (text: string) => text.replace(/\n$/, "").split("\n").length;
/// A paste's line count (a final line break ends its last line, not a new one). Counts every break
/// PASTE_LINE_BREAK names, so text whose lines end in \r or U+2028 is not "1 line".
export function pasteLines(text: string): number {
const body = text.replace(/(?:\r\n|[\n\r\u2028\u2029])$/, "");
return (body.match(PASTE_LINE_BREAK)?.length ?? 0) + 1;
}
5 changes: 3 additions & 2 deletions webviews/src/agent-session/acpmux/markdownField.css
Original file line number Diff line number Diff line change
Expand Up @@ -406,8 +406,9 @@
label; never the underlined name above. */
.acpmux-md-inline-chip[data-preview="text"] {
justify-content: stretch;
width: 168px;
max-width: 168px;
/* Wide enough for two wrapped lines of the paste and its size ("42 lines · 1.8K characters"). */
width: 256px;
max-width: min(256px, 100%);
height: calc(var(--cmux-control-height, 24px) * 2);
padding: 0;
}
Expand Down
Loading
Loading