diff --git a/docs/users/reference/keyboard-shortcuts.md b/docs/users/reference/keyboard-shortcuts.md
index fc2f86286..f0cbd7b16 100644
--- a/docs/users/reference/keyboard-shortcuts.md
+++ b/docs/users/reference/keyboard-shortcuts.md
@@ -42,7 +42,7 @@ This document lists the available keyboard shortcuts in Qwen Code.
| `Ctrl+R` | Reverse search through input/shell history. |
| `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` | 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. If the clipboard contains an image, it will be saved and a reference to it will be inserted in the prompt. |
| `Ctrl+W` / `Meta+Backspace` / `Ctrl+Backspace` | Delete the word to the left of the cursor. |
| `Ctrl+X` / `Meta+Enter` | Open the current input in an external editor. |
diff --git a/esbuild.config.js b/esbuild.config.js
index 12ab39d58..2b532b44e 100644
--- a/esbuild.config.js
+++ b/esbuild.config.js
@@ -33,6 +33,13 @@ const external = [
'@lydell/node-pty-linux-x64',
'@lydell/node-pty-win32-arm64',
'@lydell/node-pty-win32-x64',
+ '@teddyzhu/clipboard',
+ '@teddyzhu/clipboard-darwin-arm64',
+ '@teddyzhu/clipboard-darwin-x64',
+ '@teddyzhu/clipboard-linux-x64-gnu',
+ '@teddyzhu/clipboard-linux-arm64-gnu',
+ '@teddyzhu/clipboard-win32-x64-msvc',
+ '@teddyzhu/clipboard-win32-arm64-msvc',
];
esbuild
diff --git a/package-lock.json b/package-lock.json
index bed6b59d5..5a2359a5d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3834,6 +3834,119 @@
"node": ">=6"
}
},
+ "node_modules/@teddyzhu/clipboard": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard/-/clipboard-0.0.5.tgz",
+ "integrity": "sha512-XA6MG7nLPZzj51agCwDYaVnVVrt0ByJ3G9rl3ar6N4GETAjUKKup6u76SLp2C5yHRWYV9hwMYDn04OGLar0MVg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0"
+ },
+ "optionalDependencies": {
+ "@teddyzhu/clipboard-darwin-arm64": "0.0.5",
+ "@teddyzhu/clipboard-darwin-x64": "0.0.5",
+ "@teddyzhu/clipboard-linux-arm64-gnu": "0.0.5",
+ "@teddyzhu/clipboard-linux-x64-gnu": "0.0.5",
+ "@teddyzhu/clipboard-win32-arm64-msvc": "0.0.5",
+ "@teddyzhu/clipboard-win32-x64-msvc": "0.0.5"
+ }
+ },
+ "node_modules/@teddyzhu/clipboard-darwin-arm64": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.0.5.tgz",
+ "integrity": "sha512-FB3yykRAcw0VLmSjIGFddgew2t20UnLp80NZvi5e/lbsy/3mruHibMHkxHWqzCncuZsHdRsRXS/FmR/ggepW9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0"
+ }
+ },
+ "node_modules/@teddyzhu/clipboard-darwin-x64": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-darwin-x64/-/clipboard-darwin-x64-0.0.5.tgz",
+ "integrity": "sha512-tiDazMpLf2dS7BZUif3da3DLJima8E/CnexB3CNgjQf12CFJ+D1cPcj/CgfvMYZgFQSsYyACpQNfXn4hmVbymA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0"
+ }
+ },
+ "node_modules/@teddyzhu/clipboard-linux-arm64-gnu": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.0.5.tgz",
+ "integrity": "sha512-qcokM+BaXn4iG4o4nYGHdfC04pr54S2F7x2o5osFhG3hMVYHZLR/8NKcYDKELnebpH612nW2bNRoWWy14lM45g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0"
+ }
+ },
+ "node_modules/@teddyzhu/clipboard-linux-x64-gnu": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.0.5.tgz",
+ "integrity": "sha512-Ogh4zYM9s537WJszSvKrPAoKQZ2grnY7Xy6szyJp2+84uQKWNbvZkATODAsRUn48zr9gqL3PZeUqkIBaz8sCpQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0"
+ }
+ },
+ "node_modules/@teddyzhu/clipboard-win32-arm64-msvc": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.0.5.tgz",
+ "integrity": "sha512-TuU+7e8qYc0T++sIArHTmqr+nfqiTfJ6gdrb1e8yDJb6MM3EFxCd2VonTqLQL1YpUdfcH+/rdMarG2rvCwvEhQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0"
+ }
+ },
+ "node_modules/@teddyzhu/clipboard-win32-x64-msvc": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.0.5.tgz",
+ "integrity": "sha512-f1Br5bI+INNDifjkOI1woZsIxsoW0rRej/4kaaJvZcMxxkSG9TMT2LYOjTF2g+DtXw32lsGvWICN6c3JiHeG7Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0"
+ }
+ },
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -18661,6 +18774,7 @@
"@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.25.1",
"@qwen-code/qwen-code-core": "file:../core",
+ "@teddyzhu/clipboard": "^0.0.5",
"@types/update-notifier": "^6.0.8",
"ansi-regex": "^6.2.2",
"command-exists": "^1.2.9",
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 14cfde268..153a51376 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -81,12 +81,12 @@
"@types/diff": "^7.0.2",
"@types/dotenv": "^6.1.1",
"@types/node": "^20.11.24",
+ "@types/prompts": "^2.4.9",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/semver": "^7.7.0",
"@types/shell-quote": "^1.7.5",
"@types/yargs": "^17.0.32",
- "@types/prompts": "^2.4.9",
"archiver": "^7.0.1",
"ink-testing-library": "^4.0.0",
"jsdom": "^26.1.0",
@@ -95,6 +95,15 @@
"typescript": "^5.3.3",
"vitest": "^3.1.1"
},
+ "optionalDependencies": {
+ "@teddyzhu/clipboard": "^0.0.5",
+ "@teddyzhu/clipboard-darwin-arm64": "0.0.5",
+ "@teddyzhu/clipboard-darwin-x64": "0.0.5",
+ "@teddyzhu/clipboard-linux-x64-gnu": "0.0.5",
+ "@teddyzhu/clipboard-linux-arm64-gnu": "0.0.5",
+ "@teddyzhu/clipboard-win32-x64-msvc": "0.0.5",
+ "@teddyzhu/clipboard-win32-arm64-msvc": "0.0.5"
+ },
"engines": {
"node": ">=20"
}
diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts
index 8737866ea..226727c5b 100644
--- a/packages/cli/src/config/keyBindings.ts
+++ b/packages/cli/src/config/keyBindings.ts
@@ -78,6 +78,7 @@ export interface KeyBinding {
command?: boolean;
/** Paste operation requirement: true=must be paste, false=must not be paste, undefined=ignore */
paste?: boolean;
+ meta?: boolean;
}
/**
@@ -152,7 +153,16 @@ export const defaultKeyBindings: KeyBindingConfig = {
{ key: 'x', ctrl: true },
{ sequence: '\x18', ctrl: true },
],
- [Command.PASTE_CLIPBOARD_IMAGE]: [{ key: 'v', ctrl: true }],
+ [Command.PASTE_CLIPBOARD_IMAGE]:
+ process.platform === 'win32'
+ ? [
+ { key: 'v', command: true },
+ { key: 'v', meta: true },
+ ]
+ : [
+ { key: 'v', ctrl: true },
+ { key: 'v', command: true },
+ ],
// App level bindings
[Command.TOGGLE_TOOL_DESCRIPTIONS]: [{ key: 't', ctrl: true }],
diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js
index 4ff5a3c28..431b70910 100644
--- a/packages/cli/src/i18n/locales/de.js
+++ b/packages/cli/src/i18n/locales/de.js
@@ -11,6 +11,12 @@ export default {
// ============================================================================
// Help / UI Components
// ============================================================================
+ // Attachment hints
+ '↑ to manage attachments': '↑ Anhänge verwalten',
+ '← → select, Delete to remove, ↓ to exit':
+ '← → auswählen, Entf zum Löschen, ↓ beenden',
+ 'Attachments: ': 'Anhänge: ',
+
'Basics:': 'Grundlagen:',
'Add context': 'Kontext hinzufügen',
'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.':
diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js
index d4dc217c9..775f470b7 100644
--- a/packages/cli/src/i18n/locales/en.js
+++ b/packages/cli/src/i18n/locales/en.js
@@ -11,6 +11,12 @@ export default {
// ============================================================================
// Help / UI Components
// ============================================================================
+ // Attachment hints
+ '↑ to manage attachments': '↑ to manage attachments',
+ '← → select, Delete to remove, ↓ to exit':
+ '← → select, Delete to remove, ↓ to exit',
+ 'Attachments: ': 'Attachments: ',
+
'Basics:': 'Basics:',
'Add context': 'Add context',
'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.':
diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js
index 7534c54ed..a8299a762 100644
--- a/packages/cli/src/i18n/locales/ru.js
+++ b/packages/cli/src/i18n/locales/ru.js
@@ -11,6 +11,12 @@ export default {
// ============================================================================
// Справка / Компоненты интерфейса
// ============================================================================
+ // Attachment hints
+ '↑ to manage attachments': '↑ управление вложениями',
+ '← → select, Delete to remove, ↓ to exit':
+ '← → выбрать, Delete удалить, ↓ выйти',
+ 'Attachments: ': 'Вложения: ',
+
'Basics:': 'Основы:',
'Add context': 'Добавить контекст',
'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.':
diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js
index 1d6e6df68..b0db2d0e5 100644
--- a/packages/cli/src/i18n/locales/zh.js
+++ b/packages/cli/src/i18n/locales/zh.js
@@ -10,6 +10,11 @@ export default {
// ============================================================================
// Help / UI Components
// ============================================================================
+ // Attachment hints
+ '↑ to manage attachments': '↑ 管理附件',
+ '← → select, Delete to remove, ↓ to exit': '← → 选择,Delete 删除,↓ 退出',
+ 'Attachments: ': '附件:',
+
'Basics:': '基础功能:',
'Add context': '添加上下文',
'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.':
diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
index b8c83a132..d5ace1c53 100644
--- a/packages/cli/src/ui/components/InputPrompt.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.test.tsx
@@ -370,6 +370,8 @@ describe('InputPrompt', () => {
});
describe('clipboard image paste', () => {
+ const isWindows = process.platform === 'win32';
+
beforeEach(() => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null);
@@ -378,10 +380,37 @@ describe('InputPrompt', () => {
);
});
- it('should handle Ctrl+V when clipboard has an image', async () => {
+ // Windows uses Alt+V (\x1Bv), non-Windows uses Ctrl+V (\x16)
+ const describeConditional = isWindows ? it.skip : it;
+ describeConditional(
+ 'should handle Ctrl+V when clipboard has an image',
+ async () => {
+ vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
+ vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
+ '/Users/mochi/.qwen/tmp/clipboard-123.png',
+ );
+
+ const { stdin, unmount } = renderWithProviders(
+ ,
+ );
+ await wait();
+
+ // Send Ctrl+V
+ stdin.write('\x16'); // Ctrl+V
+ await wait();
+
+ expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
+ expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
+ expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalled();
+ // Note: The new implementation adds images as attachments rather than inserting into buffer
+ unmount();
+ },
+ );
+
+ it('should handle Cmd+V when clipboard has an image', async () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
- '/test/.qwen-clipboard/clipboard-123.png',
+ '/Users/mochi/.qwen/tmp/clipboard-456.png',
);
const { stdin, unmount } = renderWithProviders(
@@ -389,18 +418,15 @@ describe('InputPrompt', () => {
);
await wait();
- // Send Ctrl+V
- stdin.write('\x16'); // Ctrl+V
+ // Send Cmd+V (meta key) / Alt+V on Windows
+ // In terminals, Cmd+V or Alt+V is typically sent as ESC followed by 'v'
+ stdin.write('\x1Bv');
await wait();
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
- expect(clipboardUtils.saveClipboardImage).toHaveBeenCalledWith(
- props.config.getTargetDir(),
- );
- expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalledWith(
- props.config.getTargetDir(),
- );
- expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled();
+ expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
+ expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalled();
+ // Note: The new implementation adds images as attachments rather than inserting into buffer
unmount();
});
@@ -412,7 +438,8 @@ describe('InputPrompt', () => {
);
await wait();
- stdin.write('\x16'); // Ctrl+V
+ // Use platform-appropriate key combination
+ stdin.write(isWindows ? '\x1Bv' : '\x16');
await wait();
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
@@ -430,7 +457,8 @@ describe('InputPrompt', () => {
);
await wait();
- stdin.write('\x16'); // Ctrl+V
+ // Use platform-appropriate key combination
+ stdin.write(isWindows ? '\x1Bv' : '\x16');
await wait();
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
@@ -439,11 +467,7 @@ describe('InputPrompt', () => {
});
it('should insert image path at cursor position with proper spacing', async () => {
- const imagePath = path.join(
- 'test',
- '.qwen-clipboard',
- 'clipboard-456.png',
- );
+ const imagePath = '/Users/mochi/.qwen/tmp/clipboard-456.png';
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(imagePath);
@@ -451,27 +475,20 @@ describe('InputPrompt', () => {
mockBuffer.text = 'Hello world';
mockBuffer.cursor = [0, 5]; // Cursor after "Hello"
mockBuffer.lines = ['Hello world'];
- mockBuffer.replaceRangeByOffset = vi.fn();
const { stdin, unmount } = renderWithProviders(
,
);
await wait();
- stdin.write('\x16'); // Ctrl+V
+ // Use platform-appropriate key combination
+ stdin.write(isWindows ? '\x1Bv' : '\x16');
await wait();
- // Should insert at cursor position with spaces
- expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled();
-
- // Get the actual call to see what path was used
- const actualCall = vi.mocked(mockBuffer.replaceRangeByOffset).mock
- .calls[0];
- expect(actualCall[0]).toBe(5); // start offset
- expect(actualCall[1]).toBe(5); // end offset
- expect(actualCall[2]).toBe(
- ' @' + path.relative(path.join('test', 'project', 'src'), imagePath),
- );
+ // The new implementation adds images as attachments rather than inserting into buffer
+ // So we verify that saveClipboardImage was called instead
+ expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
+ expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
unmount();
});
@@ -485,7 +502,8 @@ describe('InputPrompt', () => {
);
await wait();
- stdin.write('\x16'); // Ctrl+V
+ // Use platform-appropriate key combination
+ stdin.write(isWindows ? '\x1Bv' : '\x16');
await wait();
// Should not throw and should not set buffer text on error
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 8820e2126..09c2b27f1 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -22,7 +22,11 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { Config } from '@qwen-code/qwen-code-core';
-import { ApprovalMode, createDebugLogger } from '@qwen-code/qwen-code-core';
+import {
+ ApprovalMode,
+ Storage,
+ createDebugLogger,
+} from '@qwen-code/qwen-code-core';
import {
parseInputForHighlighting,
buildSegmentsForVisualSlice,
@@ -41,6 +45,15 @@ import { useUIActions } from '../contexts/UIActionsContext.js';
import { useKeypressContext } from '../contexts/KeypressContext.js';
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
+/**
+ * Represents an attachment (e.g., pasted image) displayed above the input prompt
+ */
+export interface Attachment {
+ id: string; // Unique identifier (timestamp)
+ path: string; // Full file path
+ filename: string; // Filename only (for display)
+}
+
const debugLogger = createDebugLogger('INPUT_PROMPT');
export interface InputPromptProps {
buffer: TextBuffer;
@@ -126,6 +139,10 @@ export const InputPrompt: React.FC = ({
const [recentPasteTime, setRecentPasteTime] = useState(null);
const pasteTimeoutRef = useRef(null);
+ // Attachment state for clipboard images
+ const [attachments, setAttachments] = useState([]);
+ const [isAttachmentMode, setIsAttachmentMode] = useState(false);
+ const [selectedAttachmentIndex, setSelectedAttachmentIndex] = useState(-1);
// Large paste placeholder handling
const [pendingPastes, setPendingPastes] = useState