Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d0cc725
feat(cli): paste copied Windows files
Jul 28, 2026
5d8031d
fix(cli): address Windows clipboard paste review
Jul 29, 2026
0173db4
test(cli): harden clipboard paste coverage
Jul 29, 2026
a45914f
Merge branch 'main' into agent/fix-qwen-2605
yiliang114 Jul 30, 2026
baa3648
fix(cli): preserve Windows file paste references
Jul 30, 2026
626af9b
test(cli): cover UNC clipboard file references
Jul 30, 2026
28729e2
fix(cli): normalize clipboard file references
Jul 30, 2026
a5a0739
fix(cli): unescape clipboard image references
Jul 30, 2026
7e47a6a
fix(cli): unwrap quoted image references
Jul 31, 2026
c4a5ff6
chore: merge origin/main into agent/fix-qwen-2605
qwen-code-dev-bot Aug 1, 2026
87b0e57
fix(cli): avoid double-unescaping pasted image paths
Aug 1, 2026
ccab11d
Merge branch 'main' into agent/fix-qwen-2605
wenshao Aug 1, 2026
de3e823
fix(cli): tighten copied-file paste handling
Aug 2, 2026
390227a
Merge remote-tracking branch 'origin/main' into agent/fix-qwen-2605
Aug 2, 2026
9eacd6a
fix(cli): isolate clipboard file paste events
Aug 2, 2026
acec515
chore: merge main into clipboard paste branch
Sep 5, 2026
4908b46
test(cli): initialize clipboard file mock for all prompt tests
Sep 5, 2026
5b293c2
Merge branch 'main' into agent/fix-qwen-2605
wenshao Sep 5, 2026
934d3ba
fix(cli): preserve pasted paths and pending image files
Sep 6, 2026
17916b9
Merge origin/main into agent/fix-qwen-2605
Sep 6, 2026
c420b05
fix(cli): preserve clipboard owners and Unicode file references
Sep 6, 2026
0f0ce10
chore(cli): merge origin/main into agent/fix-qwen-2605
qwen-code-dev-bot Sep 23, 2026
6bb19a9
chore: merge origin/main into agent/fix-qwen-2605
qwen-code-dev-bot Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/users/reference/keyboard-shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ This document lists the available keyboard shortcuts in Qwen Code.
| `Ctrl+Y` | Retry the last failed request. |
| `Ctrl+Right Arrow` / `Meta+Right Arrow` / `Meta+F` | Move the cursor one word to the right. |
| `Ctrl+U` | Delete from the cursor to the beginning of the line. |
| `Ctrl+V` (Windows: `Alt+V`) | Paste clipboard content. If the clipboard contains an image, it will be saved and a reference to it will be inserted in the prompt. |
| `Ctrl+V` (Windows: `Alt+V`) | Paste clipboard content. Images become attachments. On Windows, files copied in Explorer paste as image attachments or their paths. |
| `Ctrl+W` / `Meta+Backspace` / `Ctrl+Backspace` | Delete the word to the left of the cursor. |
| `Ctrl+X` | Open the current input in an external editor. |

Expand Down
99 changes: 99 additions & 0 deletions packages/cli/src/ui/components/InputPrompt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,24 @@ const mockViewActions = vi.hoisted(() => ({
enterBgDetailFromPanel: vi.fn(),
}));

const { mockFsStat, mockFsMkdir, mockFsCopyFile } = vi.hoisted(() => ({
mockFsStat: vi.fn(),
mockFsMkdir: vi.fn(),
mockFsCopyFile: vi.fn(),
}));

