Skip to content

feat: show pasted images as [Image #N] and make the chip un-attach them - #762

Merged
ericleepi314 merged 2 commits into
mainfrom
feat/image-paste-chip
Jul 29, 2026
Merged

feat: show pasted images as [Image #N] and make the chip un-attach them#762
ericleepi314 merged 2 commits into
mainfrom
feat/image-paste-chip

Conversation

@ericleepi314

Copy link
Copy Markdown
Collaborator

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:

> 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'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_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. I checked three references and they disagree:

project Cmd+V bound? how
hermes (clawcodex's ui-tui ancestor) yes isMac && isActionMod(k) && inp === 'v' in textInput.tsx; super+v reserved in platform.ts
openclaude no — reserved, severity error image paste is ctrl+v; ships a tip "use control+v (not cmd+v!)"
opencode no ctrl+v only (though it uses super+z/super+a), plus a re-dispatch on an empty bracketed paste

clawcodex 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.

⚠️ 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. 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:

  • RPC forwards true from a chip-rendering caller, defaults to false, and refuses a truthy-ish 'yes'.
  • The real _do_clipboard_image / _do_detect_file_drop honour 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-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. tsc --noEmit clean; 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)

  • Atomic chip — one backspace should delete the whole pill (Cursor.ts:346-370). Today it takes 10, and a partial [Image #1 silently un-attaches.
  • Chip for /image — it currently prints a notice instead, which also leaves session.ts:162's remainder-replaces-composer behavior unaddressed.

🤖 Generated with Claude Code

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>
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

Test Results

    1 files      1 suites   7m 40s ⏱️
9 010 tests 9 004 ✅ 6 💤 0 ❌
9 256 runs  9 250 ✅ 6 💤 0 ❌

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.
@ericleepi314
ericleepi314 merged commit 11c3bed into main Jul 29, 2026
3 checks passed
@ericleepi314
ericleepi314 deleted the feat/image-paste-chip branch July 29, 2026 05:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant