feat: show pasted images as [Image #N] and make the chip un-attach them - #762
Merged
Conversation
Follow-up to #761. Two user-reported gaps. 1. A pasted image left no trace in the input box. The composer now shows the reference's chip: > what this image is about? [Image #2] The chip is not decoration. The backend assigns the id, and at submit it DROPS any pending image whose [Image #N] is no longer in the text, so deleting the chip un-attaches the image. That is the reference's rule (handlePromptSubmit.ts:225) and it closes the un-attach gap #761 shipped with: previously the only way to undo an accidental attach was /clear, which destroys the conversation. The chip text stays in the prompt the model sees -- the reference leaves image refs inline and sends the bytes as separate blocks (history.ts:79). _pending_images entries become (image_id, PastedImage, expects_placeholder) behind the existing _queue_image choke point, with a monotonic per-session _image_seq. Session-wide rather than per-prompt on purpose: ids are never reused and the drain is destructive, so a stale chip recalled from history or /resume matches nothing. A per-prompt reset would be worse than the reference -- a recalled [Image #1] would keep a freshly-pasted image #1 alive after its real chip was deleted. 2. Cmd+V did nothing on macOS. A third unwired RPC: clipboard.paste. macOS terminals own Cmd+V and, with an image-only clipboard, deliver an EMPTY bracketed paste -- which lands in the composer's empty-paste branch, calls onClipboardPaste, and hit gatewayClient's "Unhandled RPC (Phase 2)" default. Resolved {}, silently, because that call passes quiet=true. The keybinding was never the missing piece. hermes already binds Cmd+V (textInput.tsx `isMac && isActionMod(k) && inp === 'v'`, with super+v reserved in platform.ts) and clawcodex inherited it, so Cmd+V works as a keypress wherever the terminal reports Cmd, and through the empty-paste route where it does not. opencode reaches the same place from the other direction: ctrl+v plus a re-dispatch on an empty bracketed paste. `placeholder` is a CALL-SITE property, not an RPC property. Whether an [Image #N] chip gets rendered depends on the caller, so the flag has to come from one. Six sites reach these four RPCs and only three render a chip; hardcoding placeholder:true at the RPC layer made the backend drop the images of the other three -- /image, the CLAWCODEX_TUI_IMAGE startup image, and typed-path submit -- at submit time, AFTER their UI had already printed "Attached image". Both of those worked when #761 shipped. Default false is fail-open, so headless -p and the VS Code bridge keep sending their images. Tests pin the cross-layer invariant in both directions, which is what was missing when a green suite hid the above: the RPC forwards true from a chip-rendering caller, defaults to false, and refuses a truthy-ish value; the real clipboard and dropped-path handlers honour the flag and drop on a deleted chip. Live: clipboard image -> [Image #1] -> claude-opus-5 read "**PURPLE-ELEPHANT-42**" (finish_reason end_turn); with the chip deleted the prompt went out as plain text with the queue drained. Python 9007 passed / 3 skipped / 0 failed. ui-tui 1545 passed, same 8 pre-existing baseline failures. Follow-ups, not in this change: an atomic chip (one backspace deletes the whole pill, Cursor.ts:346-370 -- today it takes 10, and a partial "[Image #1" silently un-attaches), and a chip for /image. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Test Results 1 files 1 suites 7m 40s ⏱️ Results for commit 9f87c0f. ♻️ This comment has been updated with latest results. |
ericleepi314
added a commit
that referenced
this pull request
Jul 29, 2026
* fix(deps): cap mcp below 2.0 — 2.0.0 removed mcp.client.websocket
Every PR opened after 2026-07-28T13:45 fails CI with 26 collection errors:
ModuleNotFoundError: No module named 'mcp.client.websocket'
ERROR tests/test_mcp_transport.py
ERROR tests/test_mcp_auth.py
… 24 more
Interrupted: 26 errors during collection
in files the PR never touched. The bound was open-ended (``mcp>=1.27.0``),
so CI resolved mcp 2.0.0 the day it published. 2.0.0 is a restructure that
removed ``mcp.client.websocket``, which ``src/services/mcp/transport.py:30``
imports ``websocket_client`` from.
Timeline:
2026-07-28T08:12 PR #761 CI passes (mcp 1.28.1)
2026-07-28T13:45 mcp 2.0.0 published
2026-07-29T05:06 PR #762 CI fails, 26 errors (mcp 2.0.0)
Verified in a clean venv: 2.0.0 has no ``mcp.client.websocket`` (and no
``experimental``; ``_transport``/``caching``/``subscriptions`` are new),
while ``mcp>=1.27.0,<2`` resolves 1.29.0, which still ships the module and
imports fine against the already-declared ``websockets>=14.0``.
Lifting the cap is a migration, not a bump — the 2.x client internals are
renamed throughout, and the websocket transport has to be either ported or
explicitly dropped. Separate branch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(deps): cap mcp in requirements.txt too — that is what CI installs
The pyproject.toml cap alone did not fix CI, because CI never reads
pyproject.toml:
ci.yml:39 pip install -r requirements.dev.txt
requirements.dev.txt:4 -r requirements.txt
requirements.txt:14 mcp>=1.27.0 <- the bound that actually resolved
pyproject.toml is packaging metadata for the PyPI build; the CI environment
comes from requirements.txt. Both carry their own mcp bound, so both need
the cap or the two disagree — which is exactly how the first attempt at this
fix still resolved 2.0.0 and failed identically.
Verified by installing requirements.txt into a clean venv: resolves mcp
1.29.0 and ``from mcp.client.websocket import websocket_client`` succeeds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
CI installs from requirements.txt, which #763 capped; without this the image-paste PR keeps failing on the unrelated mcp 2.0.0 collection errors.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #761, from two user-reported gaps.
1. A pasted image left no trace in the input box
The composer now shows the reference's chip:
The chip is not decoration. The backend assigns the id, and at submit it drops any pending image whose
[Image #N]is no longer in the text — so deleting the chip un-attaches the image. That's the reference's rule (handlePromptSubmit.ts:225), and it closes the gap #761 shipped with: previously the only way to undo an accidental attach was/clear, which destroys the conversation.The chip text stays in the prompt the model sees — the reference leaves image refs inline and sends the bytes as separate blocks (
history.ts:79)._pending_imagesentries become(image_id, PastedImage, expects_placeholder)behind the existing_queue_imagechoke point, with a monotonic per-session_image_seq. Session-wide rather than per-prompt on purpose: ids are never reused and the drain is destructive, so a stale chip recalled from history or/resumematches nothing. A per-prompt reset would be worse than the reference — a recalled[Image #1]would keep a freshly-pasted image #1 alive after its real chip was deleted.2. Cmd+V did nothing on macOS
A third unwired RPC:
clipboard.paste. macOS terminals own Cmd+V and, with an image-only clipboard, deliver an empty bracketed paste — which lands in the composer's empty-paste branch, callsonClipboardPaste, and hitgatewayClient's "Unhandled RPC (Phase 2)" default. Resolved{}, silently, because that call passesquiet=true.The keybinding was never the missing piece. I checked three references and they disagree:
isMac && isActionMod(k) && inp === 'v'intextInput.tsx;super+vreserved inplatform.tserrorctrl+v; ships a tip "use control+v (not cmd+v!)"ctrl+vonly (though it usessuper+z/super+a), plus a re-dispatch on an empty bracketed pasteclawcodex already inherited hermes' binding, so Cmd+V works as a keypress wherever the terminal reports Cmd, and through the empty-paste route where it doesn't (Apple Terminal). Ctrl+V works everywhere.
placeholderis a call-site property, not an RPC propertyWhether an
[Image #N]chip gets rendered depends on the caller, so the flag has to come from one. Six sites reach these four RPCs and only three render a chip. Hardcodingplaceholder: trueat the RPC layer made the backend drop the images of the other three —/image, theCLAWCODEX_TUI_IMAGEstartup image, and typed-path submit — at submit time, after their UI had already printed "📎 Attached image". Both of those worked when #761 shipped.Default
falseis fail-open, so headless-pand the VS Code bridge keep sending their images. If a new image entry point is added, it must opt in only if it renders a chip.Testing
Tests pin the cross-layer invariant in both directions — that's what was missing when a green suite hid the above:
truefrom a chip-rendering caller, defaults tofalse, and refuses a truthy-ish'yes'._do_clipboard_image/_do_detect_file_drophonour the flag and drop on a deleted chip (TestClipboardRouteHonorsPlaceholder).TestChipIsAuthoritative: kept chip sends, deleted chip drops, partial drop keeps the right image (distinguishable bytes, so it can actually tell), chip text stays inline, queue drained even when everything was un-attached, no-placeholder client unaffected.Live: clipboard image →
[Image #1]→claude-opus-5read**PURPLE-ELEPHANT-42**(finish_reason: end_turn); with the chip deleted the prompt went out as plain text with the queue drained.Python 9007 passed / 3 skipped / 0 failed. ui-tui 1545 passed, same 8 pre-existing baseline failures.
tsc --noEmitclean; 0 lint errors.One flake worth naming: an earlier full run failed
test_sigterm_triggers_drain. It passes in isolation and 5/5 runs of its file, has zero overlap with anything here, and fires SIGTERM 200ms into a subprocess expecting the handler to be installed — load-sensitive. A clean re-run of identical code confirmed it.Follow-ups (not in this change)
Cursor.ts:346-370). Today it takes 10, and a partial[Image #1silently un-attaches./image— it currently prints a notice instead, which also leavessession.ts:162's remainder-replaces-composer behavior unaddressed.🤖 Generated with Claude Code