Skip to content

Commit e266a24

Browse files
authored
Merge pull request #400 from lessweb/fix/windows-image-paste-shortcut
fix(cli): support Windows and WSL image paste shortcuts
2 parents c89ca91 + 47748a8 commit e266a24

11 files changed

Lines changed: 198 additions & 16 deletions

File tree

‎README-en.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ Skills are discovered from these locations, in priority order:
9191
|------------------|----------------------------------------------------------|
9292
| `Enter` | Send the prompt |
9393
| `Shift+Enter` | Insert a newline (also `Ctrl+J`) |
94-
| `Ctrl+V` | Paste an image from the clipboard |
94+
| `Ctrl+V` | Paste an image from the clipboard (`Alt+V` on Windows) |
9595
| `Ctrl+R` | Search user input in the current and previous sessions |
9696
| `Esc` | Interrupt the current model turn |
9797
| `Ctrl+D` twice | Quit Deep Code |
@@ -120,7 +120,7 @@ Yes. Deep Code offers a full-featured VSCode extension, available on the [VSCode
120120

121121
### Does Deep Code support understanding images?
122122

123-
Yes. The `deepseek-flash` model can read local images directly, or you can paste images from the clipboard with `Ctrl+V`, so the model can see the image content directly.
123+
Yes. The `deepseek-flash` model can read local images directly, or you can paste images from the clipboard with `Ctrl+V` (`Alt+V` on Windows), so the model can see the image content directly.
124124

125125
Non-multimodal models such as `deepseek-v4-pro` and `deepseek-v4-flash` continue to use the `UnderstandImage` image-understanding tool. Deep Code detects model capabilities automatically; you can also override the detection with the `multimodal` setting.
126126

‎README-zh_CN.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ Skills 会按以下优先级扫描:
9090
|---------------|--------------------|
9191
| `Enter` | 发送消息 |
9292
| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) |
93-
| `Ctrl+V` | 从剪贴板粘贴图片 |
93+
| `Ctrl+V` | 从剪贴板粘贴图片(Windows系统是`Alt+V`) |
9494
| `Ctrl+R` | 搜索当前及历史会话中的用户输入 |
9595
| `Esc` | 中断当前模型回复 |
9696
| 连续 `Ctrl+D` | 退出 |
@@ -119,7 +119,7 @@ Deep Code 的收益来自于工具约束、上下文管理、Agent Skills 和权
119119

120120
### Deep Code 是否支持理解图片?
121121

122-
支持。`deepseek-flash` 模型支持直接读取本地图片或使用`ctrl+v`从剪贴板粘贴图片,让模型直接看到图片内容。
122+
支持。`deepseek-flash` 模型支持直接读取本地图片或使用`ctrl+v`(Windows系统是`alt+v`)从剪贴板粘贴图片,让模型直接看到图片内容。
123123

124124
`deepseek-v4-pro`、`deepseek-v4-flash` 等非多模态模型仍会使用 `UnderstandImage` 识图工具。Deep Code 会自动判断模型能力,也可通过 `multimodal` 配置项手动覆盖。
125125

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ Skills 会按以下优先级扫描:
9090
|---------------|--------------------|
9191
| `Enter` | 发送消息 |
9292
| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) |
93-
| `Ctrl+V` | 从剪贴板粘贴图片 |
93+
| `Ctrl+V` | 从剪贴板粘贴图片(Windows系统是`Alt+V`) |
9494
| `Ctrl+R` | 搜索当前及历史会话中的用户输入 |
9595
| `Esc` | 中断当前模型回复 |
9696
| 连续 `Ctrl+D` | 退出 |
@@ -119,7 +119,7 @@ Deep Code 的收益来自于工具约束、上下文管理、Agent Skills 和权
119119

120120
### Deep Code 是否支持理解图片?
121121

122-
支持。`deepseek-flash` 模型支持直接读取本地图片或使用`ctrl+v`从剪贴板粘贴图片,让模型直接看到图片内容。
122+
支持。`deepseek-flash` 模型支持直接读取本地图片或使用`ctrl+v`(Windows系统是`alt+v`)从剪贴板粘贴图片,让模型直接看到图片内容。
123123

124124
`deepseek-v4-pro`、`deepseek-v4-flash` 等非多模态模型仍会使用 `UnderstandImage` 识图工具。Deep Code 会自动判断模型能力,也可通过 `multimodal` 配置项手动覆盖。
125125

