Skip to content

Commit c05ad5f

Browse files
committed
feat(cli): paste clipboard HTML as Markdown with GFM tables
1 parent a71fbb4 commit c05ad5f

11 files changed

Lines changed: 384 additions & 27 deletions

‎package-lock.json‎

Lines changed: 35 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/cli/package.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,13 @@
4343
"read-package-up": "^12.0.0",
4444
"sharp": "^0.35.5",
4545
"string-width": "^8.2.1",
46+
"turndown": "^7.2.5",
47+
"turndown-plugin-gfm": "^1.0.2",
4648
"wrap-ansi": "^10.0.0",
4749
"yargs": "^18.0.0"
4850
},
4951
"devDependencies": {
52+
"@types/turndown": "^5.0.6",
5053
"@types/yargs": "^17.0.35"
5154
}
5255
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +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";
12+
import { getClipboardPasteShortcutLabel } from "./utils/clipboard-paste-shortcut";
1313

1414
export { isValidSessionId };
1515

@@ -61,7 +61,7 @@ const EPILOG = [
6161
" home/end Move within the current line",
6262
" alt+left/right Move by word",
6363
" ctrl+w Delete the previous word",
64-
` ${getImagePasteShortcutLabel().toLowerCase().padEnd(17)}Paste an image from the clipboard`,
64+
` ${getClipboardPasteShortcutLabel().toLowerCase().padEnd(17)}Paste an image or rich text from the clipboard`,
6565
" ctrl+x Clear pasted images",
6666
" ctrl+y Copy input text to clipboard",
6767
" esc Interrupt the current model turn",
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import assert from "node:assert/strict";
2+
import childProcess from "node:child_process";
3+
import { syncBuiltinESMExports } from "node:module";
4+
import { test, type TestContext } from "node:test";
5+
import { readClipboardHtmlAsync } from "../ui/core/clipboard";
6+
7+
function mockReader(t: TestContext, platform: NodeJS.Platform, outputs: (string | Error)[]) {
8+
const descriptor = Object.getOwnPropertyDescriptor(process, "platform")!;
9+
Object.defineProperty(process, "platform", { value: platform });
10+
const calls: { command: string; args: string[]; options: childProcess.ExecFileOptions }[] = [];
11+
t.mock.method(
12+
childProcess,
13+
"execFile",
14+
(
15+
command: string,
16+
args: string[],
17+
options: childProcess.ExecFileOptions,
18+
callback: (error: Error | null, stdout: string) => void
19+
) => {
20+
const output = outputs[calls.length] ?? new Error("missing helper");
21+
calls.push({ command, args, options });
22+
queueMicrotask(() => callback(output instanceof Error ? output : null, typeof output === "string" ? output : ""));
23+
}
24+
);
25+
syncBuiltinESMExports();
26+
t.after(() => {
27+
Object.defineProperty(process, "platform", descriptor);
28+
t.mock.restoreAll();
29+
syncBuiltinESMExports();
30+
});
31+
return calls;
32+
}
33+
34+
const HTML = '<p>中文 😀 <a href="https://example.com">链接</a></p>';
35+
36+
test("macOS explicitly reads public.html via AppKit", async (t) => {
37+
const calls = mockReader(t, "darwin", [HTML]);
38+
assert.equal(await readClipboardHtmlAsync(), HTML);
39+
assert.equal(calls[0].command, "osascript");
40+
assert.deepEqual(calls[0].args.slice(0, 3), ["-l", "JavaScript", "-e"]);
41+
assert.match(calls[0].args[3], /NSPasteboard\.generalPasteboard\.stringForType\("public.html"\)/);
42+
});
43+
44+
test("Windows extracts the selected fragment using UTF-8 byte offsets", async (t) => {
45+
const template =
46+
"Version:1.0\r\nStartHTML:0000000000\r\nEndHTML:0000000000\r\nStartFragment:0000000000\r\nEndFragment:0000000000\r\n";
47+
const prefix = "<html><body>未选中的中文<!--StartFragment-->";
48+
const suffix = "<!--EndFragment-->未选中的尾部</body></html>";
49+
const startHTML = Buffer.byteLength(template);
50+
const startFragment = startHTML + Buffer.byteLength(prefix);
51+
const endFragment = startFragment + Buffer.byteLength(HTML);
52+
const header = template
53+
.replace(/StartHTML:\d+/, `StartHTML:${String(startHTML).padStart(10, "0")}`)
54+
.replace(/EndHTML:\d+/, `EndHTML:${String(endFragment + Buffer.byteLength(suffix)).padStart(10, "0")}`)
55+
.replace(/StartFragment:\d+/, `StartFragment:${String(startFragment).padStart(10, "0")}`)
56+
.replace(/EndFragment:\d+/, `EndFragment:${String(endFragment).padStart(10, "0")}`);
57+
const calls = mockReader(t, "win32", [header + prefix + HTML + suffix + "\0"]);
58+
assert.equal(await readClipboardHtmlAsync(), HTML);
59+
assert.equal(calls[0].command, "powershell");
60+
assert.ok(calls[0].args.includes("-STA"));
61+
assert.match(calls[0].args.at(-1)!, /UTF8Encoding/);
62+
assert.match(calls[0].args.at(-1)!, /TextDataFormat\]::Html/);
63+
});
64+
65+
for (const [name, value, expected] of [
66+
[
67+
"offsets inside the header",
68+
`Version:1.0\r\nStartFragment:0\r\nEndFragment:11\r\n<!--StartFragment-->${HTML}<!--EndFragment-->`,
69+
HTML,
70+
],
71+
["missing offsets", `Version:1.0\r\n<html>before<!--StartFragment-->${HTML}<!--EndFragment-->after</html>`, HTML],
72+
[
73+
"invalid offsets",
74+
`Version:1.0\r\nStartFragment:9000\r\nEndFragment:9999\r\n<!--StartFragment-->${HTML}<!--EndFragment-->`,
75+
HTML,
76+
],
77+
[
78+
"reversed offsets",
79+
`Version:1.0\r\nStartFragment:100\r\nEndFragment:20\r\n<!--StartFragment-->${HTML}<!--EndFragment-->`,
80+
HTML,
81+
],
82+
[
83+
"HTML fallback",
84+
`Version:1.0\r\nStartHTML:-1\r\nEndHTML:-1\r\n<html><body>${HTML}</body></html>\0`,
85+
`<html><body>${HTML}</body></html>`,
86+
],
87+
["raw HTML", HTML, HTML],
88+
["header only", "Version:1.0\r\nStartHTML:-1\r\nEndHTML:-1\r\n", null],
89+
["empty fragment", "Version:1.0\r\n<!--StartFragment--><!--EndFragment-->", null],
90+
] as const) {
91+
test(`Windows handles ${name}`, async (t) => {
92+
mockReader(t, "win32", [value]);
93+
assert.equal(await readClipboardHtmlAsync(), expected);
94+
});
95+
}
96+
97+
test("Windows falls back to the HTML byte range when fragment offsets and markers are absent", async (t) => {
98+
const header = "Version:1.0\r\nStartHTML:0000000000\r\nEndHTML:0000000000\r\n";
99+
const value = header
100+
.replace(/StartHTML:\d+/, `StartHTML:${String(Buffer.byteLength(header)).padStart(10, "0")}`)
101+
.replace(/EndHTML:\d+/, `EndHTML:${String(Buffer.byteLength(header + HTML)).padStart(10, "0")}`);
102+
mockReader(t, "win32", [value + HTML + "\0"]);
103+
assert.equal(await readClipboardHtmlAsync(), HTML);
104+
});
105+
106+
test("Linux reads text/html from xclip", async (t) => {
107+
const calls = mockReader(t, "linux", [HTML]);
108+
assert.equal(await readClipboardHtmlAsync(), HTML);
109+
assert.deepEqual(
110+
calls.map(({ command, args }) => [command, args]),
111+
[["xclip", ["-selection", "clipboard", "-t", "text/html", "-o"]]]
112+
);
113+
});
114+
115+
for (const output of ["", " \n", new Error("ENOENT"), new Error("timeout"), new Error("maxBuffer exceeded")]) {
116+
test(`HTML read failure (${String(output)}) returns null`, async (t) => {
117+
const calls = mockReader(t, "darwin", [output]);
118+
assert.equal(await readClipboardHtmlAsync(), null);
119+
assert.equal(calls[0].options.timeout, 3000);
120+
assert.equal(calls[0].options.maxBuffer, 32 * 1024 * 1024);
121+
assert.equal(calls[0].options.encoding, "utf8");
122+
});
123+
test(`Linux falls back to wl-paste after ${String(output)}`, async (t) => {
124+
const calls = mockReader(t, "linux", [output, HTML]);
125+
assert.equal(await readClipboardHtmlAsync(), HTML);
126+
assert.deepEqual(
127+
calls.map(({ command }) => command),
128+
["xclip", "wl-paste"]
129+
);
130+
assert.deepEqual(calls[1].args, ["--type", "text/html", "--no-newline"]);
131+
});
132+
}
133+
134+
test("Linux returns null when both clipboard helpers fail", async (t) => {
135+
mockReader(t, "linux", []);
136+
assert.equal(await readClipboardHtmlAsync(), null);
137+
});
138+
139+
test("unsupported platforms return null without spawning", async (t) => {
140+
const calls = mockReader(t, "freebsd", []);
141+
assert.equal(await readClipboardHtmlAsync(), null);
142+
assert.equal(calls.length, 0);
143+
});

