Skip to content

Commit 47e6caf

Browse files
nedtwiggclaude
andcommitted
Read clipboard text natively in standalone to skip the WKWebView paste prompt
navigator.clipboard.readText() pops a "Paste from <App>" confirmation menu at the cursor on macOS WKWebView every time it's invoked from JS, which defeats the point of a paste shortcut. Route text reads through the sidecar (pbpaste / Get-Clipboard / xclip / wl-paste) the same way files and images already work, and accept either Ctrl+V or Cmd+V so the shortcut matches user muscle memory on every platform. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f6af887 commit 47e6caf

8 files changed

Lines changed: 173 additions & 1 deletion

File tree

lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,11 @@ export function handleMouseSelectionKeys(e: KeyboardEvent, ctx: WallKeyboardCtx)
6666
});
6767
return true;
6868
}
69-
if (mod && keyLower === 'v') {
69+
// Accept either Ctrl+V or Cmd+V for paste on all platforms — matches VSCode's
70+
// terminal paste behavior and the muscle memory of users coming from Linux/Windows.
71+
// Trade-off: shadows readline's ^V verbatim-insert; not worth surfacing as a
72+
// setting until someone asks for it.
73+
if ((e.metaKey || e.ctrlKey) && keyLower === 'v') {
7074
e.preventDefault();
7175
e.stopImmediatePropagation();
7276
void doPaste(sid);

lib/src/lib/clipboard.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,17 @@ export function pasteFilePaths(terminalId: string, paths: string[]): void {
6363
}
6464

6565
async function readTextFromClipboard(): Promise<string> {
66+
// Prefer the platform's native text read when available — navigator.clipboard.readText()
67+
// on macOS WKWebView pops a "Paste from <App>" confirmation menu at the cursor every
68+
// time it's invoked from JS, which defeats the point of a paste shortcut.
69+
const platform = getPlatform();
70+
if (platform.readClipboardText) {
71+
try {
72+
return (await platform.readClipboardText()) ?? '';
73+
} catch {
74+
return '';
75+
}
76+
}
6677
try {
6778
if (typeof navigator === 'undefined' || !navigator.clipboard?.readText) return '';
6879
return await navigator.clipboard.readText();

lib/src/lib/platform/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ export interface PlatformAdapter {
2929
// Clipboard support for file references and raw images.
3030
readClipboardFilePaths(): Promise<string[] | null>;
3131
readClipboardImageAsFilePath(): Promise<string | null>;
32+
// Optional native clipboard text read. When present, doPaste uses this
33+
// instead of navigator.clipboard.readText() so adapters whose webview pops
34+
// a "Paste from <App>" confirmation (notably Tauri's WKWebView) can bypass it.
35+
readClipboardText?(): Promise<string | null>;
3236
// Only present on adapters with a native (non-DOM) drag-drop source. Currently inert in Tauri; see diffplug/mouseterm#38 and tauri-apps/tauri#14373.
3337
onFilesDropped?(handler: (paths: string[]) => void): () => void;
3438

standalone/sidecar/clipboard-ops.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,58 @@ async function readClipboardFilePaths(runtime = {}) {
9292
return readFilePathsLinux(runtime);
9393
}
9494

95+
async function readTextMac(runtime) {
96+
const exec = runtime.exec || execFileP;
97+
try {
98+
const { stdout } = await exec('pbpaste', [], { maxBuffer: MAX_BUFFER });
99+
return stdout;
100+
} catch {
101+
return '';
102+
}
103+
}
104+
105+
async function readTextWindows(runtime) {
106+
const exec = runtime.exec || execFileP;
107+
try {
108+
const { stdout } = await exec(
109+
'powershell',
110+
['-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard -Raw'],
111+
{ maxBuffer: MAX_BUFFER },
112+
);
113+
// Get-Clipboard -Raw appends a trailing newline that wasn't on the clipboard.
114+
return stdout.replace(/\r?\n$/, '');
115+
} catch {
116+
return '';
117+
}
118+
}
119+
120+
async function readTextLinux(runtime) {
121+
const env = runtime.env || process.env;
122+
const exec = runtime.exec || execFileP;
123+
const wayland = Boolean(env.WAYLAND_DISPLAY);
124+
const attempts = wayland
125+
? [['wl-paste', ['--no-newline']], ['xclip', ['-selection', 'clipboard', '-o']]]
126+
: [['xclip', ['-selection', 'clipboard', '-o']], ['wl-paste', ['--no-newline']]];
127+
128+
for (const [cmd, args] of attempts) {
129+
try {
130+
const { stdout } = await exec(cmd, args, { maxBuffer: MAX_BUFFER });
131+
if (stdout) return stdout;
132+
} catch {}
133+
}
134+
return '';
135+
}
136+
137+
// Native clipboard text read — bypasses navigator.clipboard.readText(), whose
138+
// WKWebView implementation pops a "Paste from <App>" confirmation menu at the
139+
// cursor every time it's called from JS.
140+
async function readClipboardText(runtime = {}) {
141+
const platform = runtime.platform || process.platform;
142+
if (platform === 'darwin') return readTextMac(runtime);
143+
if (platform === 'win32') return readTextWindows(runtime);
144+
return readTextLinux(runtime);
145+
}
146+
95147
async function readImageMac(out, runtime) {
96148
const exec = runtime.exec || execFileP;
97149
const script = [
@@ -211,6 +263,7 @@ async function readClipboardImageAsFilePath(runtime = {}) {
211263
module.exports = {
212264
readClipboardFilePaths,
213265
readClipboardImageAsFilePath,
266+
readClipboardText,
214267
parseUriList,
215268
splitNonEmptyLines,
216269
};

standalone/sidecar/clipboard-ops.test.js

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const path = require('node:path');
55
const {
66
readClipboardFilePaths,
77
readClipboardImageAsFilePath,
8+
readClipboardText,
89
parseUriList,
910
splitNonEmptyLines,
1011
} = require('./clipboard-ops');
@@ -190,6 +191,81 @@ test('readClipboardImageAsFilePath returns null when osascript returns empty', a
190191
assert.deepEqual(fs.rmdirs, [path.join('/t', 'mouseterm-drops-dir-0')]);
191192
});
192193

194+
test('readClipboardText on mac shells out to pbpaste', async () => {
195+
const calls = [];
196+
const text = await readClipboardText({
197+
platform: 'darwin',
198+
exec: async (cmd, args) => {
199+
calls.push([cmd, args]);
200+
return { stdout: 'hello clipboard' };
201+
},
202+
});
203+
assert.equal(text, 'hello clipboard');
204+
assert.deepEqual(calls, [['pbpaste', []]]);
205+
});
206+
207+
test('readClipboardText on mac returns empty string when pbpaste fails', async () => {
208+
const text = await readClipboardText({
209+
platform: 'darwin',
210+
exec: async () => { throw new Error('no pbpaste'); },
211+
});
212+
assert.equal(text, '');
213+
});
214+
215+
test('readClipboardText on windows strips Get-Clipboard trailing newline', async () => {
216+
const text = await readClipboardText({
217+
platform: 'win32',
218+
exec: async (cmd, args) => {
219+
assert.equal(cmd, 'powershell');
220+
assert.ok(args.includes('Get-Clipboard -Raw'));
221+
return { stdout: 'line1\r\nline2\r\n' };
222+
},
223+
});
224+
assert.equal(text, 'line1\r\nline2');
225+
});
226+
227+
test('readClipboardText on linux prefers xclip in X11', async () => {
228+
const calls = [];
229+
const text = await readClipboardText({
230+
platform: 'linux',
231+
env: {},
232+
exec: async (cmd, args) => {
233+
calls.push([cmd, args]);
234+
if (cmd === 'xclip') return { stdout: 'x11 text' };
235+
throw new Error('should not reach');
236+
},
237+
});
238+
assert.equal(text, 'x11 text');
239+
assert.equal(calls[0][0], 'xclip');
240+
});
241+
242+
test('readClipboardText on linux prefers wl-paste under Wayland', async () => {
243+
const calls = [];
244+
const text = await readClipboardText({
245+
platform: 'linux',
246+
env: { WAYLAND_DISPLAY: 'wayland-0' },
247+
exec: async (cmd, args) => {
248+
calls.push([cmd, args]);
249+
if (cmd === 'wl-paste') return { stdout: 'wayland text' };
250+
throw new Error('should not reach');
251+
},
252+
});
253+
assert.equal(text, 'wayland text');
254+
assert.equal(calls[0][0], 'wl-paste');
255+
});
256+
257+
test('readClipboardText on linux falls back when first tool fails', async () => {
258+
const text = await readClipboardText({
259+
platform: 'linux',
260+
env: {},
261+
exec: async (cmd) => {
262+
if (cmd === 'xclip') throw new Error('no xclip');
263+
return { stdout: 'fallback text' };
264+
},
265+
});
266+
assert.equal(text, 'fallback text');
267+
});
268+
193269
test('readClipboardImageAsFilePath on linux writes buffer from exec stdout', async () => {
194270
const fs = fakeFs();
195271
const result = await readClipboardImageAsFilePath({

standalone/sidecar/main.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ rl.on('line', (line) => {
5353
path: await clipboard.readClipboardImageAsFilePath(),
5454
}));
5555
break;
56+
case 'clipboard:readText':
57+
respondAsync('clipboard:text', data.requestId, async () => ({
58+
text: await clipboard.readClipboardText(),
59+
}));
60+
break;
5661
default: console.error(`[sidecar] Unknown event: ${event}`);
5762
}
5863
} catch (err) {

standalone/src-tauri/src/lib.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,18 @@ fn read_clipboard_image_as_file_path(
289289
.and_then(|path| path.as_str().map(String::from)))
290290
}
291291

292+
#[tauri::command]
293+
fn read_clipboard_text(
294+
state: tauri::State<'_, SidecarState>,
295+
) -> Result<String, String> {
296+
let response =
297+
request_from_sidecar_timeout(&state, "clipboard:readText", serde_json::json!({}), Duration::from_secs(5))?;
298+
Ok(response
299+
.get("text")
300+
.and_then(|v| v.as_str().map(String::from))
301+
.unwrap_or_default())
302+
}
303+
292304
#[tauri::command]
293305
fn read_update_log() -> Result<String, String> {
294306
read_log_tail(10_000)
@@ -632,6 +644,7 @@ pub fn run() {
632644
get_available_shells,
633645
read_clipboard_file_paths,
634646
read_clipboard_image_as_file_path,
647+
read_clipboard_text,
635648
read_update_log,
636649
])
637650
.build(tauri::generate_context!())

standalone/src/tauri-adapter.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,12 @@ export class TauriAdapter implements PlatformAdapter {
171171
} catch { return null; }
172172
}
173173

174+
async readClipboardText(): Promise<string | null> {
175+
try {
176+
return await rawInvoke<string>("read_clipboard_text");
177+
} catch { return null; }
178+
}
179+
174180
onFilesDropped(handler: (paths: string[]) => void): () => void {
175181
this.filesDroppedHandlers.add(handler);
176182
return () => { this.filesDroppedHandlers.delete(handler); };

0 commit comments

Comments
 (0)