Skip to content

feat(cli): paste base64 / data URL images, drag image files, with [Image #N] placeholders - #3519

Closed
callmeYe wants to merge 3 commits into
QwenLM:mainfrom
callmeYe:feat/base64-image-paste
Closed

feat(cli): paste base64 / data URL images, drag image files, with [Image #N] placeholders#3519
callmeYe wants to merge 3 commits into
QwenLM:mainfrom
callmeYe:feat/base64-image-paste

Conversation

@callmeYe

@callmeYe callmeYe commented Apr 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds three new ways to attach images to the prompt and unifies them with the existing Cmd+V path under a single [Image #N] placeholder UX.

Closes #3518

What's new

  1. Data URL text paste — paste data:image/<type>;base64,<payload> (devtools "Copy as data URL", chat messages containing embedded images, etc.).
  2. Raw base64 paste — accepted only when the decoded prefix matches a known image magic (PNG / JPEG / GIF / WebP / BMP / TIFF). JWTs, hashes, and base64-looking text without a valid image magic are rejected so normal paste flows aren't hijacked.
  3. Drag an image file into the terminal — works on terminals that wrap the drop in bracketed paste AND on terminals (macOS Terminal.app is the big one) that synthesize the drop as a rapid burst of individual keystrokes, via a simple < 10 ms keystroke-gap heuristic + a 150 ms debounced scan of buffer.text's trailing token.

Unified UX

All four sources — Cmd+V binary clipboard (existing), data URL, raw base64, drag-drop — converge on:

  • Inline [Image #N] placeholder inserted at the cursor.
  • Matching chip in the attachment row.
  • Monotonic counter per input, resets on submit.
  • [Image #N]@<relative path> substitution at submit time so the model sees each image exactly where the user placed it.
> please compare [Image #1] against [Image #2]
Attachments: [Image #1] [Image #2]

Attachments added without a placeholder (legacy code paths) keep rendering as [filename], so other callers are unchanged.

Code

  • clipboardUtils.ts:
    • tryDecodeBase64Image(text) — data URL + raw base64, magic-byte sniff.
    • saveDecodedImage(buf, ext, dir) — persists decoded bytes to the shared clipboard temp dir.
    • detectDraggedImagePath(text) — validates a token is an existing local image file; supports single/double-quoted paths and escaped spaces.
    • Existing clipboardHasImage / saveClipboardImage / cleanupOldClipboardImages unchanged.
  • InputPrompt.tsx:
    • Paste branch order: pasteImage → data URL / raw base64 → drag-drop paste path → large paste placeholder → small paste.
    • Keystroke-burst detection fallback for drag-drops that arrive as typing (see constants DRAG_BURST_MAX_INTERVAL_MS = 10, DRAG_MIN_BURST_CHARS = 4, DRAG_CHECK_DEBOUNCE_MS = 150).
    • [Image #N] allocator + [Image #N]@path substitution in handleSubmitAndClear.

Test plan

  • npm test --workspace packages/cli -- clipboardUtils InputPrompt143 pass, 1 Windows-skip, 0 fail
    • tryDecodeBase64Image: data URL, declared-vs-sniffed MIME (magic wins), raw base64 PNG, raw base64 JPEG, rejects text / JWT-like / empty / short / non-base64 data URL.
    • saveDecodedImage round-trips bytes to disk.
    • detectDraggedImagePath: existing file, quoted path, escaped-space path, non-image extension, missing file, directory, empty input, multi-line.
    • InputPrompt > base64 / data URL paste: single paste, sequential [Image #1]/[Image #2], fall-through to large-paste placeholder for non-images.
    • InputPrompt > drag-and-drop image paste: image path → [Image #1], non-image path → regular buffer handling.
  • npm run build --workspace packages/cli passes.
  • Manual on darwin-arm64, Node 22.17: data URL paste, screenshot Cmd+V, Finder drag onto Terminal.app, Finder drag onto iTerm, non-image drag, empty submit.

Backwards compatibility

  • All public signatures unchanged (clipboardHasImage, saveClipboardImage, cleanupOldClipboardImages).
  • Attachment chip rendering falls back to [filename] when no placeholder is set.
  • No change to multipart message assembly or to @path mentions outside the image paste flow.
  • No change to dependencies.

Notes

  • The burst heuristic thresholds are tuned to only trigger on mechanical input storms; human typing won't fire the detection.
  • The raw-base64 branch is conservative (decoded prefix must match a known image magic) so common base64-looking pastes — auth tokens, hashes, arbitrary data — are not turned into images.
  • Not in scope: the pre-existing @teddyzhu/clipboard binding issues on some platforms (Clipboard image paste (Cmd+V) silently fails on macOS — two root causes #3517). This PR leaves that path untouched.

@LaZzyMan

Copy link
Copy Markdown
Collaborator

@callmeYe Thank you for your feedback and code contribution. However, I must point out that the issue you encountered with cmd+v not being able to paste is not caused by either of the two reasons. To verify this, you can try using Ctrl+v to paste an image. cmd+v is blocked by some command-line applications on macOS; for example, you cannot use cmd+v to paste images in iTerm2, but it works in the VS Code command line. Therefore, we provide Ctrl+v as a backup. Similarly, Ctrl+v is also blocked by most command-line applications on Windows, so we use Alt+v as a backup shortcut for pasting. If you compare, you'll find that claudecode has also implemented the same handling, as this is a common issue encountered by command-line applications.

…age #N] placeholders

Adds three new ways to attach images to the prompt, all converging on a shared
[Image #N] placeholder UX inserted at the cursor:

- Paste a `data:image/...;base64,...` URL as text. Decoded after a
  conservative magic-byte check.
- Paste raw base64. Only accepted when the decoded prefix matches a known
  image magic (PNG/JPEG/GIF/WebP/BMP/TIFF) so JWTs and regular
  base64-looking text aren't hijacked.
- Drag an image file into the terminal. Supported both when the terminal
  wraps the drop in bracketed paste AND when it synthesizes the drop as a
  rapid burst of individual keystrokes (macOS Terminal.app), via a simple
  keystroke-burst heuristic (>= 4 chars with < 10 ms gaps) + a debounced
  scan of the buffer tail.

The existing Cmd+V clipboard path now also renders as [Image #N] for
consistency; attachments added without a placeholder fall back to the
legacy [filename] chip so other callers are unchanged.

At submit time, [Image #N] tokens in the message text are substituted with
@<relative path> so the model sees each image exactly where the user
placed it. Counter resets on submit.

Files
-----
- clipboardUtils.ts: add `tryDecodeBase64Image`, `saveDecodedImage`,
  `detectDraggedImagePath` (path is validated to exist, be a regular file,
  and have a recognized image extension). Accepts single-/double-quoted
  paths and escaped spaces as used by drag-drop.
- InputPrompt.tsx: unified paste branch (pasteImage -> base64 -> drag-drop
  paste -> large paste -> small paste), keystroke-burst detection for
  drag-drops that arrive as typing, [Image #N] allocator, attachment
  placeholder substitution on submit.
- Tests: 143/143 pass (1 Windows skip).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@callmeYe
callmeYe force-pushed the feat/base64-image-paste branch from 0cfd47c to 02e181e Compare April 23, 2026 03:27
@callmeYe callmeYe changed the title feat(cli): unified image paste — Cmd+V, base64 text, drag-drop → [Image #N] feat(cli): paste base64 / data URL images, drag image files, with [Image #N] placeholders Apr 23, 2026
@callmeYe

Copy link
Copy Markdown
Collaborator Author

Hi @LaZzyMan — thanks for the pointer about the Ctrl+V / Alt+V fallbacks, that context is useful and I'll keep it in mind.

Just to clarify the evidence behind #3517 (not this PR, which intentionally leaves the Cmd+V path untouched):

My reproduction wasn't at the terminal layer. I ran the installed @teddyzhu/clipboard@0.0.5 directly from Node on darwin-arm64, with a real PNG screenshot on the pasteboard (confirmed via osascript -e 'clipboard info' listing «class PNGf», 158517, ...):

const m = await import('@teddyzhu/clipboard');
const c = new m.ClipboardManager();
c.hasFormat('image'); // => false
m.getClipboardImage();  // throws: "Failed to get image: no image data"

Since handleClipboardImage short-circuits on !clipboard.hasFormat('image'), both Cmd+V and Ctrl+V bindings route into the same handler and hit the same false result — neither key combo produces an attachment on my machine with the current main code, even though the image is clearly on the pasteboard.

If Ctrl+V works reliably on your setup, I suspect we're on different darwin architectures (mine is arm64, Node 22.17) or different versions of the native addon. Happy to attach the direct-Node reproduction and my env details to #3517 if that would help — or if you'd rather close it as "works as designed with Ctrl+V," I'm fine with that too; it's orthogonal to this PR.

For #3519 specifically: no code change needed from this discussion — the feature paths (base64 data URL paste, raw base64 paste, drag-drop file) don't go through clipboardHasImage / @teddyzhu/clipboard at all, so they work independently regardless of which key binding is used to trigger Cmd+V.

it('accepts escaped spaces (terminal drag-drop style)', async () => {
const imagePath = path.join(tmp, 'a b.png');
await fs.writeFile(imagePath, PNG_MAGIC);
const escaped = imagePath.replace(/ /g, '\\ ');

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Deleting an image attachment only removes it from attachments; it does not remove the corresponding inline [Image #N] token from buffer.text. That leaves a stale placeholder behind, and handleSubmitAndClear() will then send literal text like [Image #1] instead of an image reference after the user deletes the attachment. Please keep attachment deletion and inline placeholders synchronized.

— gpt-5.4 via Qwen Code /review

if (key.pasteImage) {
handleClipboardImage(true);
} else if (decodedImage) {
handleBase64ImagePaste(decodedImage);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] This starts the base64-image materialization asynchronously and returns immediately. If the user pastes an image and hits Enter right away, handleSubmitAndClear() can run before the attachment state and [Image #N] placeholder are inserted, so the submitted prompt silently drops the image.

Please block submit while image materialization is in flight, or await this path before normal submission can proceed.

— gpt-5.4 via Qwen Code /review

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Two key test gaps in the most important new code paths:

  1. [Image #N]@path submit substitution is not tested. No test pastes an image, types text, presses Enter, and asserts onSubmit receives the substituted @path. This is the most complex new logic in handleSubmitAndClear (the IMAGE_PLACEHOLDER_RE replace + refByPlaceholder map + path.relative). A regression would silently send literal [Image #1] to the model.

  2. Drag-burst keystroke detection has zero test coverage. All drag-and-drop tests use bracketed-paste sequences (\x1b[200~...~\x1b[201~), exercising only the key.paste branch. The entire keystroke-burst fallback — burstCharCountRef, dragCheckTimerRef, scanBufferTailForDraggedImage — is untested. This is the feature's fallback for macOS Terminal.app and iTerm without bracketed paste.

Additionally: imagePlaceholderCounter.current = 0 reset on submit (line 366) is not tested — after submitting with [Image #1]/[Image #2], a new paste should produce [Image #1] again.

— qwen3.7-max via Qwen Code /review

if (buffer.length === 0) return null;
const sniffed = detectByMagic(buffer);
const mimeType = sniffed?.mimeType ?? declaredMime;
const ext = sniffed?.ext ?? EXT_BY_MIME[declaredMime] ?? 'bin';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Data URL branch accepts non-image content when magic bytes don't match

When detectByMagic returns null for a data URL, the code falls back to the declared MIME type and may return ext: 'bin'. This is asymmetric with the raw-base64 branch (line 311: if (!sniffed) return null). For example, data:image/png;base64,SGVsbG8gV29ybGQ= (decodes to "Hello World") passes all checks and returns { mimeType: 'image/png', ext: 'png' }.

Suggested change
const ext = sniffed?.ext ?? EXT_BY_MIME[declaredMime] ?? 'bin';
const sniffed = detectByMagic(buffer);
if (!sniffed) return null;
return { buffer, mimeType: sniffed.mimeType, ext: sniffed.ext };

— qwen3.7-max via Qwen Code /review

export function tryDecodeBase64Image(
text: string,
): { buffer: Buffer; mimeType: string; ext: string } | null {
if (!text) return null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] No upper bound on base64 payload size

tryDecodeBase64Image is called synchronously on every paste (InputPrompt.tsx:635) before the large-paste threshold check. A very large base64 string (e.g. from a paste-jacking website) causes unbounded Buffer.from allocation on the main thread.

Suggested change
if (!text) return null;
if (trimmed.length < 32) return null;
if (trimmed.length > 64 * 1024 * 1024) return null;

— qwen3.7-max via Qwen Code /review

);
cleanupOldClipboardImages(Storage.getGlobalTempDir()).catch(() => {});
addImageAttachment(imagePath);
} catch (error) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Silent discard of pasted text on filesystem error

If saveDecodedImage throws (disk full, permission error, temp dir deleted), the catch block only logs. The paste event has already been consumed (key.paste handler returns true), so the user sees neither the image attachment nor the original pasted text — it's silently lost.

Consider falling back to inserting the raw pasted text into the buffer on error:

} catch (error) {
  debugLogger.error('Error handling base64 image paste:', error);
  buffer.insert(rawText, { paste: false }); // pass rawText as a parameter
}

— qwen3.7-max via Qwen Code /review

}
}
if (refByPlaceholder.size > 0) {
finalValue = finalValue.replace(IMAGE_PLACEHOLDER_RE, (match) =>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Orphaned @ref silently dropped when [Image #N] text is deleted from buffer

If the user backspaces over [Image #1] in the input text but doesn't remove the attachment from the attachment bar, the replace() call never matches that placeholder. The @ref is silently dropped — the model never receives the image reference, and no warning is shown.

Consider collecting unconsumed refByPlaceholder entries and falling back to prependRefs:

const consumed = new Set<string>();
finalValue = finalValue.replace(IMAGE_PLACEHOLDER_RE, (match) => {
  if (refByPlaceholder.has(match)) {
    consumed.add(match);
    return refByPlaceholder.get(match)!;
  }
  return match;
});
for (const [placeholder, ref] of refByPlaceholder) {
  if (!consumed.has(placeholder)) prependRefs.push(ref);
}

— qwen3.7-max via Qwen Code /review

dragCheckTimerRef.current = null;
burstCharCountRef.current = 0;
scanBufferTailRef.current?.();
}, DRAG_CHECK_DEBOUNCE_MS);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Drag-burst timer fires without checking shellModeActive

The setTimeout callback doesn't check shellModeActive. If a timer is armed before toggling shell mode (via ! on an empty buffer), it can fire and call scanBufferTailForDraggedImage on a shell command buffer, potentially corrupting the command by stripping a trailing image-path token.

Suggested change
}, DRAG_CHECK_DEBOUNCE_MS);
dragCheckTimerRef.current = setTimeout(() => {
dragCheckTimerRef.current = null;
burstCharCountRef.current = 0;
if (!shellModeActiveRef.current) {
scanBufferTailRef.current?.();
}
}, DRAG_CHECK_DEBOUNCE_MS);

— qwen3.7-max via Qwen Code /review

if (!text) return;
// Match an optional single- or double-quoted path, followed by an optional
// trailing space that some terminals add after a file drop.
const match = text.match(/('([^']+)'|"([^"]+)"|(\S+))\s?$/);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Greedy quoted-path regex in scanBufferTailForDraggedImage

The '([^']+)' alternative is greedy: it matches from the first single quote in the entire buffer to the last single quote. If the user typed text with an earlier single-quoted segment before dragging an image (e.g., it's a photo '/path/to/image.png'), the regex spans across both quotes, producing an invalid token that detectDraggedImagePath rejects — silently missing the drag-drop.

Suggested change
const match = text.match(/('([^']+)'|"([^"]+)"|(\S+))\s?$/);
const match = text.match(/(?:^|\s)(?:'([^']+)'|"([^"]+)"|(\S+))\s?$/);

— qwen3.7-max via Qwen Code /review

setAttachments([]);
setIsAttachmentMode(false);
setSelectedAttachmentIndex(-1);
imagePlaceholderCounter.current = 0;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] imagePlaceholderCounter reset on submit is not tested

After submitting a message with [Image #1] and [Image #2], pasting a new image should produce [Image #1] again (counter resets to 0). No test verifies this behavior. If this line is accidentally removed, the second message would start at [Image #3].

— qwen3.7-max via Qwen Code /review

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One additional finding not covered by the existing inline comments.

The core design (unified [Image #N] placeholders with @path substitution at submit time) is sound, and the test coverage for the new utility functions is thorough. The existing comments from @wenshao cover the most critical issues (race condition on async paste + immediate submit, stale placeholders on attachment deletion, missing size bound, untested submit substitution). This comment adds one more defensive-programming concern.

— qwen-code via Qwen Code /review

await fs.mkdir(tempDir, { recursive: true });
const filePath = path.join(
tempDir,
`clipboard-${Date.now()}.${ext || 'bin'}`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Potential filename collision in saveDecodedImage when two images are pasted within the same millisecond.

Date.now() is used as the sole entropy for the filename. Since handleBase64ImagePaste is called fire-and-forget (not awaited in the paste handler at InputPrompt.tsx:643), two rapid base64 paste events could invoke saveDecodedImage concurrently. If both calls read Date.now() before either await fs.writeFile completes, the second write overwrites the first file — the first attachment then points at the second image's data.

The attachment ID already incorporates imagePlaceholderCounter for uniqueness (${Date.now()}-${counter}). Consider applying the same pattern to the filename, e.g.:

`clipboard-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext || 'bin'}`

Or accept a counter/nonce parameter from the caller.

— qwen-code via Qwen Code /review

@wenshao

wenshao commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator

@callmeYe heads up — this PR currently has merge conflicts with main and can't be merged as-is. Could you merge main in (or rebase) and resolve them when you get a chance?

Conflicting files:

  • packages/cli/src/ui/utils/clipboardUtils.test.ts
  • packages/cli/src/ui/utils/clipboardUtils.ts

The rest merges cleanly. Thanks!

中文

@callmeYe 提个醒 —— 这个 PR 目前和 main 有合并冲突,暂时没法直接合入。方便的时候麻烦把最新的 main merge 进来(或 rebase)解决一下冲突。

冲突文件:

  • packages/cli/src/ui/utils/clipboardUtils.test.ts
  • packages/cli/src/ui/utils/clipboardUtils.ts

其余文件可以自动合并。谢谢!

@wenshao

wenshao commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resovle

}
}
if (refByPlaceholder.size > 0) {
finalValue = finalValue.replace(IMAGE_PLACEHOLDER_RE, (match) =>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Correctness] IMAGE_PLACEHOLDER_RE (with the g flag) replaces all [Image #N] patterns in finalValue at submit time — not just the auto-inserted placeholders. If a user types text that happens to contain [Image #1] (e.g. a Markdown reference link, a discussion about the placeholder format itself, or content copied from documentation) and also pastes an image, their typed [Image #1] is silently replaced with @<path>.

This is distinct from the orphaned-placeholder issue already raised (that one is about the attachment surviving after the placeholder is deleted; this is about unrelated user text being clobbered).

Fix: Make the placeholder token unambiguous so user-typed text can never collide. Two common approaches:

  1. Use a zero-width character sentinel inside the token (e.g. [Image \u200B#1]) — invisible to the user but invisible to naive regex.
  2. Use a private-use Unicode character or an internal-only marker that is extremely unlikely to appear in natural text (e.g. [\uE001Image#1] or [qwen-image:1]).

Either way the key idea is that the substitution regex should only match tokens the component itself inserted.

@wenshao

wenshao commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@wenshao

wenshao commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

2 similar comments
@wenshao

wenshao commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

@wenshao

wenshao commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

Merge origin/main into feat/base64-image-paste, resolving conflicts in:
- clipboardUtils.ts: combined imports (statSync, unescapePath from HEAD;
  fsConstants, execSync/spawn, randomUUID, wrapForMultiplexer from main)
- InputPrompt.tsx: kept IMAGE_PLACEHOLDER_RE (HEAD) alongside
  PASTED_IMAGE_EXTENSIONS + classifyPastedImagePaths (main); merged all
  three paste handler variables and dependencies into if/else chain
- clipboardUtils.test.ts: kept PNG_MAGIC/JPEG_MAGIC constants (HEAD)
  and mock infrastructure (main); adapted saveDecodedImage and
  detectDraggedImagePath tests to use vi.unmock + dynamic imports
  so they work with main's vi.mock('node:fs/promises') overrides
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code resolved the merge conflicts and pushed the branch update.

PR #3519 — Merge Conflict Resolution Summary

Branch: feat/base64-image-pastemain
Commit: ad0404a7e on qwen-resolve/pr-3519

Conflicts Resolved (3 files, 6 conflicts)

1. packages/cli/src/ui/utils/clipboardUtils.ts (1 conflict)

  • Import section: Combined both sides — kept statSync and unescapePath (HEAD), added fsConstants, execSync/spawn, randomUUID, wrapForMultiplexer (main).

2. packages/cli/src/ui/components/InputPrompt.tsx (3 conflicts)

  • Constants: Kept IMAGE_PLACEHOLDER_RE (HEAD) alongside PASTED_IMAGE_EXTENSIONS + classifyPastedImagePaths() (main).
  • Paste handler variables: Kept all three — decodedImage, draggedImagePath (HEAD), and pastedImagePaths (main) — feeding a unified if/else chain.
  • useEffect dependencies: Kept handleBase64ImagePaste, addImageAttachment (HEAD) and promotePastedImagePaths (main).

3. packages/cli/src/ui/utils/clipboardUtils.test.ts (2 conflicts + test adaptation)

  • Imports: Kept os, path (HEAD) and EventEmitter (main); dropped import * as fs from 'node:fs/promises' (top-level) since fs-dependent tests now use dynamic imports.
  • Constants + mock infrastructure: Kept PNG_MAGIC/JPEG_MAGIC (HEAD) and all of main's mock setup (vi.mock('node:child_process'), vi.mock('node:fs/promises'), createMockChild, createMockStdout, setupX11Env, originalPlatform).
  • Test adaptation: Rewrote saveDecodedImage and detectDraggedImagePath test blocks to use vi.unmock('node:fs/promises') + vi.resetModules() + dynamic imports, because main's vi.mock('node:fs/promises') overrides mkdir/writeFile/readFile to return stubs, breaking HEAD's tests that need real filesystem operations. tryDecodeBase64Image tests are pure and needed no changes.

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code did not run conflict resolution for this request.

PR #3519 does not currently have merge conflicts with main.

import * as os from 'node:os';
import * as path from 'node:path';
import { EventEmitter } from 'node:events';
import {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] [linter] ESLint: saveDecodedImage and detectDraggedImagePath are only used as types in this test file but imported as values. Use type imports:

Suggested change
import {
import type {
saveDecodedImage,
tryDecodeBase64Image,
detectDraggedImagePath,
} from './clipboardUtils.js';
import {
clipboardHasImage,
saveClipboardImage,
cleanupOldClipboardImages,
} from './clipboardUtils.js';

— qwen3.7-max via Qwen Code /review

expect(decoded!.mimeType).toBe('image/jpeg');
expect(decoded!.ext).toBe('jpg');
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] [test] 4 test failures from mock leakage. The afterEach in earlier describe blocks installs vi.mock('node:fs/promises', ...) that bleeds into these saveDecodedImage and detectDraggedImagePath test blocks despite vi.unmock + vi.resetModules() in beforeEach. The production code operates on mocked fs functions that silently no-op, so readFile returns undefined and statSync can't see the real file.

Fix: restructure the mock lifecycle — either move these test blocks above the blocks that install the fs mock, or use vi.doUnmock before vi.resetModules() and re-import the module in each test.

— qwen3.7-max via Qwen Code /review

await fs.mkdir(tempDir, { recursive: true });
const filePath = path.join(
tempDir,
`clipboard-${Date.now()}.${ext || 'bin'}`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] TOCTOU / symlink attack: saveDecodedImage generates predictable filenames (clipboard-${Date.now()}.${ext}) with no randomUUID() and no O_EXCL. The same file already demonstrates the correct pattern — saveClipboardImage (line 532) uses clipboard-${timestamp}-${randomUUID()}.png, and saveFromCommand opens with O_WRONLY | O_CREAT | O_EXCL.

On shared systems, an attacker can pre-create a symlink at the predictable path and the next paste overwrites an arbitrary target file with attacker-controlled binary content.

Suggested change
`clipboard-${Date.now()}.${ext || 'bin'}`,
const filePath = path.join(
tempDir,
`clipboard-${Date.now()}-${randomUUID()}.${ext || 'bin'}`,
);
const fd = await fs.open(filePath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL);
await fd.writeFile(buffer);
await fd.close();

— qwen3.7-max via Qwen Code /review

return null;
}
return candidate;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] detectDraggedImagePath doesn't enforce absolute paths. A relative path like image.png passes statSync (resolved against CWD), is returned as-is, and handleSubmitAndClear's path.relative(config.getTargetDir(), att.path) with a relative second argument produces a broken @ref — the model receives an incorrect path and the user gets no error.

Suggested change
}
if (!path.isAbsolute(candidate)) return null;
const stats = statSync(candidate);

— qwen3.7-max via Qwen Code /review

} else {
burstCharCountRef.current = 1;
}
if (burstCharCountRef.current >= DRAG_MIN_BURST_CHARS) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] DRAG_MIN_BURST_CHARS = 4 is too low — terminal multiplexers (tmux, mosh), CJK input methods, and SSH connections with buffered delivery routinely produce 4+ character bursts within 10ms. When the heuristic fires, scanBufferTailForDraggedImage silently strips the trailing token from the buffer and replaces it with an [Image #N] placeholder.

Consider raising DRAG_MIN_BURST_CHARS to 15+ (real file paths are typically 20+ chars), or requiring the burst to constitute the entire trailing token.

— qwen3.7-max via Qwen Code /review


const IMAGE_PLACEHOLDER_RE = /\[Image #(\d+)\]/g;

const PASTED_IMAGE_EXTENSIONS = /\.(png|jpe?g|gif|webp|bmp)$/i;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Extension list inconsistency: PASTED_IMAGE_EXTENSIONS here covers png|jpe?g|gif|webp|bmp, while IMAGE_EXTENSIONS in clipboardUtils.ts (line 721) also includes tif, tiff, heic, heif. A .heic file dragged onto the terminal is recognized by detectDraggedImagePath, but the same path pasted via bracketed paste goes through classifyPastedImagePaths and is NOT recognized.

Export IMAGE_EXTENSIONS from clipboardUtils.ts and derive PASTED_IMAGE_EXTENSIONS from it to keep a single source of truth.

— qwen3.7-max via Qwen Code /review

check: (b) => b.length >= 2 && b[0] === 0x42 && b[1] === 0x4d,
},
{
ext: 'tiff',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] BMP magic is only 2 bytes (0x42 0x4d = ASCII "BM"). Any raw base64 string starting with Qk0 decodes to those bytes and gets misidentified as a BMP image, silently consuming the paste. Consider requiring b.length >= 14 (minimum BMP file header size) to reduce false positives.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR @callmeYe!

Template: substantive content provided ✓ (uses different headings than the template but covers the intent — Summary, Test Plan, Backwards Compatibility, Notes all present).

Problem: real UX gap. Users currently have only one way to attach images (Cmd+V binary clipboard), which doesn't cover data URLs from devtools, raw base64 from chat messages, or drag-drop from Finder. Closes #3518 — this is an observed need, not theoretical hardening.

Direction: aligned. Expanding image input methods is a natural CLI UX improvement — Claude Code has similar capabilities. The [Image #N] inline placeholder with @path substitution at submit time is a clean UX model.

Size: not applicable — no core module paths touched. All 4 files are in packages/cli/src/ui/, with 438 production lines and 363 test lines.

Approach: the three new paths (data URL, raw base64, drag-drop) converging into a unified [Image #N] placeholder feels right for the stated goal. The conservative magic-byte sniffing for raw base64 (to avoid hijacking JWTs/hashes) is a good design choice. The drag-burst heuristic (< 10 ms keystroke gap) is clever but inherently fragile — worth flagging for long-term maintenance risk, not a blocker. One question: the classifyPastedImagePaths path (pre-existing, for terminals that inject @<path> on Cmd+V) now sits 4th in the paste priority chain — is there a scenario where a terminal injects a plain image path via bracketed paste that would now get caught by detectDraggedImagePath (3rd) instead? If so, the behavior is the same (image gets attached), so probably fine.

Moving on to code review. 🔍

中文说明

感谢 @callmeYe 的贡献!

模板:已包含实质内容 ✓(标题与模板不同,但覆盖了模板意图——Summary、Test Plan、Backwards Compatibility、Notes 均有)。

问题:真实的 UX 缺口。用户目前只能通过 Cmd+V 二进制剪贴板附加图片,无法处理 devtools 的 data URL、聊天消息中的 base64、或从 Finder 拖入的文件。关联 #3518——这是已观测到的需求,不是理论性加固。

方向:对齐。扩展图片输入方式是 CLI UX 的自然改进——Claude Code 也有类似能力。[Image #N] 内联占位符 + 提交时替换为 @path 的设计简洁合理。

规模:不适用——未触及核心模块路径。4 个文件均在 packages/cli/src/ui/ 下,生产代码 438 行,测试代码 363 行。

方案:三种新路径(data URL、raw base64、拖放)统一收敛到 [Image #N] 占位符,与目标匹配。对 raw base64 使用保守的 magic-byte 嗅探(避免劫持 JWT/哈希)是好设计。拖放爆发检测(< 10 ms 按键间隔)很聪明但本质上脆弱——标记为长期维护风险,不阻塞。一个疑问:classifyPastedImagePaths 路径(已有的,处理终端在 Cmd+V 时注入的 @<path>)现在在 paste 优先级链中排第 4——是否存在终端通过 bracketed paste 注入纯图片路径、被第 3 项 detectDraggedImagePath 先截获的场景?如果有,行为相同(图片被附加),所以应该没问题。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

The implementation is well-structured — clipboardUtils.ts gains three focused helpers (tryDecodeBase64Image, saveDecodedImage, detectDraggedImagePath) and InputPrompt.tsx wires them into the paste chain with a clean priority order. The [Image #N]@path substitution at submit time is straightforward. No critical correctness bugs or security holes in the production code.

Two issues found:

1. Build failure — unused import (blocker). clipboardUtils.test.ts line 17 imports cleanupOldClipboardImages but never uses it. TypeScript strict mode (noUnusedLocals) rejects this:

src/ui/utils/clipboardUtils.test.ts(17,3): error TS6133: 'cleanupOldClipboardImages' is declared but its value is never read.

npm run build --workspace packages/cli fails. Fix: remove the unused import.

2. Four test failures in vitest 3.2.4. The saveDecodedImage and detectDraggedImagePath test suites use a vi.unmock('node:fs/promises') + vi.resetModules() + dynamic import() pattern to get real filesystem access despite the file-level vi.mock('node:fs/promises'). This pattern doesn't fully restore the real module in vitest 3.2.4 (the project's version) — readFile returns the mock stub (undefined .equals), and statSync in detectDraggedImagePath appears to hit stale module state, returning null for real files.

FAIL  saveDecodedImage > writes a buffer to <targetDir>/clipboard/clipboard-<ts>.<ext>
  TypeError: Cannot read properties of undefined (reading 'equals')

FAIL  detectDraggedImagePath > returns the path for an existing image file
  expected null to be '/tmp/qwen-drag-E6mZX9/hello.png'

FAIL  detectDraggedImagePath > strips single quotes that terminals add around paths with spaces
  expected null to be '/tmp/qwen-drag-P8d3wZ/a b.png'

FAIL  detectDraggedImagePath > accepts escaped spaces (terminal drag-drop style)
  expected null to be '/tmp/qwen-drag-2ARTEs/a b.png'

These 4 tests pass in the author's environment (claimed 143/0) but fail here. The vi.unmock + vi.resetModules approach is fragile across vitest versions. Suggestion: move the fs-dependent tests to a separate describe block that uses vi.mock with importOriginal (the standard vitest pattern for partial mocking), or split them into a separate test file that doesn't mock node:fs/promises at all.

No other issues in production code. The addImageAttachmentRef forward-reference pattern is a bit awkward but well-documented. The statSync call in detectDraggedImagePath is synchronous but runs outside render and checks a single file — acceptable. The drag-burst timer cleanup on unmount is handled correctly.

Real-Scenario Testing

npm run dev cannot start because the build fails (finding #1 above). The npm run build error blocks tmux interactive testing:

$ npm run dev -- -p 'say hello world' 2>&1 | tee /tmp/triage-test/basic2.log

> @qwen-code/qwen-code@0.19.6 dev
> node scripts/dev.js -p say hello world

An unexpected critical error occurred:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../acp-bridge/dist/eventBus.js'

(This is because npm run build failed earlier due to the TS6133 error, so dist artifacts weren't generated.)

Unit test results from the PR's changed files:

$ npx vitest run clipboardUtils.test.ts InputPrompt.test.tsx

Test Files  1 failed | 1 passed (2)
     Tests  4 failed | 378 passed (382)
  • InputPrompt.test.tsx: 189/189 pass ✓ (includes all new base64/drag-drop tests)
  • clipboardUtils.test.ts: 40/44 pass, 4 fail (fs-dependent tests described above)

Once the build error and test failures are fixed, I'd like to re-run tmux interactive testing to confirm the feature works end-to-end.

中文说明

代码审查

实现结构良好——clipboardUtils.ts 新增三个聚焦的辅助函数,InputPrompt.tsx 以清晰的优先级顺序将它们接入 paste 链。[Image #N]@path 提交时替换逻辑简洁。生产代码中未发现关键正确性 bug 或安全漏洞。

发现两个问题:

1. 构建失败——未使用的导入(阻塞)。 clipboardUtils.test.ts 第 17 行导入了 cleanupOldClipboardImages 但从未使用。TypeScript 严格模式 (noUnusedLocals) 拒绝此操作,npm run build --workspace packages/cli 失败。修复:删除未使用的导入。

2. vitest 3.2.4 中 4 个测试失败。 saveDecodedImagedetectDraggedImagePath 测试套件使用 vi.unmock + vi.resetModules + 动态 import() 模式来获取真实文件系统访问,但在项目使用的 vitest 3.2.4 版本中,此模式无法完全恢复真实模块。这些测试在作者环境中通过(声称 143/0),但在此处失败。建议:将依赖文件系统的测试移至使用 importOriginal 的单独 describe 块,或拆分到不 mock node:fs/promises 的单独测试文件。

真实场景测试

由于构建失败,npm run dev 无法启动,tmux 交互测试被阻塞。

单元测试结果:

  • InputPrompt.test.tsx:189/189 通过 ✓
  • clipboardUtils.test.ts:40/44 通过,4 个失败(上述依赖文件系统的测试)

修复构建错误和测试失败后,需要重新运行 tmux 交互测试以确认功能端到端正常工作。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Stepping back: this is a well-designed feature PR that solves a real problem. The [Image #N] placeholder UX is clean, the paste priority chain is logical, and the conservative magic-byte sniffing for raw base64 shows good judgment. The production code in both clipboardUtils.ts and InputPrompt.tsx reads well — nothing I'd want to rewrite.

The two blocking issues are both in the test file and are straightforward fixes:

  1. Unused import (cleanupOldClipboardImages in clipboardUtils.test.ts:17) — one-line removal, fixes the build.
  2. vi.unmock/vi.resetModules test pattern — the four fs-dependent tests need a different isolation strategy for vitest 3.2.4. Moving them to a separate test file (without the file-level vi.mock('node:fs/promises')) would be the cleanest fix.

Neither issue reflects a design problem — they're test-infrastructure bugs that the author likely didn't hit in their local vitest version.

I can't approve while the build is broken, but once these two are addressed, this is ready to ship. Not requesting tmux re-test until the build passes.

中文说明

总体来看:这是一个设计良好的功能 PR,解决了真实问题。[Image #N] 占位符 UX 简洁,paste 优先级链逻辑清晰,对 raw base64 的保守 magic-byte 嗅探体现了良好的判断力。clipboardUtils.tsInputPrompt.tsx 中的生产代码可读性好——没有需要重写的部分。

两个阻塞问题都在测试文件中,修复很简单:

  1. 未使用的导入clipboardUtils.test.ts:17cleanupOldClipboardImages)——删除一行即可修复构建。
  2. vi.unmock/vi.resetModules 测试模式——四个依赖文件系统的测试需要针对 vitest 3.2.4 采用不同的隔离策略。将它们移到单独的测试文件(不使用文件级 vi.mock('node:fs/promises'))是最干净的修复方案。

这两个问题都不反映设计缺陷——它们是测试基础设施 bug,作者可能在本地 vitest 版本中没有遇到。

构建失败时无法批准,但修复后即可合入。构建通过后再进行 tmux 交互测试。

Qwen Code · qwen3.7-max

import * as path from 'node:path';
import { EventEmitter } from 'node:events';
import {
clipboardHasImage,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] [build] Unused imports cause TS6133 build error

clipboardHasImage, saveClipboardImage, and cleanupOldClipboardImages are imported here as values but only used inside dynamically-imported test modules (lines 178-180). These top-level imports are never referenced in the new test blocks (tryDecodeBase64Image, saveDecodedImage, detectDraggedImagePath) that use the real functions via await import().

TypeScript strict mode (noUnusedLocals) flags these as TS6133 errors, breaking the CLI package build.

Remove clipboardHasImage, saveClipboardImage, and cleanupOldClipboardImages from this import block, keeping only the functions actually used by the new tests:

Suggested change
clipboardHasImage,
import {
saveDecodedImage,
tryDecodeBase64Image,
detectDraggedImagePath,
} from './clipboardUtils.js';

— qwen3.7-max via Qwen Code /review

let saveDecodedImageReal: typeof saveDecodedImage;

beforeEach(async () => {
vi.unmock('node:fs/promises');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] [test] 4 test failures from vi.mock/vi.unmock leakage

The vi.unmock('node:fs/promises') + vi.resetModules() in this beforeEach does not fully restore the real node:fs/promises module. The top-level vi.mock('node:fs/promises', ...) factory (installed for earlier test blocks) persists in vitest's mock registry, so writeFile, readFile, and stat remain mocked no-ops.

This causes:

  • saveDecodedImage test: readFile returns undefined instead of the written buffer
  • 3 detectDraggedImagePath tests: writeFile is a no-op (files never created on disk), so statSync throws ENOENT

Fix: use vi.doMock/vi.doUnmock instead of vi.mock/vi.unmock, or restructure these test blocks to avoid depending on real filesystem operations while the module-level mock is active.

— qwen3.7-max via Qwen Code /review

// Used to catch drag-drop on terminals that don't use bracketed paste:
// the path arrives as a burst of individual keystrokes, and by the time this
// runs the path is already sitting at the end of buffer.text.
const scanBufferTailForDraggedImage = useCallback(() => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] \S+ branch cannot match paths with backslash-escaped spaces (Linux drag-drop)

The regex /('([^']+)'|"([^"]+)"|(\S+))\s?$/ uses \S+ for the unquoted branch. On Linux terminals (GNOME Terminal, Konsole, xfce4-terminal), drag-dropped paths with spaces are encoded with backslash-escaped spaces, e.g. /home/user/my\ photo.png. The literal space in the buffer causes \S+ to match only photo.png, truncating the path. detectDraggedImagePath('photo.png') then fails (relative path, not found), and the drag-drop is silently dropped.

The downstream unescapePath call inside detectDraggedImagePath is designed to handle backslash escapes, but it never receives the full path because this regex already truncated it.

Suggested fix — replace \S+ with a pattern that also consumes backslash-escaped characters:

Suggested change
const scanBufferTailForDraggedImage = useCallback(() => {
const match = text.match(/('([^']+)'|"([^"]+)"|((?:\\.|[^\s])+))\s?$/);

— qwen3.7-max via Qwen Code /review

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Suggestions — commit 6eb36453b74a1e29a5e71979647400cbb935d3c3

File Issue Suggested fix
InputPrompt.tsx:1053-1097 Drag-burst detection heuristic (~40 lines) has zero test coverage — no tests exercise burst accumulation, timer callback, or false-positive guard for human typing Add integration tests simulating rapid single-char key events with <10ms deltas followed by a 150ms+ pause, asserting scanBufferTailForDraggedImage fires
InputPrompt.tsx:853-857 promotePastedImagePaths creates attachments without [Image #N] placeholders, causing inconsistent submit behavior (images prepended instead of inline) compared to the new addImageAttachment path Have promotePastedImagePaths call addImageAttachment per resolved image for consistent placeholder-based substitution
InputPrompt.tsx:1129 classifyPastedImagePaths(pasted) runs unconditionally on every paste, even when earlier branches (pasteImage, decodedImage, draggedImagePath) short-circuit Move the call inside the else if branch that consumes it
clipboardUtils.ts:838 detectDraggedImagePath uses synchronous statSync, blocking the event loop on every paste; inconsistent with the rest of the file which uses fs/promises Replace statSync with await fs.stat() and make callers async
clipboardUtils.ts:742 isLikelyBase64 rejects valid unpadded base64 via s.length % 4 !== 0; several real-world encoders produce unpadded output Pad to multiple of 4 before checking: s += '='.repeat((4 - s.length % 4) % 4)
InputPrompt.tsx:296-298 DRAG_BURST_MAX_INTERVAL_MS, DRAG_CHECK_DEBOUNCE_MS, DRAG_MIN_BURST_CHARS declared inside component body; existing module-level constants set the pattern Move to module scope alongside LARGE_PASTE_CHAR_THRESHOLD
clipboardUtils.ts:773-777 try { Buffer.from(payload, 'base64') } catch is dead code — Node.js never throws for string+base64 encoding Remove the try/catch or add a comment explaining it's intentionally defensive
InputPrompt.tsx:1133 handleBase64ImagePaste(decodedImage) is a floating promise without void; nearby promotePastedImagePaths uses void Add void prefix for consistency with promotePastedImagePaths

— qwen3.7-max via Qwen Code /review

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed — no blockers. Suggestions are inline.

file.startsWith('clipboard-') &&
(file.endsWith('.png') ||
file.endsWith('.jpg') ||
file.endsWith('.jpeg') ||

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] .bin files produced by saveDecodedImage are never cleaned up

saveDecodedImage falls back to ext = 'bin' when the MIME type is unrecognized (line ~827: EXT_BY_MIME[declaredMime] ?? 'bin'), producing files like clipboard-<ts>.bin. However, this cleanup filter only matches .png, .jpg, .jpeg, .webp, .heic, .heif, .tiff, .gif, .bmp.bin is absent, so those files accumulate indefinitely.

Suggested change
file.endsWith('.jpeg') ||
file.endsWith('.bmp') ||
file.endsWith('.bin'))

— qwen3.7-max via Qwen Code /review

// scan of buffer.text's trailing token and promote it to an image
// attachment when it points at a real image file.
if (
!shellModeActive &&

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] No test for keystroke-burst drag detection path

The non-bracketed-paste drag detection (burst timing + debounce timer + scanBufferTailForDraggedImage) is the most complex new code path but has zero test coverage. The existing drag-drop tests only exercise the bracketed-paste path (\x1b[200~...\x1b[201~), which goes through a completely different branch.

Consider adding a test that simulates rapid single-character keystrokes spelling out an image path, waits for the debounce timer, and asserts addImageAttachment is called with the detected path.

— qwen3.7-max via Qwen Code /review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type/feature-request New feature or enhancement request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow pasting images as base64 / data-URL text and dragging image files, with unified [Image #N] placeholders

8 participants