vi.mock('../hooks/useShellHistory.js');
vi.mock('../hooks/useCommandCompletion.js');
vi.mock('../hooks/useInputHistory.js');
vi.mock('../hooks/useReverseSearchCompletion.js');
vi.mock('../hooks/use-voice-input.js');
vi.mock('../utils/clipboardUtils.js');
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof import('node:fs/promises')>()),
stat: mockFsStat,
mkdir: mockFsMkdir,
copyFile: mockFsCopyFile,
}));
vi.mock('../../services/prompt-stash.js');
vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: vi.fn(() => ({ isFeedbackDialogOpen: false, messageQueue: [] })),
Expand Down Expand Up @@ -203,6 +215,9 @@ describe('InputPrompt', () => {

beforeEach(() => {
vi.resetAllMocks();
mockFsStat.mockRejectedValue(new Error('file not found'));
mockFsMkdir.mockResolvedValue(undefined);
mockFsCopyFile.mockResolvedValue(undefined);
mockedSavePromptStash.mockReturnValue(true);
mockedClearPromptStash.mockReturnValue(true);
mockViewActions.setAgentTabBarFocused.mockReset();
Expand Down Expand Up @@ -1424,6 +1439,7 @@ describe('InputPrompt', () => {
const isWindows = process.platform === 'win32';

beforeEach(() => {
vi.mocked(clipboardUtils.readClipboardFiles).mockResolvedValue([]);
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null);
vi.mocked(clipboardUtils.cleanupOldClipboardImages).mockResolvedValue(
Expand Down Expand Up @@ -1481,6 +1497,81 @@ describe('InputPrompt', () => {
unmount();
});

it('inserts copied non-image file paths on the clipboard shortcut', async () => {
vi.mocked(clipboardUtils.readClipboardFiles).mockResolvedValue([
'C:\\Users\\mochi\\notes.txt',
]);

const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();

stdin.write(isWindows ? '\x1Bv' : '\x16');
await wait();

expect(mockBuffer.insert).toHaveBeenCalledWith(
'C:\\Users\\mochi\\notes.txt',
{ paste: false },
);
expect(clipboardUtils.clipboardHasImage).not.toHaveBeenCalled();
unmount();
});

it('promotes copied image files to attachments on the clipboard shortcut', async () => {
const imagePath = 'C:\\Users\\mochi\\image.png';
vi.mocked(clipboardUtils.readClipboardFiles).mockResolvedValue([
imagePath,
]);
mockFsStat.mockResolvedValue({
isFile: () => true,
});

const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();

stdin.write(isWindows ? '\x1Bv' : '\x16');
await wait();

expect(mockFsStat).toHaveBeenCalledWith(imagePath);
expect(mockFsCopyFile).toHaveBeenCalledWith(
imagePath,
expect.stringMatching(/clipboard-\d+-0\.png$/),
);
expect(mockBuffer.insert).not.toHaveBeenCalled();
unmount();
});

it('collapses a large copied file list into a paste placeholder', async () => {
const clipboardFiles = Array.from(
{ length: 11 },
(_, index) => `C:\\Users\\mochi\\notes-${index}.txt`,
);
vi.mocked(clipboardUtils.readClipboardFiles).mockResolvedValue(
clipboardFiles,
);

const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();

stdin.write(isWindows ? '\x1Bv' : '\x16');
await wait();

expect(mockBuffer.insert).toHaveBeenCalledWith(
expect.stringMatching(/^\[Pasted Content \d+ chars\]$/),
{ paste: false },
);
expect(mockBuffer.insert).not.toHaveBeenCalledWith(
clipboardFiles.join('\n'),
expect.anything(),
);
unmount();
});

it('keeps generated attachment references out of submitted provenance', async () => {
const imagePath = path.join(
'test',
Expand Down Expand Up @@ -5697,6 +5788,14 @@ function clean(str: string | undefined): string {
}

describe('classifyPastedImagePaths', () => {
it('recognizes a Windows image path read from the file clipboard', () => {
const imagePath = 'C:\\Users\\mochi\\image.png';
expect(classifyPastedImagePaths(imagePath)).toEqual({
imagePaths: [imagePath],
allImages: true,
});
});

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',
Expand Down
69 changes: 51 additions & 18 deletions packages/cli/src/ui/components/InputPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
clipboardHasImage,
saveClipboardImage,
cleanupOldClipboardImages,
readClipboardFiles,
} from '../utils/clipboardUtils.js';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
Expand Down Expand Up @@ -582,6 +583,29 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return id === 1 ? base : `${base} #${id}`;
}, []);

const insertLargePastePlaceholder = useCallback(
(pasted: string): boolean => {
const charCount = [...pasted].length;
const lineCount = pasted.split('\n').length;
if (
charCount <= LARGE_PASTE_CHAR_THRESHOLD &&
lineCount <= LARGE_PASTE_LINE_THRESHOLD
) {
return false;
}

const placeholder = nextLargePastePlaceholder(charCount);
setPendingPastes((prev) => {
const next = new Map(prev);
next.set(placeholder, pasted);
return next;
});
buffer.insert(placeholder, { paste: false });
return true;
},
[buffer, nextLargePastePlaceholder],
);

// Clear escape prompt timer on unmount
useEffect(
() => () => {
Expand Down Expand Up @@ -821,6 +845,28 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
[config, buffer],
);

const handleClipboardPaste = useCallback(async () => {
const clipboardFiles = await readClipboardFiles(reportClipboardUnavailable);
if (clipboardFiles.length === 0) {
await handleClipboardImage();
return;
}

const pasted = clipboardFiles.join('\n');
const pastedImagePaths = classifyPastedImagePaths(pasted);
if (pastedImagePaths.allImages) {
await promotePastedImagePaths(pastedImagePaths.imagePaths, pasted);
} else if (!insertLargePastePlaceholder(pasted)) {
buffer.insert(pasted, { paste: false });
}
}, [
buffer,
handleClipboardImage,
insertLargePastePlaceholder,
promotePastedImagePaths,
reportClipboardUnavailable,
]);

// Handle deletion of an attachment from the list
const handleAttachmentDelete = useCallback((index: number) => {
setAttachments((prev) => {
Expand Down Expand Up @@ -1024,8 +1070,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({

// Handle large pastes by showing a placeholder
const pasted = key.sequence.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const charCount = [...pasted].length; // Proper Unicode char count
const lineCount = pasted.split('\n').length;

// Ensure we never accidentally interpret paste as regular input.
const pastedImagePaths = classifyPastedImagePaths(pasted);
Expand All @@ -1041,19 +1085,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// 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
) {
const placeholder = nextLargePastePlaceholder(charCount);
setPendingPastes((prev) => {
const next = new Map(prev);
next.set(placeholder, pasted);
return next;
});
// Insert the placeholder as regular text
buffer.insert(placeholder, { paste: false });
} else {
} else if (!insertLargePastePlaceholder(pasted)) {
// Normal paste handling for small content
buffer.handleInput(key);
}
Expand Down Expand Up @@ -1715,9 +1747,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return true;
}

// Ctrl+V for clipboard image paste
// Clipboard shortcut for copied files or image data
if (keyMatchers[Command.PASTE_CLIPBOARD_IMAGE](key)) {
handleClipboardImage();
void handleClipboardPaste();
return true;
}

Expand Down Expand Up @@ -1824,6 +1856,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
shellHistory,
reverseSearchCompletion,
handleClipboardImage,
handleClipboardPaste,
reportClipboardUnavailable,
promotePastedImagePaths,
resetCompletionState,
Expand All @@ -1847,7 +1880,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
handleAttachmentDelete,
uiActions,
pasteWorkaround,
nextLargePastePlaceholder,
insertLargePastePlaceholder,
pendingPastes,
parsePlaceholder,
freePlaceholderId,
Expand Down
38 changes: 37 additions & 1 deletion packages/cli/src/ui/contexts/KeypressContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,14 @@ import {
import { useStdin } from 'ink';
import { EventEmitter } from 'node:events';

const mockClipboardHasImage = vi.hoisted(() => vi.fn());
const { mockClipboardHasImage, mockReadClipboardFiles } = vi.hoisted(() => ({
mockClipboardHasImage: vi.fn(),
mockReadClipboardFiles: vi.fn(),
}));

vi.mock('../utils/clipboardUtils.js', () => ({
clipboardHasImage: mockClipboardHasImage,
readClipboardFiles: mockReadClipboardFiles,
}));

// Mock the 'ink' module to control stdin
Expand Down Expand Up @@ -90,6 +94,7 @@ describe('KeypressContext - Kitty Protocol', () => {
beforeEach(() => {
vi.clearAllMocks();
mockClipboardHasImage.mockResolvedValue(false);
mockReadClipboardFiles.mockResolvedValue([]);
stdin = new MockStdin();
(useStdin as Mock).mockReturnValue({
stdin,
Expand Down Expand Up @@ -885,6 +890,37 @@ describe('KeypressContext - Kitty Protocol', () => {
});
});

it.each([
['raw paste path', false],
['keypress paste path', true],
])(
'turns copied Windows files into pasted paths through the %s',
async (_path, pasteWorkaround) => {
const keyHandler = vi.fn();
mockReadClipboardFiles.mockResolvedValue([
'C:\\Users\\mochi\\image.png',
'C:\\Users\\mochi\\notes.txt',
]);
const { result } = renderHook(() => useKeypressContext(), {
wrapper: ({ children }) => wrapper({ children, pasteWorkaround }),
});
act(() => result.current.subscribe(keyHandler));

act(() => stdin.sendPaste(''));

await waitFor(() => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
paste: true,
sequence:
'C:\\Users\\mochi\\image.png\nC:\\Users\\mochi\\notes.txt',
}),
);
});
expect(mockClipboardHasImage).not.toHaveBeenCalled();
},
);

describe('paste mode markers', () => {
// These tests use pasteWorkaround=true to force passthrough mode for raw keypress testing

Expand Down
Loading