Skip to content

Commit 63780e0

Browse files
fix(cli): add Option+V for clipboard image paste on macOS (#8120)
* fix(cli): add Option+V for clipboard image paste on macOS (#8118) macOS terminals intercept Cmd+V for text paste, so the existing Cmd+V binding for clipboard image paste never fires. Users had to discover Ctrl+V through trial and error. Changes: - keyBindings.ts: add { key: 'v', meta: true } (Option/Alt+V) to PASTE_CLIPBOARD_IMAGE bindings on non-Windows platforms - KeyboardShortcuts.tsx: update macOS hint from 'cmd+v' to 'ctrl+v / opt+v' to reflect what actually works * fix(cli): correct macOS image-paste shortcut hint (#8118) * test(cli): cover platform-specific image-paste shortcut hint (#8118) * fix(cli): make option+v image paste work on stock macOS terminals (#8118) * fix(cli): address review feedback on macOS image-paste shortcut (#8118) - Guard the option-composed glyph lookup with Object.hasOwn so inherited Object.prototype members can never resolve as a composed key. - Add a non-macOS pass-through test for the "√" glyph, mirroring the existing "†" coverage. - Correct the keyboard-shortcuts docs: Ctrl+V also pastes on macOS, matching the overlay hint. --------- Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com> Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
1 parent 29e9b35 commit 63780e0

5 files changed

Lines changed: 168 additions & 22 deletions

File tree

docs/users/reference/keyboard-shortcuts.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ This document lists the available keyboard shortcuts in Qwen Code.
4545
| `Ctrl+Y` | Retry the last failed request. |
4646
| `Ctrl+Right Arrow` / `Meta+Right Arrow` / `Meta+F` | Move the cursor one word to the right. |
4747
| `Ctrl+U` | Delete from the cursor to the beginning of the line. |
48-
| `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. |
48+
| `Ctrl+V` / `Option+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. |
4949
| `Ctrl+W` / `Meta+Backspace` / `Ctrl+Backspace` | Delete the word to the left of the cursor. |
5050
| `Ctrl+X` | Open the current input in an external editor. |
5151

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, it, expect, vi, afterEach } from 'vitest';
8+
import { render } from 'ink-testing-library';
9+
import { KeyboardShortcuts } from './KeyboardShortcuts.js';
10+
11+
// A narrow width forces the single-column layout so each shortcut renders on
12+
// its own line. ink-testing-library hard-codes stdout to 100 columns, so a
13+
// multi-column layout chosen from a wider mock can still wrap physically and
14+
// break these assertions for reasons unrelated to the hint text.
15+
vi.mock('../hooks/useTerminalSize.js', () => ({
16+
useTerminalSize: vi.fn(() => ({ columns: 40, rows: 24 })),
17+
}));
18+
19+
const originalPlatform = process.platform;
20+
21+
function stubPlatform(platform: NodeJS.Platform): void {
22+
Object.defineProperty(process, 'platform', {
23+
value: platform,
24+
configurable: true,
25+
});
26+
}
27+
28+
describe('KeyboardShortcuts', () => {
29+
afterEach(() => {
30+
stubPlatform(originalPlatform);
31+
});
32+
33+
it.each([
34+
['darwin', 'ctrl+v / option+v to paste images', ['alt+v']],
35+
['win32', 'alt+v to paste images', ['option+v']],
36+
['linux', 'ctrl+v to paste images', ['option+v', 'alt+v']],
37+
] as const)(
38+
'advertises the %s image-paste key',
39+
(platform, expectedPasteCell, absentKeys) => {
40+
stubPlatform(platform);
41+
const { lastFrame } = render(<KeyboardShortcuts />);
42+
const frame = lastFrame() ?? '';
43+
expect(frame).toContain(expectedPasteCell);
44+
for (const absent of absentKeys) {
45+
expect(frame).not.toContain(absent);
46+
}
47+
},
48+
);
49+
});

packages/cli/src/ui/components/KeyboardShortcuts.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ const getNewlineKey = () =>
2020
process.platform === 'win32' ? 'ctrl+enter' : 'ctrl+j';
2121
const getPasteKey = () => {
2222
if (process.platform === 'win32') return 'alt+v';
23-
return process.platform === 'darwin' ? 'cmd+v' : 'ctrl+v';
23+
// macOS terminals (iTerm2, Terminal.app) intercept Cmd+V for text paste, so
24+
// advertise Ctrl+V and Option+V, which reach the CLI as image-paste keys.
25+
return process.platform === 'darwin' ? 'ctrl+v / option+v' : 'ctrl+v';
2426
};
2527
const getExternalEditorKey = () =>
2628
process.platform === 'darwin' ? 'ctrl+x' : 'ctrl+x';

packages/cli/src/ui/contexts/KeypressContext.test.tsx

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,96 @@ describe('KeypressContext - Kitty Protocol', () => {
219219
}
220220
});
221221