packages/cli/src/tests/image-paste-shortcut.test.ts renamed to packages/cli/src/tests/clipboard-paste-shortcut.test.ts

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import os from "node:os";
33
import { test, type TestContext } from "node:test";
44
import React from "react";
55
import { renderToString } from "ink";
6-
import { getImagePasteShortcutLabel, isImagePasteShortcut } from "../utils/image-paste-shortcut";
6+
import { getClipboardPasteShortcutLabel, isClipboardPasteShortcut } from "../utils/clipboard-paste-shortcut";
77
import { dispatchTerminalInput, parseTerminalInput } from "../ui/hooks/useTerminalInput";
88
import { PromptInput } from "../ui/views/PromptInput";
99
import { buildWelcomeTips } from "../ui/views/WelcomeScreen";
@@ -83,7 +83,7 @@ function mockEnvironment(t: TestContext, environment: (typeof environments)[numb
8383
for (const environment of environments) {
8484
const { name, platform, label, ctrl, alt } = environment;
8585

86-
test(`${name}: only the image paste shortcuts trigger image reading`, (t) => {
86+
test(`${name}: only the clipboard paste shortcuts trigger clipboard reading`, (t) => {
8787
mockEnvironment(t, environment);
8888
for (const [raw, expected] of [
8989
["\u001bv", alt],
@@ -96,25 +96,28 @@ for (const environment of environments) {
9696
["pasted text", false],
9797
] as const) {
9898
const events: boolean[] = [];
99-
dispatchTerminalInput(raw, (input, key) => events.push(isImagePasteShortcut(input, key, platform)));
99+
dispatchTerminalInput(raw, (input, key) => events.push(isClipboardPasteShortcut(input, key, platform)));
100100
assert.deepEqual(events, [expected], JSON.stringify(raw));
101101
}
102102

103103
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);
104+
assert.equal(isClipboardPasteShortcut(shortcut.input, { ...shortcut.key, paste: true }, platform), false);
105+
assert.equal(isClipboardPasteShortcut("v", { ctrl: true, meta: true, paste: false }, platform), false);
106+
assert.equal(getClipboardPasteShortcutLabel(platform), label);
107107
});
108108

109109
test(`${name}: help, welcome tips and prompt footer show ${label}`, async (t) => {
110110
mockEnvironment(t, environment);
111111

112-
assert.equal(getImagePasteShortcutLabel(), label);
112+
assert.equal(getClipboardPasteShortcutLabel(), label);
113113
const shortcut = parseTerminalInput(platform === "win32" ? "\u001bv" : "\u0016");
114-
assert.equal(isImagePasteShortcut(shortcut.input, shortcut.key), true);
114+
assert.equal(isClipboardPasteShortcut(shortcut.input, shortcut.key), true);
115115

116116
const tips = buildWelcomeTips([]);
117-
assert.equal(tips.find((tip) => tip.description === "Paste an image from the clipboard")?.label, label);
117+
assert.equal(
118+
tips.find((tip) => tip.description === "Paste an image or rich text from the clipboard")?.label,
119+
label
120+
);
118121

119122
const footer = stripTerminalControls(
120123
renderToString(
@@ -139,9 +142,9 @@ for (const environment of environments) {
139142
{ columns: 160 }
140143
)
141144
);
142-
assert.ok(footer.includes(`${label.toLowerCase()} image`), footer);
143-
if (!ctrl) assert.ok(!footer.includes("ctrl+v image"), footer);
144-
if (!alt) assert.ok(!footer.includes("alt+v image"), footer);
145+
assert.ok(footer.includes(`${label.toLowerCase()} image / rich text`), footer);
146+
if (!ctrl) assert.ok(!footer.includes("ctrl+v image / rich text"), footer);
147+
if (!alt) assert.ok(!footer.includes("alt+v image / rich text"), footer);
145148

146149
// Reload the module so its static help text uses the simulated platform.
147150
const moduleUrl = new URL(`../cli-args.ts?image-paste-platform=${name}`, import.meta.url).href;
@@ -150,8 +153,8 @@ for (const environment of environments) {
150153
t.mock.method(console, "log", (...args: unknown[]) => output.push(args.join(" ")));
151154
await parseArguments(["--help"]);
152155
const help = output.join("\n");
153-
assert.ok(help.includes(`${label.toLowerCase().padEnd(17)}Paste an image from the clipboard`), help);
154-
if (!ctrl) assert.ok(!help.includes(`${"ctrl+v".padEnd(17)}Paste an image from the clipboard`), help);
155-
if (!alt) assert.ok(!help.includes(`${"alt+v".padEnd(17)}Paste an image from the clipboard`), help);
156+
assert.ok(help.includes(`${label.toLowerCase().padEnd(17)}Paste an image or rich text from the clipboard`), help);
157+
if (!ctrl) assert.ok(!help.includes(`${"ctrl+v".padEnd(17)}Paste an image or rich text from the clipboard`), help);
158+
if (!alt) assert.ok(!help.includes(`${"alt+v".padEnd(17)}Paste an image or rich text from the clipboard`), help);
156159
});
157160
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
declare module "turndown-plugin-gfm" {
2+
import type TurndownService from "turndown";
3+
4+
export const gfm: TurndownService.Plugin;
5+
}

0 commit comments

Comments
 (0)