cmux NEXT agent chat: long prompts collapse to ten lines with Show more; pasted blocks are chips

Merged
TE
15d ago
+1413-9

Problem

Leo (2026-10-21): a long sent prompt draws in full, so a 2,000-line paste becomes a bubble tens of thousands of pixels tall that you have to scroll past every time, including prompts loaded from history. Claude collapses long user messages instead.

Fix

  • Collapse (conversation/PromptText.tsx, promptCollapse.ts): a prompt taller than 10 lines shows its first 10. The last two fade out under a translucent mask-image, so the bubble's glass shows through and nothing covers it. Under them is a Show more / Show less text button: hover pill only, no title, reusing the localized message.more / message.less. The hover row (time, receipt, Copy, Edit, Retry, Fork, Pin) is unchanged, and Copy still copies the full text (row.text). Whether a prompt is long is measured against its line height after layout. Before layout, a text heuristic decides.
  • State survives virtualization and history: open/closed is stored in the transcript disclosure store (transcriptDisclosure.tsx) under prompt:<row id>. Row ids are user-<seq>, so the state holds when a row is scrolled out and back, and on reload. Prompts from history collapse the same way, because the logic lives in the bubble, not the composer.
  • Find: if the current match is in a collapsed prompt (or a closed paste chip), that prompt opens and the highlights repaint, using the same FindReveal path IncomingMessageCard uses.
  • Row estimate (model.ts): user rows are estimated at their collapsed height (10 lines plus the 24px Show more line), so rows don't jump when a long prompt mounts. Chief prompts keep their own bubble.
  • Pasted text chip: if a user_message carries pasted: [{start, end}], each pasted block draws as the composer's attachment chip from #23100 (acpmux-attachment acpmux-attachment-file acpmux-attachment-link) without the ×. The chip shows the paste's first words and its line count ("2,300 lines", a new plural string in all 21 locales). Clicking it opens the paste in place, and opening it also opens the prompt.
  • Gallery (agent-pane.user-bubble): new states long-collapsed, long-expanded, short (unchanged), pasted-chip and pasted-chip-open.

Paste marker: what composer-polish and acpmux must send

I read the code and checked whether a paste marker survives the whole path. It does not reach the transcript today. session/prompt keeps the blocks and their _meta, but the daemon records user_message as plain joined text:

  • cmux-tui/crates/acpmux/src/hub/turns.rs:263 prompt_text(&blocks) (hub/session.rs:197 joins the non-context text blocks with "\n"). Then turns.rs:303 builds {text, client, promptId, turnId}, and append_prompt (hub/prompt_images.rs:82) adds only images. Steers go the same way (hub/steer_end.rs:81-84).
  • History, reload and fetchOlder page the same user_message records (hub/paging.rs), so the loss applies there too.
  • The Claude stdio bridge (claude_stdio/outbound.rs:63-100) drops _meta from each text block. The agent still gets plain text, which is intended. The webview reads only the mux user_message (direct.ts), not the echoed user_message_chunk.

Contract this PR reads:

  1. Composer (composer-polish): send each paste as its own ACP text content block after the typed block, with _meta: {"cmux": {"pasted": true}}.
  2. acpmux (separate Rust PR, needs fleet cargo tests): in Hub::append_prompt, walk the same blocks prompt_text joins (non-context blocks with text, separated by "\n"). Record msg["pasted"] = [{"start": u, "end": u}, ...]: the UTF-16 offsets into msg.text of each block whose _meta.cmux.pasted == true. Leave the key out when there are none. The same key can go on queued (hub/queue.rs:291).
  3. Webview (this PR): direct.ts reads msg.pasted defensively (pasteRanges: integers only, inside the text, sorted, non-overlapping). It applies them only while the row shows the text exactly as recorded. With no marker, a paste just collapses with the rest of the prompt.

Changelog

  • Changed: long sent messages in agent chat collapse to 10 lines with Show more / Show less, and a pasted block shows as a "Pasted text" chip that expands in place.

Testing