222+
it('rewrites macOS composed Option+v glyph "√" to Alt+v', () => {
223+
const originalPlatform = process.platform;
224+
Object.defineProperty(process, 'platform', {
225+
value: 'darwin',
226+
configurable: true,
227+
writable: true,
228+
});
229+
try {
230+
const keyHandler = vi.fn();
231+
232+
const { result } = renderHook(() => useKeypressContext(), {
233+
wrapper,
234+
});
235+
236+
act(() => {
237+
result.current.subscribe(keyHandler);
238+
});
239+
240+
act(() => {
241+
stdin.pressKey({
242+
name: '',
243+
ctrl: false,
244+
meta: false,
245+
shift: false,
246+
paste: false,
247+
sequence: '√',
248+
});
249+
});
250+
251+
expect(keyHandler).toHaveBeenCalledWith(
252+
expect.objectContaining({
253+
name: 'v',
254+
meta: true,
255+
sequence: '√',
256+
}),
257+
);
258+
} finally {
259+
Object.defineProperty(process, 'platform', {
260+
value: originalPlatform,
261+
configurable: true,
262+
writable: true,
263+
});
264+
}
265+
});
266+
267+
it('leaves "√" untouched on non-macOS platforms', () => {
268+
const originalPlatform = process.platform;
269+
Object.defineProperty(process, 'platform', {
270+
value: 'linux',
271+
configurable: true,
272+
writable: true,
273+
});
274+
try {
275+
const keyHandler = vi.fn();
276+
277+
const { result } = renderHook(() => useKeypressContext(), {
278+
wrapper,
279+
});
280+
281+
act(() => {
282+
result.current.subscribe(keyHandler);
283+
});
284+
285+
act(() => {
286+
stdin.pressKey({
287+
name: '',
288+
ctrl: false,
289+
meta: false,
290+
shift: false,
291+
paste: false,
292+
sequence: '√',
293+
});
294+
});
295+
296+
expect(keyHandler).toHaveBeenCalledWith(
297+
expect.objectContaining({
298+
name: '',
299+
meta: false,
300+
sequence: '√',
301+
}),
302+
);
303+
} finally {
304+
Object.defineProperty(process, 'platform', {
305+
value: originalPlatform,
306+
configurable: true,
307+
writable: true,
308+
});
309+
}
310+
});
311+
222312
it('should recognize regular enter key (keycode 13) in kitty protocol', async () => {
223313
const keyHandler = vi.fn();
224314

packages/cli/src/ui/contexts/KeypressContext.tsx

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,18 @@ import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js';
5050

5151
const ESC = '\u001B';
5252
// On macOS, when the terminal's Option key is in its default "compose
53-
// character" mode (iTerm2 "Normal", VS Code without macOptionIsMeta), Option+t
54-
// is delivered to the app as the dagger glyph "†" (U+2020) with no modifier
55-
// metadata — so there is no way to tell Option was held. Terminals that speak
56-
// the Kitty keyboard protocol (e.g. Ghostty) instead report a real Alt+t event,
57-
// which is why the shortcut already works there. We treat a lone "†" as Alt+t so
58-
// the "expand thinking" shortcut works everywhere without requiring users to
59-
// reconfigure their terminal. See handleKeypress for where this is applied.
60-
const OPTION_T_COMPOSED_GLYPH = '†';
53+
// character" mode (Terminal.app, iTerm2 "Normal", VS Code without
54+
// macOptionIsMeta), Option+<key> is delivered to the app as a bare glyph with
55+
// no modifier metadata — so there is no way to tell Option was held. Terminals
56+
// that speak the Kitty keyboard protocol (e.g. Ghostty, WezTerm) instead report
57+
// a real Alt event, which is why those shortcuts already work there. We rewrite
58+
// each lone glyph to its synthetic Alt equivalent so the shortcuts work
59+
// everywhere without requiring users to reconfigure their terminal. See
60+
// handleKeypress for where this is applied.
61+
const OPTION_COMPOSED_GLYPHS: Record<string, string> = {
62+
'†': 't', // Option+t → "expand thinking"
63+
'√': 'v', // Option+v → paste clipboard image
64+
};
6165
export const PASTE_MODE_PREFIX = `${ESC}[200~`;
6266
export const PASTE_MODE_SUFFIX = `${ESC}[201~`;
6367
export const DRAG_COMPLETION_TIMEOUT_MS = 100; // Broadcast full path after 100ms if no more input
@@ -1180,18 +1184,19 @@ export function KeypressProvider({
11801184
key.meta = true;
11811185
}
11821186

1183-
// macOS "Option as compose character" terminals turn Option+t into the
1184-
// bare glyph "†" (U+2020) with no modifier metadata. Rewrite it to a
1185-
// synthetic Alt+t so the "expand thinking" shortcut fires; the meta flag
1186-
// also stops the glyph from being inserted into the input buffer (the
1187-
// text buffer skips printable input when meta/ctrl is set), so it looks
1188-
// exactly like Alt was pressed.
1189-
if (
1190-
process.platform === 'darwin' &&
1191-
!isPaste &&
1192-
key.sequence === OPTION_T_COMPOSED_GLYPH
1193-
) {
1194-
key.name = 't';
1187+
// macOS "Option as compose character" terminals turn Option+<key> into a
1188+
// bare glyph with no modifier metadata. Rewrite it to a synthetic Alt so
1189+
// the shortcut fires; the meta flag also stops the glyph from being
1190+
// inserted into the input buffer (the text buffer skips printable input
1191+
// when meta/ctrl is set), so it looks exactly like Alt was pressed.
1192+
const composedKeyName = Object.hasOwn(
1193+
OPTION_COMPOSED_GLYPHS,
1194+
key.sequence,
1195+
)
1196+
? OPTION_COMPOSED_GLYPHS[key.sequence]
1197+
: undefined;
1198+
if (process.platform === 'darwin' && !isPaste && composedKeyName) {
1199+
key.name = composedKeyName;
11951200
key.meta = true;
11961201
}
11971202

0 commit comments

Comments
 (0)