Skip to content

Commit 0e26259

Browse files
committed
feat(cli): add Ctrl+Y shortcut to copy input text to clipboard
1 parent e266a24 commit 0e26259

9 files changed

Lines changed: 381 additions & 2 deletions

File tree

‎README-en.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ Skills are discovered from these locations, in priority order:
9292
| `Enter` | Send the prompt |
9393
| `Shift+Enter` | Insert a newline (also `Ctrl+J`) |
9494
| `Ctrl+V` | Paste an image from the clipboard (`Alt+V` on Windows) |
95+
| `Ctrl+Y` | Copy input text to clipboard |
9596
| `Ctrl+R` | Search user input in the current and previous sessions |
9697
| `Esc` | Interrupt the current model turn |
9798
| `Ctrl+D` twice | Quit Deep Code |

‎README-zh_CN.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ Skills 会按以下优先级扫描:
9191
| `Enter` | 发送消息 |
9292
| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) |
9393
| `Ctrl+V` | 从剪贴板粘贴图片(Windows系统是`Alt+V`) |
94+
| `Ctrl+Y` | 将输入框中的文本复制到剪贴板 |
9495
| `Ctrl+R` | 搜索当前及历史会话中的用户输入 |
9596
| `Esc` | 中断当前模型回复 |
9697
| 连续 `Ctrl+D` | 退出 |

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ Skills 会按以下优先级扫描:
9191
| `Enter` | 发送消息 |
9292
| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) |
9393
| `Ctrl+V` | 从剪贴板粘贴图片(Windows系统是`Alt+V`) |
94+
| `Ctrl+Y` | 将输入框中的文本复制到剪贴板 |
9495
| `Ctrl+R` | 搜索当前及历史会话中的用户输入 |
9596
| `Esc` | 中断当前模型回复 |
9697
| 连续 `Ctrl+D` | 退出 |

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ const EPILOG = [
6363
" ctrl+w Delete the previous word",
6464
` ${getImagePasteShortcutLabel().toLowerCase().padEnd(17)}Paste an image from the clipboard`,
6565
" ctrl+x Clear pasted images",
66+
" ctrl+y Copy input text to clipboard",
6667
" esc Interrupt the current model turn",
6768
" / Open the skills/commands menu",
6869
" /skills List available skills",
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import assert from "node:assert/strict";
2+
import childProcess from "node:child_process";
3+
import { EventEmitter } from "node:events";
4+
import { syncBuiltinESMExports } from "node:module";
5+
import { PassThrough } from "node:stream";
6+
import { test, type TestContext } from "node:test";
7+
import { writeClipboardTextAsync } from "../ui/core/clipboard";
8+
9+
type Outcome = "success" | "failure" | "missing" | "pipe-error" | "timeout" | "throw";
10+
11+
function mockWriter(t: TestContext, platform: NodeJS.Platform, outcomes: Outcome[] = ["success"]) {
12+
const descriptor = Object.getOwnPropertyDescriptor(process, "platform")!;
13+
Object.defineProperty(process, "platform", { value: platform });
14+
const calls: { command: string; args: string[]; text: string; killed: boolean }[] = [];
15+
t.mock.method(childProcess, "spawn", (command: string, args: string[]) => {
16+
const outcome = outcomes[calls.length] ?? "failure";
17+
const call = { command, args, text: "", killed: false };
18+
calls.push(call);
19+
if (outcome === "throw") throw new Error("spawn failed");
20+
const stdin = new PassThrough();
21+
stdin.on("data", (chunk: Buffer) => (call.text += chunk.toString("utf8")));
22+
const child = Object.assign(new EventEmitter(), {
23+
stdin,
24+
kill(signal: string) {
25+
assert.equal(signal, "SIGKILL");
26+
call.killed = true;
27+
return true;
28+
},
29+
});
30+
queueMicrotask(() => {
31+
if (outcome === "missing") child.emit("error", new Error("ENOENT"));
32+
else if (outcome === "pipe-error") stdin.emit("error", new Error("EPIPE"));
33+
else if (outcome !== "timeout") child.emit("exit", outcome === "success" ? 0 : 1);
34+
});
35+
return child;
36+
});
37+
syncBuiltinESMExports();
38+
t.after(() => {
39+
Object.defineProperty(process, "platform", descriptor);
40+
t.mock.restoreAll();
41+
syncBuiltinESMExports();
42+
});
43+
return calls;
44+
}
45+
46+
const TEXT = " 中文 😀\n'quoted' `literal` $(literal)\r\nlast line\n";
47+
48+
for (const [platform, command] of [
49+
["darwin", "pbcopy"],
50+
["linux", "wl-copy"],
51+
["win32", "powershell"],
52+
] as const) {
53+
test(`${platform}: copies exact UTF-8 text through stdin`, async (t) => {
54+
const calls = mockWriter(t, platform);
55+
assert.equal(await writeClipboardTextAsync(TEXT), true);
56+
assert.equal(calls.length, 1);
57+
assert.equal(calls[0].command, command);
58+
assert.equal(calls[0].text, TEXT);
59+
assert.ok(!calls[0].args.join(" ").includes(TEXT));
60+
assert.equal(calls[0].killed, false);
61+
if (platform === "win32") {
62+
assert.match(calls[0].args.at(-1)!, /UTF8Encoding/);
63+
assert.match(calls[0].args.at(-1)!, /ReadToEnd/);
64+
}
65+
});
66+
}
67+
68+
for (const outcome of ["failure", "missing", "pipe-error", "throw"] as const) {
69+
test(`clipboard ${outcome} returns false`, async (t) => {
70+
mockWriter(t, "darwin", [outcome]);
71+
assert.equal(await writeClipboardTextAsync(TEXT), false);
72+
});
73+
test(`Linux falls back to xclip after ${outcome}`, async (t) => {
74+
const calls = mockWriter(t, "linux", [outcome, "success"]);
75+
assert.equal(await writeClipboardTextAsync(TEXT), true);
76+
assert.deepEqual(
77+
calls.map((call) => call.command),
78+
["wl-copy", "xclip"]
79+
);
80+
assert.deepEqual(calls[1].args, ["-selection", "clipboard", "-in"]);
81+
assert.equal(calls[1].text, TEXT);
82+
});
83+
}
84+
85+
test("clipboard timeout kills the helper and returns false", async (t) => {
86+
const calls = mockWriter(t, "darwin", ["timeout"]);
87+
t.mock.timers.enable({ apis: ["setTimeout"] });
88+
const copying = writeClipboardTextAsync(TEXT);
89+
t.mock.timers.tick(3000);
90+
assert.equal(await copying, false);
91+
assert.equal(calls[0].killed, true);
92+
});
93+
94+
test("Linux reports failure when both clipboard helpers are missing", async (t) => {
95+
const calls = mockWriter(t, "linux", ["missing", "missing"]);
96+
assert.equal(await writeClipboardTextAsync(TEXT), false);
97+
assert.equal(calls.length, 2);
98+
});
99+
100+
test("empty input leaves the clipboard untouched", async (t) => {
101+
const calls = mockWriter(t, "darwin");
102+
assert.equal(await writeClipboardTextAsync(""), false);
103+
assert.equal(calls.length, 0);
104+
assert.equal(await writeClipboardTextAsync(" \n"), true);
105+
assert.equal(calls[0].text, " \n");
106+
});
107+
108+
test("unsupported platforms report failure without spawning", async (t) => {
109+
const calls = mockWriter(t, "freebsd");
110+
assert.equal(await writeClipboardTextAsync(TEXT), false);
111+
assert.equal(calls.length, 0);
112+
});
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import assert from "node:assert/strict";
2+
import childProcess from "node:child_process";
3+
import { EventEmitter } from "node:events";
4+
import fs from "node:fs";
5+
import os from "node:os";
6+
import path from "node:path";
7+
import { syncBuiltinESMExports } from "node:module";
8+
import { PassThrough, Writable } from "node:stream";
9+
import { test, type TestContext } from "node:test";
10+
import { setTimeout } from "node:timers/promises";
11+
import React from "react";
12+
import { render } from "ink";
13+
import { PromptInput, type PromptDraft, type PromptSubmission } from "../ui/views/PromptInput";
14+
import { parseTerminalInput } from "../ui/hooks";
15+
import { stripTerminalControls } from "./terminal-output";
16+
17+
async function harness(t: TestContext, busy = false) {
18+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-copy-input-"));
19+
const frames: string[] = [];
20+
const output = Object.assign(
21+
new Writable({
22+
write(chunk, _encoding, done) {
23+
const text = stripTerminalControls(chunk.toString());
24+
if (text.trim()) frames.push(text);
25+
done();
26+
},
27+
}),
28+
{ columns: 100, rows: 30, isTTY: true }
29+
);
30+
const input = Object.assign(new PassThrough(), { isTTY: true, setRawMode() {}, ref() {}, unref() {} });
31+
const copies: { text: string; finish: (code: number) => void }[] = [];
32+
let holdCopy = false;
33+
t.mock.method(os, "homedir", () => root);
34+
t.mock.method(childProcess, "spawn", () => {
35+
const stdin = new PassThrough();
36+
const child = Object.assign(new EventEmitter(), { stdin, kill: () => true });
37+
const copy = { text: "", finish: (code: number) => void child.emit("exit", code) };
38+
stdin.on("data", (chunk: Buffer) => (copy.text += chunk.toString("utf8")));
39+
copies.push(copy);
40+
if (!holdCopy) queueMicrotask(() => copy.finish(0));
41+
return child;
42+
});
43+
syncBuiltinESMExports();
44+
const submissions: PromptSubmission[] = [];
45+
let props = {
46+
projectRoot: root,
47+
screenWidth: 100,
48+
skills: [],
49+
modelConfig: { model: "test", thinkingEnabled: false, reasoningEffort: "high" as const },
50+
promptHistory: ["previous prompt"],
51+
busy,
52+
disabled: false,
53+
promptDraft: undefined as PromptDraft | undefined,
54+
planMode: false,
55+
onSubmit: (submission: PromptSubmission) => void submissions.push(submission),
56+
onPromptDraftApplied() {},
57+
onModelConfigChange: () => "",
58+
onPermissionSettingsChange: () => "",
59+
onPlanModeChange() {},
60+
onInterrupt() {},
61+
};
62+
const app = render(React.createElement(PromptInput, props), {
63+
stdin: input as unknown as NodeJS.ReadStream,
64+
stdout: output as unknown as NodeJS.WriteStream,
65+
stderr: output as unknown as NodeJS.WriteStream,
66+
debug: true,
67+
patchConsole: false,
68+
exitOnCtrlC: false,
69+
});
70+
t.after(() => {
71+
app.unmount();
72+
app.cleanup();
73+
t.mock.restoreAll();
74+
syncBuiltinESMExports();
75+
fs.rmSync(root, { recursive: true, force: true });
76+
});
77+
async function flush() {
78+
await setTimeout(80);
79+
await app.waitUntilRenderFlush();
80+
}
81+
await flush();
82+
return {
83+
copies,
84+
submissions,
85+
flush,
86+
latest: () => frames.at(-1) ?? "",
87+
holdCopy: () => (holdCopy = true),
88+
async send(text: string) {
89+
input.write(text);
90+
await flush();
91+
},
92+
async update(changes: Partial<typeof props>) {
93+
props = { ...props, ...changes };
94+
app.rerender(React.createElement(PromptInput, props));
95+
await flush();
96+
},
97+
};
98+
}
99+
100+
test("raw Ctrl+Y is parsed as the copy shortcut", () => {
101+
const { input, key } = parseTerminalInput("\x19");
102+
assert.equal(input, "y");
103+
assert.equal(key.ctrl, true);
104+
assert.equal(key.meta, false);
105+
assert.equal(key.paste, false);
106+
});
107+
108+
test("Ctrl+Y preserves input, cursor and undo while the model is busy", async (t) => {
109+
const h = await harness(t, true);
110+
await h.send("hello");
111+
await h.send("\x1b[D");
112+
await h.send("!");
113+
await h.send("\x19");
114+
assert.equal(h.copies[0].text, "hell!o");
115+
assert.match(h.latest(), /Copied to clipboard/);
116+
assert.equal(h.submissions.length, 0);
117+
await h.send("?");
118+
assert.match(h.latest(), /hell!\?o/);
119+
await h.send("\x1b[45;5u");
120+
await h.send("\x1b[45;5u");
121+
await h.update({ busy: false });
122+
await h.send("\r");
123+
assert.equal(h.submissions[0].text, "hello");
124+
});
125+
126+
test("Ctrl+Y expands pasted text and excludes attached images", async (t) => {
127+
const h = await harness(t);
128+
const original = " 中文 😀\n".repeat(100);
129+
await h.update({ promptDraft: { nonce: 1, text: "prefix ", imageUrls: ["data:image/png;base64,test"] } });
130+
await h.send(`\x1b[200~${original}\x1b[201~`);
131+
assert.match(h.latest(), /paste #1/);
132+
await h.send("\x19");
133+
assert.equal(h.copies[0].text, "prefix " + original);
134+
assert.match(h.latest(), /paste #1/);
135+
assert.match(h.latest(), /1 image/);
136+
await h.send("\r");
137+
assert.equal(h.submissions[0].text, h.copies[0].text);
138+
assert.deepEqual(h.submissions[0].imageUrls, ["data:image/png;base64,test"]);
139+
});
140+
141+
test("empty, search and modal inputs do not copy", async (t) => {
142+
const h = await harness(t);
143+
await h.send("\x19");
144+
assert.equal(h.copies.length, 0, "empty input");
145+
await h.send("draft");
146+
await h.send("\x12");
147+
await h.send("\x19");
148+
assert.equal(h.copies.length, 0, "history search");
149+
assert.match(h.latest(), /reverse-i-search/);
150+
await h.send("\x1b");
151+
await h.update({ promptDraft: { nonce: 2, text: "/skills", imageUrls: [] } });
152+
await h.send("\r");
153+
await h.send("\x19");
154+
assert.equal(h.copies.length, 0);
155+
});
156+
157+
test("disabled input does not copy", async (t) => {
158+
const h = await harness(t);
159+
await h.send("draft");
160+
await h.update({ disabled: true });
161+
await h.send("\x19");
162+
assert.equal(h.copies.length, 0);
163+
});
164+
165+
test("Alt combinations and bracketed paste do not trigger copying", async (t) => {
166+
const h = await harness(t);
167+
await h.send("draft");
168+
await h.send("\x1b[121;7u");
169+
await h.send("\x1b[200~\x19\x1b[201~");
170+
assert.equal(h.copies.length, 0);
171+
});
172+
173+
test("copy uses a snapshot and ignores repeated presses until completion", async (t) => {
174+
const h = await harness(t);
175+
h.holdCopy();
176+
await h.send("before");
177+
await h.send("\x19\x19");
178+
await h.send(" after");
179+
await h.send("\x19");
180+
assert.equal(h.copies.length, 1);
181+
assert.equal(h.copies[0].text, "before");
182+
h.copies[0].finish(0);
183+
await h.flush();
184+
await h.send("\x19");
185+
assert.equal(h.copies[1].text, "before after");
186+
h.copies[1].finish(0);
187+
await h.flush();
188+
});
189+
190+
test("copy failure shows a short status and preserves the draft", async (t) => {
191+
const h = await harness(t);
192+
h.holdCopy();
193+
await h.send("keep me");
194+
await h.send("\x19");
195+
h.copies[0].finish(1);
196+
await h.flush();
197+
if (process.platform === "linux") {
198+
h.copies[1].finish(1);
199+
await h.flush();
200+
}
201+
assert.match(h.latest(), /Failed to copy/);
202+
await h.send("\r");
203+
assert.equal(h.submissions[0].text, "keep me");
204+
});

0 commit comments

Comments
 (0)