mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
feat(cli): promote pasted image paths to attachments (#5803)
This commit is contained in:
parent
85fef85274
commit
3fbb5c1a26
2 changed files with 138 additions and 1 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue