diff --git a/lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts b/lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts index 5f7924b4..0d49a612 100644 --- a/lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts +++ b/lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts @@ -66,7 +66,11 @@ export function handleMouseSelectionKeys(e: KeyboardEvent, ctx: WallKeyboardCtx) }); return true; } - if (mod && keyLower === 'v') { + // Accept either Ctrl+V or Cmd+V for paste on all platforms — matches VSCode's + // terminal paste behavior and the muscle memory of users coming from Linux/Windows. + // Trade-off: shadows readline's ^V verbatim-insert; not worth surfacing as a + // setting until someone asks for it. + if ((e.metaKey || e.ctrlKey) && keyLower === 'v') { e.preventDefault(); e.stopImmediatePropagation(); void doPaste(sid); diff --git a/lib/src/lib/clipboard.test.ts b/lib/src/lib/clipboard.test.ts index f3c708e5..fa97c334 100644 --- a/lib/src/lib/clipboard.test.ts +++ b/lib/src/lib/clipboard.test.ts @@ -31,14 +31,13 @@ describe('doPaste three-tier fallthrough', () => { }); }); - it('uses file refs when present and never reads text or image', async () => { + it('prefers file refs over text and skips the image read', async () => { mocks.readClipboardFilePaths.mockResolvedValue(['/tmp/a.png', '/tmp/b file.png']); - mocks.readText.mockResolvedValue('should not be read'); + mocks.readText.mockResolvedValue('coexisting text payload'); mocks.readClipboardImageAsFilePath.mockResolvedValue('/tmp/img.png'); await doPaste('t1'); - expect(mocks.readText).not.toHaveBeenCalled(); expect(mocks.readClipboardImageAsFilePath).not.toHaveBeenCalled(); expect(mocks.writePty).toHaveBeenCalledTimes(1); expect(mocks.writePty).toHaveBeenCalledWith('t1', '/tmp/a.png /tmp/b\\ file.png '); diff --git a/lib/src/lib/clipboard.ts b/lib/src/lib/clipboard.ts index 0d2201d3..3d0027aa 100644 --- a/lib/src/lib/clipboard.ts +++ b/lib/src/lib/clipboard.ts @@ -63,6 +63,17 @@ export function pasteFilePaths(terminalId: string, paths: string[]): void { } async function readTextFromClipboard(): Promise { + // Prefer the platform's native text read when available — navigator.clipboard.readText() + // on macOS WKWebView pops a "Paste from " confirmation menu at the cursor every + // time it's invoked from JS, which defeats the point of a paste shortcut. + const platform = getPlatform(); + if (platform.readClipboardText) { + try { + return (await platform.readClipboardText()) ?? ''; + } catch { + return ''; + } + } try { if (typeof navigator === 'undefined' || !navigator.clipboard?.readText) return ''; return await navigator.clipboard.readText(); @@ -73,24 +84,24 @@ async function readTextFromClipboard(): Promise { /** * Read the clipboard and write its contents to the PTY, honoring the inside - * program's bracketed-paste mode when enabled (spec §8.5). Falls through from - * file references → plain text → raw image (saved to a temp file) so a - * Cmd+V of a Finder file or a screenshot both type a usable path. + * program's bracketed-paste mode when enabled (spec §8.5). Prefers file + * references over plain text (a Finder Cmd+V types the path, not "Document.pdf" + * as a name string), with raw images saved to a temp file as a last resort. * - * Files are checked before text so that a file-ref clipboard never reaches - * `navigator.clipboard.readText()` — on macOS WKWebView that call can trigger - * a native paste-permission popup when the clipboard came from another app. + * File-path and text reads run in parallel since they're independent IPC + * roundtrips; the image read is sequential because it allocates a temp file. */ export async function doPaste(terminalId: string): Promise { const platform = getPlatform(); - const paths = await platform.readClipboardFilePaths().catch(() => null); + const [paths, text] = await Promise.all([ + platform.readClipboardFilePaths().catch(() => null), + readTextFromClipboard(), + ]); if (paths && paths.length > 0) { pasteFilePaths(terminalId, paths); return; } - - const text = await readTextFromClipboard(); if (text) { writePasteToPty(terminalId, text); return; diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index ed27b461..0568846e 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -29,6 +29,10 @@ export interface PlatformAdapter { // Clipboard support for file references and raw images. readClipboardFilePaths(): Promise; readClipboardImageAsFilePath(): Promise; + // Optional native clipboard text read. When present, doPaste uses this + // instead of navigator.clipboard.readText() so adapters whose webview pops + // a "Paste from " confirmation (notably Tauri's WKWebView) can bypass it. + readClipboardText?(): Promise; // 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. onFilesDropped?(handler: (paths: string[]) => void): () => void; diff --git a/standalone/sidecar/clipboard-ops.js b/standalone/sidecar/clipboard-ops.js index 73620739..5a0b8676 100644 --- a/standalone/sidecar/clipboard-ops.js +++ b/standalone/sidecar/clipboard-ops.js @@ -92,6 +92,58 @@ async function readClipboardFilePaths(runtime = {}) { return readFilePathsLinux(runtime); } +async function readTextMac(runtime) { + const exec = runtime.exec || execFileP; + try { + const { stdout } = await exec('pbpaste', [], { maxBuffer: MAX_BUFFER }); + return stdout; + } catch { + return ''; + } +} + +async function readTextWindows(runtime) { + const exec = runtime.exec || execFileP; + try { + const { stdout } = await exec( + 'powershell', + ['-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard -Raw'], + { maxBuffer: MAX_BUFFER }, + ); + // Get-Clipboard -Raw appends a trailing newline that wasn't on the clipboard. + return stdout.replace(/\r?\n$/, ''); + } catch { + return ''; + } +} + +async function readTextLinux(runtime) { + const env = runtime.env || process.env; + const exec = runtime.exec || execFileP; + const wayland = Boolean(env.WAYLAND_DISPLAY); + const attempts = wayland + ? [['wl-paste', ['--no-newline']], ['xclip', ['-selection', 'clipboard', '-o']]] + : [['xclip', ['-selection', 'clipboard', '-o']], ['wl-paste', ['--no-newline']]]; + + for (const [cmd, args] of attempts) { + try { + const { stdout } = await exec(cmd, args, { maxBuffer: MAX_BUFFER }); + if (stdout) return stdout; + } catch {} + } + return ''; +} + +// Native clipboard text read — bypasses navigator.clipboard.readText(), whose +// WKWebView implementation pops a "Paste from " confirmation menu at the +// cursor every time it's called from JS. +async function readClipboardText(runtime = {}) { + const platform = runtime.platform || process.platform; + if (platform === 'darwin') return readTextMac(runtime); + if (platform === 'win32') return readTextWindows(runtime); + return readTextLinux(runtime); +} + async function readImageMac(out, runtime) { const exec = runtime.exec || execFileP; const script = [ @@ -211,6 +263,7 @@ async function readClipboardImageAsFilePath(runtime = {}) { module.exports = { readClipboardFilePaths, readClipboardImageAsFilePath, + readClipboardText, parseUriList, splitNonEmptyLines, }; diff --git a/standalone/sidecar/clipboard-ops.test.js b/standalone/sidecar/clipboard-ops.test.js index a270adaf..f35d44d5 100644 --- a/standalone/sidecar/clipboard-ops.test.js +++ b/standalone/sidecar/clipboard-ops.test.js @@ -5,6 +5,7 @@ const path = require('node:path'); const { readClipboardFilePaths, readClipboardImageAsFilePath, + readClipboardText, parseUriList, splitNonEmptyLines, } = require('./clipboard-ops'); @@ -190,6 +191,81 @@ test('readClipboardImageAsFilePath returns null when osascript returns empty', a assert.deepEqual(fs.rmdirs, [path.join('/t', 'mouseterm-drops-dir-0')]); }); +test('readClipboardText on mac shells out to pbpaste', async () => { + const calls = []; + const text = await readClipboardText({ + platform: 'darwin', + exec: async (cmd, args) => { + calls.push([cmd, args]); + return { stdout: 'hello clipboard' }; + }, + }); + assert.equal(text, 'hello clipboard'); + assert.deepEqual(calls, [['pbpaste', []]]); +}); + +test('readClipboardText on mac returns empty string when pbpaste fails', async () => { + const text = await readClipboardText({ + platform: 'darwin', + exec: async () => { throw new Error('no pbpaste'); }, + }); + assert.equal(text, ''); +}); + +test('readClipboardText on windows strips Get-Clipboard trailing newline', async () => { + const text = await readClipboardText({ + platform: 'win32', + exec: async (cmd, args) => { + assert.equal(cmd, 'powershell'); + assert.ok(args.includes('Get-Clipboard -Raw')); + return { stdout: 'line1\r\nline2\r\n' }; + }, + }); + assert.equal(text, 'line1\r\nline2'); +}); + +test('readClipboardText on linux prefers xclip in X11', async () => { + const calls = []; + const text = await readClipboardText({ + platform: 'linux', + env: {}, + exec: async (cmd, args) => { + calls.push([cmd, args]); + if (cmd === 'xclip') return { stdout: 'x11 text' }; + throw new Error('should not reach'); + }, + }); + assert.equal(text, 'x11 text'); + assert.equal(calls[0][0], 'xclip'); +}); + +test('readClipboardText on linux prefers wl-paste under Wayland', async () => { + const calls = []; + const text = await readClipboardText({ + platform: 'linux', + env: { WAYLAND_DISPLAY: 'wayland-0' }, + exec: async (cmd, args) => { + calls.push([cmd, args]); + if (cmd === 'wl-paste') return { stdout: 'wayland text' }; + throw new Error('should not reach'); + }, + }); + assert.equal(text, 'wayland text'); + assert.equal(calls[0][0], 'wl-paste'); +}); + +test('readClipboardText on linux falls back when first tool fails', async () => { + const text = await readClipboardText({ + platform: 'linux', + env: {}, + exec: async (cmd) => { + if (cmd === 'xclip') throw new Error('no xclip'); + return { stdout: 'fallback text' }; + }, + }); + assert.equal(text, 'fallback text'); +}); + test('readClipboardImageAsFilePath on linux writes buffer from exec stdout', async () => { const fs = fakeFs(); const result = await readClipboardImageAsFilePath({ diff --git a/standalone/sidecar/main.js b/standalone/sidecar/main.js index f2562d4c..4ae2f106 100644 --- a/standalone/sidecar/main.js +++ b/standalone/sidecar/main.js @@ -53,6 +53,11 @@ rl.on('line', (line) => { path: await clipboard.readClipboardImageAsFilePath(), })); break; + case 'clipboard:readText': + respondAsync('clipboard:text', data.requestId, async () => ({ + text: await clipboard.readClipboardText(), + })); + break; default: console.error(`[sidecar] Unknown event: ${event}`); } } catch (err) { diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index 0fedeada..b3a2384c 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -289,6 +289,18 @@ fn read_clipboard_image_as_file_path( .and_then(|path| path.as_str().map(String::from))) } +#[tauri::command] +fn read_clipboard_text( + state: tauri::State<'_, SidecarState>, +) -> Result { + let response = + request_from_sidecar_timeout(&state, "clipboard:readText", serde_json::json!({}), Duration::from_secs(5))?; + Ok(response + .get("text") + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_default()) +} + #[tauri::command] fn read_update_log() -> Result { read_log_tail(10_000) @@ -632,6 +644,7 @@ pub fn run() { get_available_shells, read_clipboard_file_paths, read_clipboard_image_as_file_path, + read_clipboard_text, read_update_log, ]) .build(tauri::generate_context!()) diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 559bee38..535326b9 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -171,6 +171,12 @@ export class TauriAdapter implements PlatformAdapter { } catch { return null; } } + async readClipboardText(): Promise { + try { + return await rawInvoke("read_clipboard_text"); + } catch { return null; } + } + onFilesDropped(handler: (paths: string[]) => void): () => void { this.filesDroppedHandlers.add(handler); return () => { this.filesDroppedHandlers.delete(handler); };