From 3fbb5c1a26a1ddf379dd41b321e6fe5d86f30ba0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Wed, 24 Jun 2026 15:12:20 +0800 Subject: [PATCH] feat(cli): promote pasted image paths to attachments (#5803) --- .../src/ui/components/InputPrompt.test.tsx | 41 +++++++- .../cli/src/ui/components/InputPrompt.tsx | 98 +++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 45145ee472..0c51da6e32 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -7,7 +7,7 @@ import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor, act } from '@testing-library/react'; import type { InputPromptProps } from './InputPrompt.js'; -import { InputPrompt } from './InputPrompt.js'; +import { InputPrompt, classifyPastedImagePaths } from './InputPrompt.js'; import { useTextBuffer, type TextBuffer } from './shared/text-buffer.js'; import type { Config } from '@qwen-code/qwen-code-core'; import { ApprovalMode } from '@qwen-code/qwen-code-core'; @@ -5207,3 +5207,42 @@ function clean(str: string | undefined): string { // Remove ANSI escape codes and trim whitespace return stripAnsi(str).trim(); } + +describe('classifyPastedImagePaths', () => { + it('treats a lone @-prefixed image path (terminal Cmd+V injection) as all-image', () => { + const result = classifyPastedImagePaths( + '@/var/folders/12/T/clipboard-2026-06-24-124142-18EC6DC9.png', + ); + expect(result.allImages).toBe(true); + expect(result.imagePaths).toEqual([ + '/var/folders/12/T/clipboard-2026-06-24-124142-18EC6DC9.png', + ]); + }); + + it('splits multiple space- and newline-separated image paths', () => { + const result = classifyPastedImagePaths( + '/a/one.png /b/two.jpg\n/c/three.webp', + ); + expect(result.allImages).toBe(true); + expect(result.imagePaths).toEqual([ + '/a/one.png', + '/b/two.jpg', + '/c/three.webp', + ]); + }); + + it('unwraps surrounding quotes and shell-escaped spaces', () => { + const result = classifyPastedImagePaths('"/a/my image.png"'); + expect(result.allImages).toBe(true); + expect(result.imagePaths).toEqual(['/a/my image.png']); + }); + + it('does not treat plain text or non-image paths as image paste', () => { + expect(classifyPastedImagePaths('just some text').allImages).toBe(false); + expect(classifyPastedImagePaths('/src/index.ts').imagePaths).toEqual([]); + // A path followed by free text is left for normal text handling. + expect( + classifyPastedImagePaths('@/a/img.png describe this').allImages, + ).toBe(false); + }); +}); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 52d760c3f2..e3ab80ff25 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -43,6 +43,7 @@ import { cleanupOldClipboardImages, } from '../utils/clipboardUtils.js'; import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; import { SCREEN_READER_USER_PREFIX } from '../textConstants.js'; import { useShellFocusState } from '../contexts/ShellFocusContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; @@ -89,6 +90,48 @@ export interface Attachment { filename: string; // Filename only (for display) } +const PASTED_IMAGE_EXTENSIONS = /\.(png|jpe?g|gif|webp|bmp)$/i; + +/** + * Classify a pasted blob as image-file-path(s). + * + * Some terminals and clipboard helpers handle an image paste by injecting the + * saved file path as text (optionally prefixed with `@`, shell-escaped, or + * space/newline-separated for multiple files) instead of routing through our + * own Ctrl+V handler. Recognizing those lets Cmd+V (terminal-driven) converge + * on the same attachment UX as Ctrl+V. Mirrors Claude Code's usePasteHandler: + * split on newlines and on spaces that precede an absolute path (spaces inside + * a path are shell-escaped), then normalize each token. + * + * @returns `imagePaths` (normalized tokens with an image extension) and + * `allImages` (true only when every token is such a path), so the caller can + * promote a pure image-path paste without swallowing mixed text. + */ +export function classifyPastedImagePaths(pasted: string): { + imagePaths: string[]; + allImages: boolean; +} { + const tokens = pasted + .split(/ (?=@?\/|@?[A-Za-z]:\\)/) + .flatMap((part) => part.split('\n')) + .map((token) => token.trim()) + .filter(Boolean); + const imagePaths: string[] = []; + let allImages = tokens.length > 0; + for (const token of tokens) { + const normalized = token + .replace(/^@/, '') // strip the `@` reference prefix + .replace(/^["']|["']$/g, '') // strip surrounding quotes + .replace(/\\ /g, ' '); // unescape shell-escaped spaces + if (PASTED_IMAGE_EXTENSIONS.test(normalized)) { + imagePaths.push(normalized); + } else { + allImages = false; + } + } + return { imagePaths, allImages }; +} + const debugLogger = createDebugLogger('INPUT_PROMPT'); export interface InputPromptProps { @@ -654,6 +697,52 @@ export const InputPrompt: React.FC = ({ } }, []); + // Promote a paste that is purely image-file path(s) (e.g. a terminal/clipboard + // helper that injects `@` text on Cmd+V) into attachment chips, so the + // interaction matches Ctrl+V. Each source image is copied into the global temp + // dir (the same place Ctrl+V saves to) so it resolves under the workspace + // boundary on submit regardless of where it originally lived. If none of the + // candidate paths resolve, the original text is inserted unchanged. + const promotePastedImagePaths = useCallback( + async (imagePaths: string[], originalPasted: string) => { + const cwd = config.getTargetDir(); + const clipboardDir = path.join(Storage.getGlobalTempDir(), 'clipboard'); + const attachments: Attachment[] = []; + for (const imagePath of imagePaths) { + const sourcePath = path.isAbsolute(imagePath) + ? imagePath + : path.resolve(cwd, imagePath); + try { + const stats = await fs.stat(sourcePath); + if (!stats.isFile()) continue; + await fs.mkdir(clipboardDir, { recursive: true }); + const destPath = path.join( + clipboardDir, + `clipboard-${Date.now()}-${attachments.length}${path.extname(sourcePath)}`, + ); + await fs.copyFile(sourcePath, destPath); + attachments.push({ + id: `${Date.now()}-${attachments.length}`, + path: destPath, + filename: path.basename(destPath), + }); + } catch { + // Source missing or copy failed — skip this token. + } + } + if (attachments.length > 0) { + cleanupOldClipboardImages(Storage.getGlobalTempDir()).catch(() => { + // Ignore cleanup errors + }); + setAttachments((prev) => [...prev, ...attachments]); + } else { + // Looked like image paths but none resolved — keep the original as text. + buffer.insert(originalPasted, { paste: false }); + } + }, + [config, buffer], + ); + // Handle deletion of an attachment from the list const handleAttachmentDelete = useCallback((index: number) => { setAttachments((prev) => { @@ -861,8 +950,16 @@ export const InputPrompt: React.FC = ({ const lineCount = pasted.split('\n').length; // Ensure we never accidentally interpret paste as regular input. + const pastedImagePaths = classifyPastedImagePaths(pasted); if (key.pasteImage) { handleClipboardImage(true); + } else if ( + pastedImagePaths.allImages && + pastedImagePaths.imagePaths.length > 0 + ) { + // Pasted text is purely image path(s) — promote to attachment chips + // so Cmd+V (terminal-injected path) matches the Ctrl+V experience. + void promotePastedImagePaths(pastedImagePaths.imagePaths, pasted); } else if ( charCount > LARGE_PASTE_CHAR_THRESHOLD || lineCount > LARGE_PASTE_LINE_THRESHOLD @@ -1592,6 +1689,7 @@ export const InputPrompt: React.FC = ({ shellHistory, reverseSearchCompletion, handleClipboardImage, + promotePastedImagePaths, resetCompletionState, dismissCompletion, escPressCount,