fix(coding-agent): attach clipboard image on empty bracketed paste

macOS terminals (iTerm2, Terminal.app, Warp, Ghostty without OSC 5522,
…) intercept Cmd+V and read NSPasteboardTypeString first. For an
image-only clipboard (Cmd+Shift+5 screenshot saved to clipboard, Chrome
image copy, …) that read returns empty, so the terminal forwards a
complete-but-empty bracketed paste — '\x1b[200~\x1b[201~' — to the app.
CustomEditor.handleInput inserted that empty payload and the keystroke
disappeared, forcing users back to Ctrl+V (which is never intercepted
and already routes through handleImagePaste).

Add isEmptyBracketedPaste and route a complete, empty-or-whitespace-only
bracketed paste through the same onPasteImage smart reader the
app.clipboard.pasteImage keybind uses, so Cmd+V attaches the clipboard
image (or falls back to the #1628 smart text paste / 'clipboard is
empty' diagnostic) instead of silent nothing. Bracketed pastes carrying
any text (including the explicit image-file path branch from #3506)
keep their existing routing.

Fixes #3601
This commit is contained in:
roboomp
2026-06-26 23:39:45 +00:00
parent 14c8e09e26
commit 7d655fcfdc
3 changed files with 201 additions and 0 deletions
@@ -234,6 +234,22 @@ export function extractBracketedImagePastePath(data: string): string | undefined
return paths?.length === 1 ? paths[0] : undefined;
}
/**
* `true` when `data` is exactly one complete bracketed paste whose payload is
* empty (or whitespace-only). macOS terminals (iTerm2, Warp, Ghostty, …) ask
* the OS pasteboard for `NSPasteboardTypeString` when the user hits `Cmd+V`;
* an image-only clipboard (e.g. `Cmd+Shift+5` screenshot saved to clipboard,
* Chrome image copy) returns `""`, so the terminal still emits the start/end
* markers around an empty payload. Without a fallback the keystroke would
* dead-end silently and force users back to `Ctrl+V`.
*/
export function isEmptyBracketedPaste(data: string): boolean {
if (!data.startsWith(BRACKETED_PASTE_START)) return false;
const endIndex = data.indexOf(BRACKETED_PASTE_END, BRACKETED_PASTE_START.length);
if (endIndex === -1 || endIndex + BRACKETED_PASTE_END.length !== data.length) return false;
return data.slice(BRACKETED_PASTE_START.length, endIndex).trim().length === 0;
}
/**
* Return a single image file path when `text` is exactly one explicit path
* pointing at a supported image extension (`.png`, `.jpg`/`.jpeg`, `.gif`,
@@ -591,6 +607,18 @@ export class CustomEditor extends Editor {
return;
}
// #3601: a `Cmd+V`/`Ctrl+V` on an image-only clipboard makes terminals
// that strip the pasteboard to text first (iTerm2, Warp, Ghostty,
// Terminal.app, Windows Terminal, …) forward an empty bracketed paste
// — the marker pair with no payload. Route it through the same smart
// clipboard reader the `app.clipboard.pasteImage` keybind uses so the
// keystroke attaches the image (or falls back to a text paste / empty
// notice) instead of disappearing.
if (isEmptyBracketedPaste(data) && this.onPasteImage) {
void this.onPasteImage();
return;
}
const parsedKey = parseKey(data);
const canonical = parsedKey !== undefined ? canonicalKeyId(parsedKey) : undefined;