‎docs/quickstart.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ deepcode -p "总结这个项目"
109109
| 发送消息 | `Enter` |
110110
| 输入多行 | `Shift+Enter` 或 `Ctrl+J` |
111111
| 中断当前回复 | `Esc` |
112-
| 粘贴图片 | `Ctrl+V` |
112+
| 粘贴图片 | `Ctrl+V` (macOS / Linux) 或 `Alt+V` (Windows) |
113113
| 退出 | 连续按两次 `Ctrl+D`,或使用 `/exit` |
114114

115115
## 斜杠命令

‎docs/quickstart_en.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ Before editing files, propose a plan for adding pagination to the user list.
109109
| Send message | `Enter` |
110110
| Insert a newline | `Shift+Enter` or `Ctrl+J` |
111111
| Interrupt the current response | `Esc` |
112-
| Paste an image | `Ctrl+V` |
112+
| Paste an image | `Ctrl+V` (macOS / Linux) or `Alt+V` (Windows) |
113113
| Quit | Press `Ctrl+D` twice, or use `/exit` |
114114

115115
## Slash Commands

‎packages/cli/src/cli-args.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { getCliVersion } from "./utils/version";
99
import { writeStderrLine } from "./utils/stdio-helpers";
1010
import { hideBin } from "yargs/helpers";
1111
import { isValidSessionId } from "@vegamo/deepcode-core/common/session-id";
12+
import { getImagePasteShortcutLabel } from "./utils/image-paste-shortcut";
1213

1314
export { isValidSessionId };
1415

