feat(cli): promote pasted image paths to attachments (#5803)

This commit is contained in:
易良 2026-06-24 15:12:20 +08:00 committed by GitHub
parent 85fef85274
commit 3fbb5c1a26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 138 additions and 1 deletions

View file

@ -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);
});
});

View file

@ -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<InputPromptProps> = ({
}
}, []);
// Promote a paste that is purely image-file path(s) (e.g. a terminal/clipboard
// helper that injects `@<path>` 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<InputPromptProps> = ({
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<InputPromptProps> = ({
shellHistory,
reverseSearchCompletion,
handleClipboardImage,
promotePastedImagePaths,
resetCompletionState,
dismissCompletion,
escPressCount,