Red then green. Both commits ran on a fleet light step (scripts/ci/run-webviews-tests.sh):

  • Red, e1f009e97ee (tests only): test/user-prompt-collapse.test.tsx and src/agent-session/acpmux/rowEstimates.test.ts FAIL (Cannot find module './conversation/promptCollapse'). Step umc-red-e1f009e: 2 files, 2 failed.
  • Latest green, dbd104d805d, step umc-green7-dbd104d80: 23 files, 0 failed (my 5 files plus the 18 transcript and agent-pane files that react-apps-check ran).
  • Green, 6f946de000d (fix): step umc-green3-6f946de00: 5 files, 0 failed. PASS test/user-prompt-collapse.test.tsx (8 tests), rowEstimates.test.ts, and the neighbors sent-image-lifecycle.test.tsx, ChiefPromptBubble.test.tsx, strings-build-output.test.ts. An earlier green run at 4ff08f4b318 caught a real bug: a bubble reused for another prompt kept the old measured length. 6f946de fixes it.

New tests:

  • test/user-prompt-collapse.test.tsx (JSDOM): a 2,000-line prompt opens clamped and faded with Show more; the toggle has no title; the hover row stays. The toggle opens and closes it, and the choice survives an unmount and remount. Copy copies the full 2,000 lines. A short prompt is unchanged. A Find match opens a collapsed prompt. A pasted block is a chip ("error at frame 1", "2,300 lines", no ×) that opens in place, and Copy is still the full prompt. A Find match opens a closed chip. pasteRanges drops malformed input.
  • rowEstimates.test.ts: a 2,000-line prompt is estimated at 16 + 10 lines + 24 + 28 + 8, under 10.5 lines. A short prompt has no Show more line. A 2,300-line paste estimates as its typed line plus one 40px chip.
  • test/agent-pane-long-prompt.test.ts (Playwright, GitHub CI only; fleet workers have no browsers): a 2,000-line prompt from history draws at most 10 lines tall, its mask is a gradient, the next row starts where it ends, Show more opens it (>1,000 lines), Show less closes it again, and a short prompt has no toggle.

Observed / Not run

Observed: the fleet red and green runs above; scripts/web-lint.sh passed (webviews bun run check: Oxfmt, Oxlint, type check, style checks); scripts/cmux-next/prepush-gate.py: no new violations.

Not run: the Playwright tests agent-pane-long-prompt.test.ts and agent-pane-chat-column.test.ts (fleet workers have no browsers, so they run only in GitHub CI; dbd104d805d makes an unclamped prompt's text wrapper display: contents again, so the glass bubble test reads the same 12px top inset as before); gallery captures (the advisory gallery run renders the new states); no macOS app build or real-app dogfood. The clone is sparse (webviews, scripts, schemas, tools, config, Packages/Shared, Packages/macOS, Resources, KeyboardPinningLab, web/data, cmux-tui/crates, root files). Lint needed those paths. No animation was added for opening and closing: the change applies in one frame, so it needs no motion gate.

Agent session: cosmic-gray

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Long sent prompts in agent chat now collapse to their first ten lines with a Show more/Show less toggle, so a 2,000-line paste no longer draws as a bubble tens of thousands of pixels tall. Pasted blocks draw as chips (first words plus line count) that expand in place.

  • The last two collapsed lines fade out under a translucent mask; Copy still copies the full text, and Find opens a collapsed prompt or chip holding the current match, with chip labels never counting as matches.
  • The open/closed state is stored per row id, so it survives virtualization and applies to prompts loaded from history.
  • Row estimates use the collapsed height so nothing below jumps when a long prompt mounts; a bubble reused for another prompt re-measures instead of keeping the old length, and an unclamped prompt's text wrapper is display: contents so the bubble insets its text as before.

Paste marker

The pasted ranges this PR reads come from user_message.pasted, which acpmux does not record yet. composer-polish sends each paste as its own ACP text block with _meta.cmux.pasted, and acpmux needs a follow-up to write the UTF-16 ranges into the recorded text. Until then, pastes collapse with the rest of the prompt.