@@ -60,7 +61,7 @@ const EPILOG = [
6061
" home/end Move within the current line",
6162
" alt+left/right Move by word",
6263
" ctrl+w Delete the previous word",
63-
" ctrl+v Paste an image from the clipboard",
64+
` ${getImagePasteShortcutLabel().toLowerCase().padEnd(17)}Paste an image from the clipboard`,
6465
" ctrl+x Clear pasted images",
6566
" esc Interrupt the current model turn",
6667
" / Open the skills/commands menu",
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import assert from "node:assert/strict";
2+
import os from "node:os";
3+
import { test, type TestContext } from "node:test";
4+
import React from "react";
5+
import { renderToString } from "ink";
6+
import { getImagePasteShortcutLabel, isImagePasteShortcut } from "../utils/image-paste-shortcut";
7+
import { dispatchTerminalInput, parseTerminalInput } from "../ui/hooks/useTerminalInput";
8+
import { PromptInput } from "../ui/views/PromptInput";
9+
import { buildWelcomeTips } from "../ui/views/WelcomeScreen";
10+
import { stripTerminalControls } from "./terminal-output";
11+
12+
const environments: {
13+
name: string;
14+
platform: NodeJS.Platform;
15+
release?: string;
16+
env?: NodeJS.ProcessEnv;
17+
label: string;
18+
ctrl: boolean;
19+
alt: boolean;
20+
}[] = [
21+
{ name: "win32", platform: "win32", label: "Alt+V", ctrl: false, alt: true },
22+
{ name: "darwin", platform: "darwin", label: "Ctrl+V", ctrl: true, alt: false },
23+
{ name: "linux", platform: "linux", label: "Ctrl+V", ctrl: true, alt: false },
24+
{
25+
name: "wsl-distro",
26+
platform: "linux",
27+
env: { WSL_DISTRO_NAME: "Ubuntu" },
28+
label: "Ctrl+V / Alt+V",
29+
ctrl: true,
30+
alt: true,
31+
},
32+
{
33+
name: "wsl-interop",
34+
platform: "linux",
35+
env: { WSL_INTEROP: "/run/WSL/123_interop" },
36+
label: "Ctrl+V / Alt+V",
37+
ctrl: true,
38+
alt: true,
39+
},
40+
{
41+
name: "wsl1-kernel",
42+
platform: "linux",
43+
release: "4.4.0-19041-Microsoft",
44+
label: "Ctrl+V / Alt+V",
45+
ctrl: true,
46+
alt: true,
47+
},
48+
{
49+
name: "wsl2-kernel",
50+
platform: "linux",
51+
release: "6.6.87.2-microsoft-standard-WSL2",
52+
label: "Ctrl+V / Alt+V",
53+
ctrl: true,
54+
alt: true,
55+
},
56+
{
57+
name: "win32-with-wsl-env",
58+
platform: "win32",
59+
env: { WSL_DISTRO_NAME: "Ubuntu" },
60+
label: "Alt+V",
61+
ctrl: false,
62+
alt: true,
63+
},
64+
];
65+
66+
function mockEnvironment(t: TestContext, environment: (typeof environments)[number]): void {
67+
const descriptor = Object.getOwnPropertyDescriptor(process, "platform")!;
68+
Object.defineProperty(process, "platform", { value: environment.platform });
69+
t.after(() => Object.defineProperty(process, "platform", descriptor));
70+
t.mock.method(os, "release", () => environment.release ?? "6.8.0-generic");
71+
for (const key of ["WSL_DISTRO_NAME", "WSL_INTEROP"]) {
72+
const original = process.env[key];
73+
const value = environment.env?.[key];
74+
if (value === undefined) delete process.env[key];
75+
else process.env[key] = value;
76+
t.after(() => {
77+
if (original === undefined) delete process.env[key];
78+
else process.env[key] = original;
79+
});
80+
}
81+
}
82+
83+
for (const environment of environments) {
84+
const { name, platform, label, ctrl, alt } = environment;
85+
86+
test(`${name}: only the image paste shortcuts trigger image reading`, (t) => {
87+
mockEnvironment(t, environment);
88+
for (const [raw, expected] of [
89+
["\u001bv", alt],
90+
["\u001bV", alt],
91+
["\u0016", ctrl],
92+
["v", false],
93+
["V", false],
94+
["\u001bx", false],
95+
["\u0018", false],
96+
["pasted text", false],
97+
] as const) {
98+
const events: boolean[] = [];
99+
dispatchTerminalInput(raw, (input, key) => events.push(isImagePasteShortcut(input, key, platform)));
100+
assert.deepEqual(events, [expected], JSON.stringify(raw));
101+
}
102+
103+
const shortcut = parseTerminalInput(platform === "win32" ? "\u001bv" : "\u0016");
104+
assert.equal(isImagePasteShortcut(shortcut.input, { ...shortcut.key, paste: true }, platform), false);
105+
assert.equal(isImagePasteShortcut("v", { ctrl: true, meta: true, paste: false }, platform), false);
106+
assert.equal(getImagePasteShortcutLabel(platform), label);
107+
});
108+
109+
test(`${name}: help, welcome tips and prompt footer show ${label}`, async (t) => {
110+
mockEnvironment(t, environment);
111+
112+
assert.equal(getImagePasteShortcutLabel(), label);
113+
const shortcut = parseTerminalInput(platform === "win32" ? "\u001bv" : "\u0016");
114+
assert.equal(isImagePasteShortcut(shortcut.input, shortcut.key), true);
115+
116+
const tips = buildWelcomeTips([]);
117+
assert.equal(tips.find((tip) => tip.description === "Paste an image from the clipboard")?.label, label);
118+
119+
const footer = stripTerminalControls(
120+
renderToString(
121+
React.createElement(PromptInput, {
122+
projectRoot: process.cwd(),
123+
screenWidth: 160,
124+
skills: [],
125+
modelConfig: { model: "test", thinkingEnabled: false, reasoningEffort: "high" },
126+
promptHistory: [],
127+
busy: false,
128+
disabled: true,
129+
planMode: false,
130+
onSubmit() {},
131+
onPromptDraftApplied() {},
132+
onModelConfigChange: () => "",
133+
onPermissionSettingsChange: () => "",
134+
onPlanModeChange() {},
135+
onInterrupt() {},
136+
}),
137+
{ columns: 160 }
138+
)
139+
);
140+
assert.ok(footer.includes(`${label.toLowerCase()} image`), footer);
141+
if (!ctrl) assert.ok(!footer.includes("ctrl+v image"), footer);
142+
if (!alt) assert.ok(!footer.includes("alt+v image"), footer);
143+
144+
// Reload the module so its static help text uses the simulated platform.
145+
const moduleUrl = new URL(`../cli-args.ts?image-paste-platform=${name}`, import.meta.url).href;
146+
const { parseArguments } = await import(moduleUrl);
147+
const output: string[] = [];
148+
t.mock.method(console, "log", (...args: unknown[]) => output.push(args.join(" ")));
149+
await parseArguments(["--help"]);
150+
const help = output.join("\n");
151+
assert.ok(help.includes(`${label.toLowerCase().padEnd(17)}Paste an image from the clipboard`), help);
152+
if (!ctrl) assert.ok(!help.includes(`${"ctrl+v".padEnd(17)}Paste an image from the clipboard`), help);
153+
if (!alt) assert.ok(!help.includes(`${"alt+v".padEnd(17)}Paste an image from the clipboard`), help);
154+
});
155+
}

‎packages/cli/src/ui/views/PromptInput.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
} from "../core/file-mentions";
5252
import type { FileMentionItem } from "../core/file-mentions";
5353
import { readClipboardImageAsync } from "../core/clipboard";
54+
import { getImagePasteShortcutLabel, isImagePasteShortcut } from "../../utils/image-paste-shortcut";
5455
import {
5556
useTerminalInput,
5657
usePasteHandling,
@@ -242,7 +243,7 @@ export const PromptInput = React.memo(function PromptInput({
242243
? statusMessage
243244
: busy
244245
? busyStatusText
245-
: `enter send · shift+enter newline · @ files · ctrl+v image · / commands · ctrl+d exit${pasteHint}`;
246+
: `enter send · shift+enter newline · @ files · ${getImagePasteShortcutLabel().toLowerCase()} image · / commands · ctrl+d exit${pasteHint}`;
246247
const showFooterText = useMemo(
247248
() =>
248249
showMenu ||
@@ -465,7 +466,7 @@ export const PromptInput = React.memo(function PromptInput({
465466
return;
466467
}
467468

468-
if (key.ctrl && (input === "v" || input === "V")) {
469+
if (isImagePasteShortcut(input, key)) {
469470
setStatusMessage("Reading clipboard...");
470471
readClipboardImageAsync()
471472
.then((image) => {

‎packages/cli/src/ui/views/WelcomeScreen.tsx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ThemedGradient } from "./ThemedGradient";
77
import { AsciiLogo } from "../ascii-art";
88
import { useAppContext } from "../contexts";
99
import { formatHomeRelativePath } from "../core/display-path";
10+
import { getImagePasteShortcutLabel } from "../../utils/image-paste-shortcut";
1011

1112
export { formatHomeRelativePath } from "../core/display-path";
1213

@@ -20,10 +21,10 @@ type WelcomeScreenProps = {
2021
const TITLE_PANEL_WIDTH = 70;
2122
const PANEL_CONTENT_HEIGHT = 8;
2223

23-
const SHORTCUT_TIPS = [
24+
const shortcutTips = () => [
2425
{ label: "Enter", description: "Send the prompt" },
2526
{ label: "Shift+Enter", description: "Insert a newline" },
26-
{ label: "Ctrl+V", description: "Paste an image from the clipboard" },
27+
{ label: getImagePasteShortcutLabel(), description: "Paste an image from the clipboard" },
2728
{ label: "Ctrl+R", description: "Search prompt history" },
2829
{ label: "Esc", description: "Interrupt the current model turn" },
2930
{ label: "/", description: "Open the skills and commands menu" },
@@ -110,7 +111,7 @@ export function buildWelcomeTips(skills: SkillInfo[]): Array<{ label: string; de
110111

111112
return [
112113
...slashTips,
113-
...SHORTCUT_TIPS.filter((tip) => !BUILTIN_SLASH_COMMANDS.some((command) => command.label === tip.label)),
114+
...shortcutTips().filter((tip) => !BUILTIN_SLASH_COMMANDS.some((command) => command.label === tip.label)),
114115
];
115116
}
116117

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import os from "node:os";
2+
import type { InputKey } from "../ui/hooks/useTerminalInput";
3+
4+
function isWsl(platform: NodeJS.Platform): boolean {
5+
return (
6+
platform === "linux" &&
7+
(Boolean(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) || /microsoft/i.test(os.release()))
8+
);
9+
}
10+
11+
export function getImagePasteShortcutLabel(platform: NodeJS.Platform = process.platform): string {
12+
if (platform === "win32") return "Alt+V";
13+
return isWsl(platform) ? "Ctrl+V / Alt+V" : "Ctrl+V";
14+
}
15+
16+
export function isImagePasteShortcut(
17+
input: string,
18+
key: Pick<InputKey, "ctrl" | "meta" | "paste">,
19+
platform: NodeJS.Platform = process.platform
20+
): boolean {
21+
if (key.paste || (input !== "v" && input !== "V")) return false;
22+
if (platform === "win32") return key.meta && !key.ctrl;
23+
return (key.ctrl && !key.meta) || (key.meta && !key.ctrl && isWsl(platform));
24+
}

0 commit comments

Comments
 (0)