From 5af4f12366c7330886f529a03665433a80f4a611 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Mon, 20 Apr 2026 23:46:06 +0000 Subject: [PATCH 01/17] Claude Code simplify: remove dead code in mouse-mode-observer dispose test --- lib/src/lib/mouse-mode-observer.test.ts | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/lib/src/lib/mouse-mode-observer.test.ts b/lib/src/lib/mouse-mode-observer.test.ts index 057f59b..b67112b 100644 --- a/lib/src/lib/mouse-mode-observer.test.ts +++ b/lib/src/lib/mouse-mode-observer.test.ts @@ -93,21 +93,8 @@ describe('attachMouseModeObserver', () => { }); it('dispose tears down both handlers', () => { - const { terminal } = buildMockTerminal(); - const mockDispose1 = vi.fn(); - const mockDispose2 = vi.fn(); - const realParser = terminal.parser; - (terminal as unknown as { parser: unknown }).parser = { - registerCsiHandler(_id: unknown, _cb: unknown) { - return realParser === terminal.parser - ? { dispose: mockDispose1 } - : { dispose: mockDispose2 }; - }, - }; - - // Simpler: build a fresh mock with explicit disposables const disposables: Array<{ dispose: ReturnType }> = []; - const term2 = { + const terminal = { parser: { registerCsiHandler() { const d = { dispose: vi.fn() }; @@ -118,7 +105,7 @@ describe('attachMouseModeObserver', () => { modes: { mouseTrackingMode: 'none', bracketedPasteMode: false }, } as unknown as Terminal; - const observer = attachMouseModeObserver('a', term2); + const observer = attachMouseModeObserver('a', terminal); observer.dispose(); expect(disposables).toHaveLength(2); From 1ab1707c236110c8ec3051e16e93d87dfc92abe6 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Mon, 20 Apr 2026 23:55:38 +0000 Subject: [PATCH 02/17] Codex review R1: fix indentation in selection-text.ts and SelectionOverlay.tsx --- lib/src/components/SelectionOverlay.tsx | 4 ++-- lib/src/lib/selection-text.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/src/components/SelectionOverlay.tsx b/lib/src/components/SelectionOverlay.tsx index 7dbcd27..90c282b 100644 --- a/lib/src/components/SelectionOverlay.tsx +++ b/lib/src/components/SelectionOverlay.tsx @@ -80,9 +80,9 @@ function computeRects( for (let r = firstRow; r <= lastRow; r++) { let c0 = 0; let c1 = cols; - if (r === n.r0) c0 = n.c0; + if (r === n.r0) c0 = n.c0; if (r === n.r1) c1 = n.c1 + 1; - if (c1 <= c0) continue; + if (c1 <= c0) continue; rects.push({ top: (r - viewportStart) * cellHeight, left: c0 * cellWidth, diff --git a/lib/src/lib/selection-text.ts b/lib/src/lib/selection-text.ts index 8b949ad..2840c8f 100644 --- a/lib/src/lib/selection-text.ts +++ b/lib/src/lib/selection-text.ts @@ -52,9 +52,9 @@ export function extractSelectionText(terminal: Terminal, sel: Selection): string for (let r = n.r0; r <= n.r1; r++) { const line = buf.getLine(r); if (!line) continue; - const c0 = r === n.r0 ? n.c0 : 0; + const c0 = r === n.r0 ? n.c0 : 0; const c1 = r === n.r1 ? n.c1 + 1 : terminal.cols; - lines.push(line.translateToString(false, c0, c1).replace(/\s+$/, '')); + lines.push(line.translateToString(false, c0, c1).replace(/\s+$/, '')); } return lines.join('\n'); } From 3d217ad6c89ffca8596393a5e8d77797896c9cc1 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 21 Apr 2026 00:05:29 +0000 Subject: [PATCH 03/17] Claude Code review R1: fix flashCopy race where beginDrag during flash period lets timer nuke new selection --- lib/src/lib/mouse-selection.test.ts | 18 ++++++++++++++++++ lib/src/lib/mouse-selection.ts | 3 +++ 2 files changed, 21 insertions(+) diff --git a/lib/src/lib/mouse-selection.test.ts b/lib/src/lib/mouse-selection.test.ts index 0e6703d..028b956 100644 --- a/lib/src/lib/mouse-selection.test.ts +++ b/lib/src/lib/mouse-selection.test.ts @@ -4,6 +4,7 @@ import { __resetMouseSelectionForTests, beginDrag, endDrag, + flashCopy, getMouseSelectionSnapshot, getMouseSelectionState, isDragging, @@ -272,6 +273,23 @@ describe('mouse-selection: drag lifecycle', () => { }); }); +describe('mouse-selection: flashCopy race', () => { + it('beginDrag during a flash clears copyFlash so the timer does not nuke the new selection', () => { + beginDrag('a', { row: 0, col: 0, altKey: false, startedInScrollback: false }); + updateDrag('a', { row: 3, col: 5, altKey: false }); + endDrag('a'); + + // Simulate flashCopy — but we call beginDrag before the timer fires. + flashCopy('a', 'raw', 500); + expect(getMouseSelectionState('a').copyFlash).toBe('raw'); + + // New drag starts before the 500ms timer. + beginDrag('a', { row: 10, col: 2, altKey: false, startedInScrollback: false }); + expect(getMouseSelectionState('a').copyFlash).toBeNull(); + expect(getMouseSelectionState('a').selection?.startRow).toBe(10); + }); +}); + describe('mouse-selection: snapshot caching', () => { it('returns the same snapshot reference between changes', () => { setMouseReporting('a', 'vt200'); diff --git a/lib/src/lib/mouse-selection.ts b/lib/src/lib/mouse-selection.ts index 1d1892f..cca81ee 100644 --- a/lib/src/lib/mouse-selection.ts +++ b/lib/src/lib/mouse-selection.ts @@ -149,6 +149,9 @@ export function beginDrag( args: { row: number; col: number; altKey: boolean; startedInScrollback: boolean }, ): void { const s = ensure(id); + // Clear any in-flight copy flash so its timer won't null out this new + // selection when it fires (the timer checks `copyFlash !== kind`). + s.copyFlash = null; s.selection = { startRow: args.row, startCol: args.col, From 5c5515fe9bf171a0be6fbfa1e626421ab2a955cb Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 21 Apr 2026 00:13:39 +0000 Subject: [PATCH 04/17] Codex review R2: strip trailing punctuation before pattern matching in smart-token Error-location tokens like "src/foo.ts:42." (common in compiler output where a sentence ends with a file:line reference) were not detected because the error-location regex was tested against the raw token with skipStrip:true, so the trailing period prevented the regex $ anchor from matching. Fix: strip trailing punctuation once up front, then test all patterns against the cleaned token. The skipStrip flag is removed since all patterns now benefit from uniform stripping. Added a regression test for error-location-with-trailing-period. --- lib/src/lib/smart-token.test.ts | 5 +++++ lib/src/lib/smart-token.ts | 32 +++++++++++++++++--------------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/lib/src/lib/smart-token.test.ts b/lib/src/lib/smart-token.test.ts index a8d1a88..42814de 100644 --- a/lib/src/lib/smart-token.test.ts +++ b/lib/src/lib/smart-token.test.ts @@ -87,6 +87,11 @@ describe('detectTokenAt: path', () => { expect(t?.text).toBe('src/foo.ts:42:7'); }); + it('error location with trailing period is detected after stripping', () => { + const t = at('Error at src/foo.ts:42. See docs.', 'src/'); + expect(t).toMatchObject({ kind: 'path', text: 'src/foo.ts:42' }); + }); + it('strips trailing period on absolute path', () => { const t = detectTokenAt('/tmp/a.', 0); expect(t?.text).toBe('/tmp/a'); diff --git a/lib/src/lib/smart-token.ts b/lib/src/lib/smart-token.ts index e521f40..f2dd329 100644 --- a/lib/src/lib/smart-token.ts +++ b/lib/src/lib/smart-token.ts @@ -16,19 +16,16 @@ export interface DetectedToken { interface Pattern { kind: 'url' | 'path'; re: RegExp; - /** When true, trailing-punctuation stripping is skipped — the pattern's - * trailing characters are significant (e.g. error-location `:line:col`). */ - skipStrip?: boolean; } const PATTERNS: Pattern[] = [ - { kind: 'url', re: /^https?:\/\/\S+$/ }, - { kind: 'url', re: /^file:\/\/\S+$/ }, - { kind: 'path', re: /^\S+:\d+(:\d+)?$/, skipStrip: true }, // error-location first (so it beats generic path) - { kind: 'path', re: /^~\/\S*$/ }, - { kind: 'path', re: /^\/\S+$/ }, - { kind: 'path', re: /^\.\.?\/\S*$/ }, - { kind: 'path', re: /^[A-Za-z]:\\\S*$/ }, + { kind: 'url', re: new RegExp('^https?://\\S+$') }, + { kind: 'url', re: new RegExp('^file://\\S+$') }, + { kind: 'path', re: new RegExp('^\\S+:\\d+(:\\d+)?$') }, // error-location first (so it beats generic path) + { kind: 'path', re: new RegExp('^~/\\S*$') }, + { kind: 'path', re: new RegExp('^/\\S+$') }, + { kind: 'path', re: new RegExp('^\\.{1,2}/\\S*$') }, + { kind: 'path', re: new RegExp('^[A-Za-z]:\\\\\\S*$') }, ]; const TRAILING_PUNCT = /[.,;:!?'"]+$/; @@ -88,11 +85,16 @@ export function detectTokenAt(line: string, col: number): DetectedToken | null { const raw = line.slice(start, end); if (!raw) return null; - for (const { kind, re, skipStrip } of PATTERNS) { - if (!re.test(raw)) continue; - const text = skipStrip ? raw : stripTrailing(raw); - if (!text) continue; - return { kind, start, end: start + text.length, text }; + // Strip trailing punctuation once, then test all patterns against the + // cleaned token. This ensures error-location patterns like `file:42` are + // found even when the original token had a trailing period (e.g. in + // compiler output "Error at src/foo.ts:42."). + const cleaned = stripTrailing(raw); + if (!cleaned) return null; + + for (const { kind, re } of PATTERNS) { + if (!re.test(cleaned)) continue; + return { kind, start, end: start + cleaned.length, text: cleaned }; } return null; } From 3767d335dcad513566cb7fa1b280e213be083e5e Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 22 Apr 2026 11:50:30 -0700 Subject: [PATCH 05/17] copy-paste confirmed on mac standalone for a single image --- docs/specs/mouse-and-clipboard.md | 20 +- lib/clipboard-ops.cjs | 3 + lib/src/components/Pond.tsx | 12 +- lib/src/components/TerminalPane.tsx | 32 ++- lib/src/lib/clipboard.ts | 63 +++++- lib/src/lib/platform/fake-adapter.ts | 4 + lib/src/lib/platform/types.ts | 7 + lib/src/lib/platform/vscode-adapter.ts | 25 +++ lib/src/lib/reconnect.test.ts | 3 + lib/src/lib/session-restore.test.ts | 3 + lib/src/lib/session-save.test.ts | 3 + lib/src/lib/shell-escape.test.ts | 50 +++++ lib/src/lib/shell-escape.ts | 22 ++ standalone/sidecar/clipboard-ops.js | 274 +++++++++++++++++++++++ standalone/sidecar/clipboard-ops.test.js | 232 +++++++++++++++++++ standalone/sidecar/main.js | 34 ++- standalone/src-tauri/src/lib.rs | 100 ++++++++- standalone/src/tauri-adapter.ts | 35 +++ vscode-ext/src/extension.ts | 7 +- vscode-ext/src/message-router.ts | 36 +++ vscode-ext/src/message-types.ts | 6 + vscode-ext/src/webview-view-provider.ts | 2 + 22 files changed, 950 insertions(+), 23 deletions(-) create mode 100644 lib/clipboard-ops.cjs create mode 100644 lib/src/lib/shell-escape.test.ts create mode 100644 lib/src/lib/shell-escape.ts create mode 100644 standalone/sidecar/clipboard-ops.js create mode 100644 standalone/sidecar/clipboard-ops.test.js diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index 6b75c98..3740959 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -300,11 +300,21 @@ The bracketed-paste mode is read at paste time from xterm's public `terminal.mod ### 8.6 Paste Content -The terminal pastes plain text only. It calls `navigator.clipboard.readText()` and writes the resulting string to the PTY (with bracketed-paste wrapping when enabled). If `readText` returns an empty string or throws (e.g. the document lacks focus or permission was denied), the paste is silently a no-op. +Paste reads the clipboard in three tiers, falling through in order: -File-URL handling, image paste, content-aware transformations, paste history, and credential warnings are out of scope (see §9). +1. **Plain text.** `navigator.clipboard.readText()`. If non-empty, the string is written to the PTY (with bracketed-paste wrapping when enabled by the inside program). +2. **File references.** If the clipboard has no text but carries OS file references (Finder/Explorer Copy of a file), each path is shell-escaped and the space-joined list is written to the PTY with a trailing space so the next token starts cleanly. +3. **Raw image data.** If neither of the above matches and the clipboard holds image bytes (e.g. a `Cmd+Shift+4` screenshot), the bytes are written to `$TMPDIR/mouseterm-drops/.png` and that single path is pasted as in tier 2. -### 8.7 Right-Click and Menu Paste +Each tier is implemented by a shared Node module (`standalone/sidecar/clipboard-ops.js`) that shells out to the OS-native clipboard tool: `osascript` on macOS, `Get-Clipboard` on Windows, `wl-paste`/`xclip` on Linux. The Tauri build reaches it through the existing sidecar; the VSCode build calls into the same module from its extension host. If every tier comes back empty, paste is a silent no-op. + +Content-aware transformations, paste history, credential warnings, and middle-click (X11 PRIMARY) paste remain out of scope (see §9). + +### 8.7 Drag-to-Paste + +Dragging files onto a terminal pane mirrors the paste chain above: escaped paths are typed at the current prompt, space-joined with a trailing space. Tauri receives the drop natively via `WindowEvent::DragDrop` and routes paths to the focused pane. VSCode webviews are sandboxed — `File.path` is not exposed — so bytes are copied into `$TMPDIR/mouseterm-drops/-` and that temp path is pasted. This mismatch is intentional: under Tauri the original path is preserved; under VSCode the user gets a usable path with a tolerable byte-copy cost. + +### 8.8 Right-Click and Menu Paste Right-click and OS Edit-menu paste are not currently implemented; users paste via the keyboard shortcuts in §8.2. @@ -332,10 +342,10 @@ The following are explicitly not implemented today; they may be added in respons - A settings toggle to disable Ctrl+V interception on Windows and Linux. - A paste popup (parallel to the copy popup) for previewing or transforming paste content before it is committed. - Paste content transformations (strip trailing whitespace, normalize line endings, convert smart quotes). -- File URL handling: pasting a `file://` URL as the bare path (Finder/Explorer drag-as-text). -- Image paste: detecting image data on the clipboard and offering to paste it as a temp file path or inline base64. - Paste history. - Credential-shaped content detection and warnings. - Multi-line paste confirmation dialogs. - A "literal next keystroke" terminal-level shortcut (Ctrl+Alt+V or similar) for programs that don't support Ctrl+Q-style `quoted-insert`. - Middle-click paste / X11 PRIMARY selection integration on Linux. +- Drop-position-aware pane routing (currently drops always go to the focused pane). +- Preserving the original path when dragging into the VSCode build (blocked by webview sandboxing of `File.path`). diff --git a/lib/clipboard-ops.cjs b/lib/clipboard-ops.cjs new file mode 100644 index 0000000..ca89906 --- /dev/null +++ b/lib/clipboard-ops.cjs @@ -0,0 +1,3 @@ +// The packaged Tauri sidecar must ship a local copy of the clipboard ops, so +// this CommonJS shim points other Node consumers at that shared implementation. +module.exports = require('../standalone/sidecar/clipboard-ops.js'); diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index a01e935..c223f42 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -26,7 +26,7 @@ import { setSelection as setMouseSelection, subscribeToMouseSelection, } from '../lib/mouse-selection'; -import { copyRaw, copyRewrapped, doPaste } from '../lib/clipboard'; +import { copyRaw, copyRewrapped, doPaste, pasteFilePaths } from '../lib/clipboard'; import { IS_MAC } from '../lib/platform'; import { type AlarmButtonActionResult, @@ -1690,12 +1690,22 @@ export function Pond({ platform.onRequestSessionFlush(handleSessionFlushRequest); window.addEventListener('pagehide', handlePageHide); + const unsubFilesDropped = platform.onFilesDropped?.((paths) => { + if (paths.length === 0) return; + const sid = selectedTypeRef.current === 'pane' ? selectedIdRef.current : null; + if (!sid) return; + const api = apiRef.current; + if (!api || !api.panels.some((p) => p.id === sid)) return; + pasteFilePaths(sid, paths); + }); + return () => { if (sessionSaveTimerRef.current) { clearTimeout(sessionSaveTimerRef.current); sessionSaveTimerRef.current = null; } window.removeEventListener('pagehide', handlePageHide); + unsubFilesDropped?.(); platform.offRequestSessionFlush(handleSessionFlushRequest); platform.offPtyExit(handlePtyExit); layoutDisposable.dispose(); diff --git a/lib/src/components/TerminalPane.tsx b/lib/src/components/TerminalPane.tsx index 8dd4364..419f8f9 100644 --- a/lib/src/components/TerminalPane.tsx +++ b/lib/src/components/TerminalPane.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import '@xterm/xterm/css/xterm.css'; import { getOrCreateTerminal, @@ -7,6 +7,8 @@ import { refitTerminal, focusTerminal, } from '../lib/terminal-registry'; +import { pasteFilePaths } from '../lib/clipboard'; +import { getPlatform } from '../lib/platform'; import { SelectionOverlay } from './SelectionOverlay'; import { SelectionPopup } from './SelectionPopup'; @@ -49,8 +51,34 @@ export function TerminalPane({ id, isFocused = true }: TerminalPaneProps) { focusTerminal(id, isFocused); }, [id, isFocused]); + const onDragOver = useCallback((e: React.DragEvent) => { + if (e.dataTransfer.types.includes('Files')) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + } + }, []); + + const onDrop = useCallback(async (e: React.DragEvent) => { + const files = Array.from(e.dataTransfer.files); + if (files.length === 0) return; + e.preventDefault(); + const platform = getPlatform(); + const paths: string[] = []; + for (const file of files) { + const bytes = new Uint8Array(await file.arrayBuffer()); + const p = await platform.saveDroppedBytesToTempFile(bytes, file.name); + if (p) paths.push(p); + } + if (paths.length > 0) pasteFilePaths(id, paths); + }, [id]); + return ( -
+
diff --git a/lib/src/lib/clipboard.ts b/lib/src/lib/clipboard.ts index 02f6e49..29837d1 100644 --- a/lib/src/lib/clipboard.ts +++ b/lib/src/lib/clipboard.ts @@ -2,6 +2,7 @@ import { getMouseSelectionState } from './mouse-selection'; import { rewrap } from './rewrap'; import { extractSelectionText } from './selection-text'; import { getPlatform } from './platform'; +import { shellEscapePath } from './shell-escape'; import { getTerminalInstance } from './terminal-registry'; async function writeText(text: string): Promise { @@ -42,22 +43,60 @@ export async function copyRewrapped(terminalId: string): Promise { await writeText(out); } +function writePasteToPty(terminalId: string, text: string): void { + if (!text) return; + const bracketed = getMouseSelectionState(terminalId).bracketedPaste; + const payload = bracketed ? `\x1b[200~${text}\x1b[201~` : text; + getPlatform().writePty(terminalId, payload); +} + /** - * Read text from the clipboard and write it to the PTY, honoring the - * inside program's bracketed-paste mode when enabled (spec §8.5). + * Shell-escape the given paths and type them at the terminal, joined by single + * spaces with a trailing space so the next prompt keystroke starts a fresh + * token. */ -export async function doPaste(terminalId: string): Promise { - let text: string; +export function pasteFilePaths(terminalId: string, paths: string[]): void { + if (paths.length === 0) return; + const text = paths.map(shellEscapePath).join(' ') + ' '; + writePasteToPty(terminalId, text); +} + +async function readTextFromClipboard(): Promise { try { - if (typeof navigator === 'undefined' || !navigator.clipboard?.readText) return; - text = await navigator.clipboard.readText(); + if (typeof navigator === 'undefined' || !navigator.clipboard?.readText) return ''; + return await navigator.clipboard.readText(); } catch { - // Clipboard read can fail when the document lacks focus or the - // Permissions API denied access. Silently ignore. + return ''; + } +} + +/** + * 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. + * + * 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. + */ +export async function doPaste(terminalId: string): Promise { + const platform = getPlatform(); + + const paths = await platform.readClipboardFilePaths().catch(() => null); + if (paths && paths.length > 0) { + pasteFilePaths(terminalId, paths); return; } - if (!text) return; - const bracketed = getMouseSelectionState(terminalId).bracketedPaste; - const payload = bracketed ? `\x1b[200~${text}\x1b[201~` : text; - getPlatform().writePty(terminalId, payload); + + const text = await readTextFromClipboard(); + if (text) { + writePasteToPty(terminalId, text); + return; + } + + const imagePath = await platform.readClipboardImageAsFilePath().catch(() => null); + if (imagePath) { + pasteFilePaths(terminalId, [imagePath]); + } } diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index 1c264fe..155fcd1 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -127,6 +127,10 @@ export class FakePtyAdapter implements PlatformAdapter { async getCwd(_id: string): Promise { return null; } async getScrollback(_id: string): Promise { return null; } + async readClipboardFilePaths(): Promise { return null; } + async readClipboardImageAsFilePath(): Promise { return null; } + async saveDroppedBytesToTempFile(_bytes: Uint8Array, _filename: string): Promise { return null; } + requestInit(): void {} onPtyList(_handler: (detail: { ptys: PtyInfo[] }) => void): void {} offPtyList(_handler: (detail: { ptys: PtyInfo[] }) => void): void {} diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index b9e5f9f..82cba6c 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -26,6 +26,13 @@ export interface PlatformAdapter { getCwd(id: string): Promise; getScrollback(id: string): Promise; + // Clipboard and drag-drop support for file references and raw images. + readClipboardFilePaths(): Promise; + readClipboardImageAsFilePath(): Promise; + saveDroppedBytesToTempFile(bytes: Uint8Array, filename: string): Promise; + // Only present on adapters with a native (non-DOM) drag-drop source. + onFilesDropped?(handler: (paths: string[]) => void): () => void; + // PTY event listeners onPtyData(handler: (detail: { id: string; data: string }) => void): void; offPtyData(handler: (detail: { id: string; data: string }) => void): void; diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index fae3956..80d4ef2 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -130,6 +130,31 @@ export class VSCodeAdapter implements PlatformAdapter { return this.requestResponse('pty:getScrollback', 'pty:scrollback', { id }, (msg) => msg.data); } + readClipboardFilePaths(): Promise { + return this.requestResponse( + 'clipboard:readFiles', 'clipboard:files', {}, + (msg) => msg.paths, + 5000, + ); + } + + readClipboardImageAsFilePath(): Promise { + return this.requestResponse( + 'clipboard:readImage', 'clipboard:image', {}, + (msg) => msg.path, + 10000, + ); + } + + saveDroppedBytesToTempFile(bytes: Uint8Array, filename: string): Promise { + return this.requestResponse( + 'file:saveBytes', 'file:savedBytes', + { filename, bytes: Array.from(bytes) }, + (msg) => msg.path, + 10000, + ); + } + onPtyData(handler: (detail: { id: string; data: string }) => void): void { this.dataHandlers.add(handler); } diff --git a/lib/src/lib/reconnect.test.ts b/lib/src/lib/reconnect.test.ts index 3e55794..1f0228a 100644 --- a/lib/src/lib/reconnect.test.ts +++ b/lib/src/lib/reconnect.test.ts @@ -28,6 +28,9 @@ function createPlatform(ptys: PtyInfo[], savedState: PersistedSession | null): P killPty: vi.fn(), getCwd: vi.fn(async () => null), getScrollback: vi.fn(async () => null), + readClipboardFilePaths: vi.fn(async () => null), + readClipboardImageAsFilePath: vi.fn(async () => null), + saveDroppedBytesToTempFile: vi.fn(async () => null), onPtyData: vi.fn(), offPtyData: vi.fn(), onPtyExit: vi.fn(), diff --git a/lib/src/lib/session-restore.test.ts b/lib/src/lib/session-restore.test.ts index 5fdc957..d94a7e6 100644 --- a/lib/src/lib/session-restore.test.ts +++ b/lib/src/lib/session-restore.test.ts @@ -25,6 +25,9 @@ function createPlatform(savedState: PersistedSession | null): PlatformAdapter { killPty: vi.fn(), getCwd: vi.fn(async () => null), getScrollback: vi.fn(async () => null), + readClipboardFilePaths: vi.fn(async () => null), + readClipboardImageAsFilePath: vi.fn(async () => null), + saveDroppedBytesToTempFile: vi.fn(async () => null), onPtyData: vi.fn(), offPtyData: vi.fn(), onPtyExit: vi.fn(), diff --git a/lib/src/lib/session-save.test.ts b/lib/src/lib/session-save.test.ts index 2f32ccf..2288f5f 100644 --- a/lib/src/lib/session-save.test.ts +++ b/lib/src/lib/session-save.test.ts @@ -28,6 +28,9 @@ function createPlatform(savedState: PersistedSession | null): PlatformAdapter { getAvailableShells: vi.fn(async () => []), getCwd: vi.fn(async () => '/tmp/live'), getScrollback: vi.fn(async () => 'echo hello\n'), + readClipboardFilePaths: vi.fn(async () => null), + readClipboardImageAsFilePath: vi.fn(async () => null), + saveDroppedBytesToTempFile: vi.fn(async () => null), onPtyData: () => {}, offPtyData: () => {}, onPtyExit: () => {}, diff --git a/lib/src/lib/shell-escape.test.ts b/lib/src/lib/shell-escape.test.ts new file mode 100644 index 0000000..47c23cb --- /dev/null +++ b/lib/src/lib/shell-escape.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { shellEscapePosix, shellEscapeWindows } from './shell-escape'; + +describe('shellEscapePosix', () => { + it('wraps simple paths in single quotes', () => { + expect(shellEscapePosix('/tmp/a.png')).toBe(`'/tmp/a.png'`); + }); + + it('handles spaces', () => { + expect(shellEscapePosix('/tmp/a file.png')).toBe(`'/tmp/a file.png'`); + }); + + it('escapes embedded single quotes', () => { + expect(shellEscapePosix(`it's.png`)).toBe(`'it'\\''s.png'`); + }); + + it('leaves double quotes untouched inside single quotes', () => { + expect(shellEscapePosix('a"b.png')).toBe(`'a"b.png'`); + }); + + it('preserves backslashes as literal', () => { + expect(shellEscapePosix('a\\b.png')).toBe(`'a\\b.png'`); + }); + + it('handles empty string', () => { + expect(shellEscapePosix('')).toBe(`''`); + }); + + it('preserves unicode', () => { + expect(shellEscapePosix('/tmp/café.png')).toBe(`'/tmp/café.png'`); + }); +}); + +describe('shellEscapeWindows', () => { + it('wraps in double quotes', () => { + expect(shellEscapeWindows('C:\\Users\\a.png')).toBe(`"C:\\Users\\a.png"`); + }); + + it('doubles embedded double quotes', () => { + expect(shellEscapeWindows('a"b.png')).toBe(`"a""b.png"`); + }); + + it('handles spaces', () => { + expect(shellEscapeWindows('C:\\a file.png')).toBe(`"C:\\a file.png"`); + }); + + it('handles empty string', () => { + expect(shellEscapeWindows('')).toBe(`""`); + }); +}); diff --git a/lib/src/lib/shell-escape.ts b/lib/src/lib/shell-escape.ts new file mode 100644 index 0000000..c0a2ea1 --- /dev/null +++ b/lib/src/lib/shell-escape.ts @@ -0,0 +1,22 @@ +import { IS_MAC } from './platform'; + +function detectIsWindows(): boolean { + if (typeof navigator === 'undefined') return false; + const nav = navigator as Navigator & { userAgentData?: { platform?: string } }; + const p = (nav.userAgentData?.platform ?? nav.platform ?? nav.userAgent ?? '').toLowerCase(); + return p.includes('win'); +} + +export function shellEscapePosix(input: string): string { + if (input === '') return "''"; + return `'${input.replace(/'/g, `'\\''`)}'`; +} + +export function shellEscapeWindows(input: string): string { + return `"${input.replace(/"/g, '""')}"`; +} + +export function shellEscapePath(input: string): string { + if (!IS_MAC && detectIsWindows()) return shellEscapeWindows(input); + return shellEscapePosix(input); +} diff --git a/standalone/sidecar/clipboard-ops.js b/standalone/sidecar/clipboard-ops.js new file mode 100644 index 0000000..3817c43 --- /dev/null +++ b/standalone/sidecar/clipboard-ops.js @@ -0,0 +1,274 @@ +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { execFile, spawn } = require('node:child_process'); +const { promisify } = require('node:util'); + +const execFileP = promisify(execFile); + +const MAX_BUFFER = 16 * 1024 * 1024; +const DEBUG = process.env.MOUSETERM_DEBUG_CLIPBOARD === '1'; + +function debugLog(...parts) { + if (!DEBUG) return; + try { process.stderr.write(`[clipboard] ${parts.join(' ')}\n`); } catch {} +} + +function tempDropsDir(osModule = os) { + return path.join(osModule.tmpdir(), 'mouseterm-drops'); +} + +function sanitizeFilename(name) { + const base = path.basename(String(name || '') || 'file'); + const clean = base.replace(/[^A-Za-z0-9._-]/g, '_'); + const trimmed = clean.length > 120 ? clean.slice(-120) : clean; + return trimmed || 'file'; +} + +async function ensureDir(dir, fsp) { + await fsp.mkdir(dir, { recursive: true }); +} + +async function fileNonEmpty(p, fsp) { + try { + const st = await fsp.stat(p); + return st.size > 0; + } catch { + return false; + } +} + +async function silentUnlink(p, fsp) { + try { await fsp.unlink(p); } catch {} +} + +function collectSpawnStdout(spawnFn, cmd, args) { + return new Promise((resolve) => { + let child; + try { + child = spawnFn(cmd, args); + } catch { + resolve(null); + return; + } + const chunks = []; + child.stdout.on('data', (c) => chunks.push(c)); + child.on('error', () => resolve(null)); + child.on('close', (code) => { + if (code === 0 && chunks.length > 0) resolve(Buffer.concat(chunks)); + else resolve(null); + }); + }); +} + +const MAC_FILE_PATHS_SCRIPT = [ + 'use framework "AppKit"', + 'use framework "Foundation"', + 'use scripting additions', + 'try', + ' set pb to current application\'s NSPasteboard\'s generalPasteboard()', + ' set urls to pb\'s readObjectsForClasses:{current application\'s NSURL} options:(missing value)', + ' if urls is missing value then return ""', + ' set AppleScript\'s text item delimiters to linefeed', + ' set path_list to {}', + ' repeat with u in urls', + ' if (u\'s isFileURL()) as boolean then', + ' set end of path_list to (u\'s |path|() as text)', + ' end if', + ' end repeat', + ' if (count of path_list) > 0 then return path_list as text', + 'end try', + 'return ""', +].join('\n'); + +async function readFilePathsMac(runtime) { + const exec = runtime.exec || execFileP; + if (DEBUG) { + try { + const { stdout } = await exec('osascript', ['-e', 'clipboard info'], { maxBuffer: MAX_BUFFER }); + debugLog('clipboard info:', JSON.stringify(stdout.trim())); + } catch (err) { + debugLog('clipboard info failed:', err && err.message || err); + } + } + try { + const { stdout } = await exec('osascript', ['-e', MAC_FILE_PATHS_SCRIPT], { maxBuffer: MAX_BUFFER }); + debugLog('files script stdout:', JSON.stringify(stdout)); + return stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean); + } catch (err) { + debugLog('files script error:', err && err.message || err); + return []; + } +} + +async function readFilePathsWindows(runtime) { + const exec = runtime.exec || execFileP; + const cmd = '$out = Get-Clipboard -Format FileDropList; if ($out) { $out | ForEach-Object { $_.FullName } }'; + try { + const { stdout } = await exec( + 'powershell', + ['-NoProfile', '-NonInteractive', '-Command', cmd], + { maxBuffer: MAX_BUFFER }, + ); + return stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean); + } catch { + return []; + } +} + +function parseUriList(stdout) { + return stdout + .split(/\r?\n/) + .map((s) => s.trim()) + .filter((s) => s && !s.startsWith('#')) + .filter((s) => s.startsWith('file://')) + .map((uri) => { + try { return decodeURIComponent(uri.slice('file://'.length)); } + catch { return null; } + }) + .filter(Boolean); +} + +async function readFilePathsLinux(runtime) { + const env = runtime.env || process.env; + const exec = runtime.exec || execFileP; + const wayland = Boolean(env.WAYLAND_DISPLAY); + const attempts = wayland + ? [['wl-paste', ['--type', 'text/uri-list', '--no-newline']], ['xclip', ['-selection', 'clipboard', '-o', '-t', 'text/uri-list']]] + : [['xclip', ['-selection', 'clipboard', '-o', '-t', 'text/uri-list']], ['wl-paste', ['--type', 'text/uri-list', '--no-newline']]]; + + for (const [cmd, args] of attempts) { + try { + const { stdout } = await exec(cmd, args, { maxBuffer: MAX_BUFFER }); + const paths = parseUriList(stdout); + if (paths.length > 0) return paths; + } catch {} + } + return []; +} + +async function readClipboardFilePaths(runtime = {}) { + const platform = runtime.platform || process.platform; + if (platform === 'darwin') return readFilePathsMac(runtime); + if (platform === 'win32') return readFilePathsWindows(runtime); + return readFilePathsLinux(runtime); +} + +function dropsFilePath(osModule, cryptoModule, name) { + return path.join(osModule.tmpdir(), 'mouseterm-drops', `${cryptoModule.randomUUID()}-${name}`); +} + +async function readImageMac(out, runtime) { + const exec = runtime.exec || execFileP; + const script = [ + 'try', + ' set info to clipboard info', + ' repeat with entry in info', + ' if (item 1 of entry) is «class furl» then return ""', + ' end repeat', + 'end try', + 'try', + ` set f to open for access POSIX file "${out.replace(/"/g, '\\"')}" with write permission`, + ' write (the clipboard as «class PNGf») to f', + ' close access f', + ' return "ok"', + 'on error', + ' try', + ' close access f', + ' end try', + ' return ""', + 'end try', + ].join('\n'); + try { + const { stdout } = await exec('osascript', ['-e', script], { maxBuffer: MAX_BUFFER }); + debugLog('image script stdout:', JSON.stringify(stdout)); + return stdout.trim() === 'ok'; + } catch (err) { + debugLog('image script error:', err && err.message || err); + return false; + } +} + +async function readImageWindows(out, runtime) { + const exec = runtime.exec || execFileP; + const cmd = [ + 'Add-Type -AssemblyName System.Windows.Forms;', + 'Add-Type -AssemblyName System.Drawing;', + '$img = [System.Windows.Forms.Clipboard]::GetImage();', + `if ($img) { $img.Save('${out.replace(/'/g, "''")}', [System.Drawing.Imaging.ImageFormat]::Png); 'ok' } else { '' }`, + ].join(' '); + try { + const { stdout } = await exec( + 'powershell', + ['-NoProfile', '-NonInteractive', '-Command', cmd], + { maxBuffer: MAX_BUFFER }, + ); + return stdout.trim() === 'ok'; + } catch { + return false; + } +} + +async function readImageLinux(out, runtime, fsp) { + const env = runtime.env || process.env; + const spawnFn = runtime.spawn || spawn; + const wayland = Boolean(env.WAYLAND_DISPLAY); + const attempts = wayland + ? [['wl-paste', ['--type', 'image/png']], ['xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o']]] + : [['xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o']], ['wl-paste', ['--type', 'image/png']]]; + + for (const [cmd, args] of attempts) { + const buf = await collectSpawnStdout(spawnFn, cmd, args); + if (buf && buf.length > 0) { + await fsp.writeFile(out, buf); + return true; + } + } + return false; +} + +async function readClipboardImageAsFilePath(runtime = {}) { + const platform = runtime.platform || process.platform; + const osModule = runtime.osModule || os; + const cryptoModule = runtime.cryptoModule || crypto; + const fsp = (runtime.fsModule && runtime.fsModule.promises) || fs.promises; + + const out = dropsFilePath(osModule, cryptoModule, 'clipboard.png'); + try { + await ensureDir(path.dirname(out), fsp); + } catch { + return null; + } + + let ok = false; + if (platform === 'darwin') ok = await readImageMac(out, runtime); + else if (platform === 'win32') ok = await readImageWindows(out, runtime); + else ok = await readImageLinux(out, runtime, fsp); + + if (ok && await fileNonEmpty(out, fsp)) return out; + await silentUnlink(out, fsp); + return null; +} + +async function saveDroppedBytesToTempFile(bytes, filename, runtime = {}) { + const osModule = runtime.osModule || os; + const cryptoModule = runtime.cryptoModule || crypto; + const fsp = (runtime.fsModule && runtime.fsModule.promises) || fs.promises; + const dir = tempDropsDir(osModule); + await ensureDir(dir, fsp); + const name = sanitizeFilename(filename); + const out = path.join(dir, `${cryptoModule.randomUUID()}-${name}`); + const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes); + await fsp.writeFile(out, buf); + return out; +} + +module.exports = { + readClipboardFilePaths, + readClipboardImageAsFilePath, + saveDroppedBytesToTempFile, + sanitizeFilename, + tempDropsDir, + parseUriList, +}; diff --git a/standalone/sidecar/clipboard-ops.test.js b/standalone/sidecar/clipboard-ops.test.js new file mode 100644 index 0000000..0c9f007 --- /dev/null +++ b/standalone/sidecar/clipboard-ops.test.js @@ -0,0 +1,232 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const { + readClipboardFilePaths, + readClipboardImageAsFilePath, + saveDroppedBytesToTempFile, + sanitizeFilename, + tempDropsDir, + parseUriList, +} = require('./clipboard-ops'); + +function fakeOs(tmp = '/tmp/test') { + return { tmpdir: () => tmp }; +} + +function fakeCrypto(uuid = 'uuid-0') { + return { randomUUID: () => uuid }; +} + +function fakeFs() { + const writes = []; + const files = new Map(); + const unlinks = []; + return { + writes, + files, + unlinks, + module: { + promises: { + async mkdir() {}, + async writeFile(p, buf) { writes.push([p, buf]); files.set(p, buf); }, + async stat(p) { + const b = files.get(p); + if (!b) throw new Error('ENOENT'); + return { size: b.length }; + }, + async unlink(p) { unlinks.push(p); files.delete(p); }, + }, + }, + }; +} + +test('sanitizeFilename strips weird chars and caps length', () => { + assert.equal(sanitizeFilename('hello.png'), 'hello.png'); + assert.equal(sanitizeFilename("it's a photo.png"), 'it_s_a_photo.png'); + assert.equal(sanitizeFilename('../../etc/passwd'), 'passwd'); + assert.equal(sanitizeFilename(''), 'file'); + assert.equal(sanitizeFilename(null), 'file'); + const long = 'a'.repeat(300) + '.png'; + const out = sanitizeFilename(long); + assert.equal(out.length, 120); + assert.ok(out.endsWith('.png')); +}); + +test('tempDropsDir uses os.tmpdir()/mouseterm-drops', () => { + assert.equal(tempDropsDir(fakeOs('/t')), path.join('/t', 'mouseterm-drops')); +}); + +test('parseUriList decodes file URIs and ignores comments/non-file', () => { + const input = [ + '# comment', + 'file:///Users/me/a%20file.png', + 'file:///tmp/plain.txt', + 'https://example.com/nope', + '', + ].join('\n'); + assert.deepEqual(parseUriList(input), [ + '/Users/me/a file.png', + '/tmp/plain.txt', + ]); +}); + +test('readClipboardFilePaths on mac parses osascript linefeed-separated output', async () => { + const paths = await readClipboardFilePaths({ + platform: 'darwin', + exec: async (cmd, args) => { + assert.equal(cmd, 'osascript'); + assert.equal(args[0], '-e'); + return { stdout: '/Users/me/a.png\n/Users/me/b.jpg\n' }; + }, + }); + assert.deepEqual(paths, ['/Users/me/a.png', '/Users/me/b.jpg']); +}); + +test('readClipboardFilePaths on mac returns [] when osascript fails', async () => { + const paths = await readClipboardFilePaths({ + platform: 'darwin', + exec: async () => { throw new Error('boom'); }, + }); + assert.deepEqual(paths, []); +}); + +test('readClipboardFilePaths on windows parses FileDropList lines', async () => { + const paths = await readClipboardFilePaths({ + platform: 'win32', + exec: async (cmd) => { + assert.equal(cmd, 'powershell'); + return { stdout: 'C:\\a.png\r\nC:\\b.jpg\r\n' }; + }, + }); + assert.deepEqual(paths, ['C:\\a.png', 'C:\\b.jpg']); +}); + +test('readClipboardFilePaths on linux prefers xclip in X11 and parses file URIs', async () => { + const calls = []; + const paths = await readClipboardFilePaths({ + platform: 'linux', + env: {}, + exec: async (cmd, args) => { + calls.push([cmd, args]); + if (cmd === 'xclip') return { stdout: 'file:///tmp/one.png\nfile:///tmp/two.png\n' }; + throw new Error('should not reach'); + }, + }); + assert.deepEqual(paths, ['/tmp/one.png', '/tmp/two.png']); + assert.equal(calls[0][0], 'xclip'); +}); + +test('readClipboardFilePaths on linux prefers wl-paste under Wayland', async () => { + const calls = []; + const paths = await readClipboardFilePaths({ + platform: 'linux', + env: { WAYLAND_DISPLAY: 'wayland-0' }, + exec: async (cmd, args) => { + calls.push([cmd, args]); + if (cmd === 'wl-paste') return { stdout: 'file:///tmp/w.png\n' }; + throw new Error('should not reach'); + }, + }); + assert.deepEqual(paths, ['/tmp/w.png']); + assert.equal(calls[0][0], 'wl-paste'); +}); + +test('readClipboardFilePaths on linux falls back when first tool fails', async () => { + const paths = await readClipboardFilePaths({ + platform: 'linux', + env: {}, + exec: async (cmd) => { + if (cmd === 'xclip') throw new Error('no xclip'); + return { stdout: 'file:///tmp/fb.png\n' }; + }, + }); + assert.deepEqual(paths, ['/tmp/fb.png']); +}); + +test('saveDroppedBytesToTempFile writes sanitized name under temp dir', async () => { + const fs = fakeFs(); + const out = await saveDroppedBytesToTempFile( + new Uint8Array([1, 2, 3]), + "it's shot.png", + { + osModule: fakeOs('/t'), + cryptoModule: fakeCrypto('uuid-A'), + fsModule: fs.module, + }, + ); + assert.equal(out, path.join('/t', 'mouseterm-drops', 'uuid-A-it_s_shot.png')); + assert.equal(fs.writes.length, 1); + assert.equal(fs.writes[0][0], out); + assert.deepEqual(Array.from(fs.writes[0][1]), [1, 2, 3]); +}); + +test('saveDroppedBytesToTempFile accepts Buffer directly', async () => { + const fs = fakeFs(); + const out = await saveDroppedBytesToTempFile( + Buffer.from('hello'), + 'a.txt', + { osModule: fakeOs('/t'), cryptoModule: fakeCrypto('u'), fsModule: fs.module }, + ); + assert.equal(fs.files.get(out).toString(), 'hello'); +}); + +test('readClipboardImageAsFilePath on mac returns temp path on success', async () => { + const fs = fakeFs(); + const result = await readClipboardImageAsFilePath({ + platform: 'darwin', + osModule: fakeOs('/t'), + cryptoModule: fakeCrypto('uuid-I'), + fsModule: fs.module, + exec: async (cmd, args) => { + assert.equal(cmd, 'osascript'); + const [, script] = args; + const match = script.match(/POSIX file "([^"]+)"/); + assert.ok(match, 'script should reference target path'); + fs.files.set(match[1], Buffer.from('fakepng')); + return { stdout: 'ok\n' }; + }, + }); + assert.equal(result, path.join('/t', 'mouseterm-drops', 'uuid-I-clipboard.png')); +}); + +test('readClipboardImageAsFilePath returns null when osascript returns empty', async () => { + const fs = fakeFs(); + const result = await readClipboardImageAsFilePath({ + platform: 'darwin', + osModule: fakeOs('/t'), + cryptoModule: fakeCrypto('uuid-I'), + fsModule: fs.module, + exec: async () => ({ stdout: '' }), + }); + assert.equal(result, null); +}); + +test('readClipboardImageAsFilePath on linux writes buffer from spawn stdout', async () => { + const fs = fakeFs(); + const EventEmitter = require('node:events'); + function fakeSpawn(cmd) { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + setImmediate(() => { + if (cmd === 'xclip') { + child.stdout.emit('data', Buffer.from([0x89, 0x50, 0x4E, 0x47])); + child.emit('close', 0); + } else { + child.emit('close', 1); + } + }); + return child; + } + const result = await readClipboardImageAsFilePath({ + platform: 'linux', + env: {}, + osModule: fakeOs('/t'), + cryptoModule: fakeCrypto('uuid-L'), + fsModule: fs.module, + spawn: fakeSpawn, + }); + assert.equal(result, path.join('/t', 'mouseterm-drops', 'uuid-L-clipboard.png')); + assert.equal(fs.writes.length, 1); +}); diff --git a/standalone/sidecar/main.js b/standalone/sidecar/main.js index ae871dd..0545211 100644 --- a/standalone/sidecar/main.js +++ b/standalone/sidecar/main.js @@ -9,11 +9,25 @@ const readline = require('readline'); const nodePty = require('node-pty'); const { create } = require('./pty-core'); +const clipboard = require('./clipboard-ops'); + +function send(event, data) { + process.stdout.write(JSON.stringify({ event, data }) + '\n'); +} const mgr = create((event, data) => { - process.stdout.write(JSON.stringify({ event: `pty:${event}`, data }) + '\n'); + send(`pty:${event}`, data); }, nodePty); +async function respondAsync(event, requestId, run) { + try { + const data = await run(); + send(event, { ...data, requestId }); + } catch (err) { + send(event, { error: String(err && err.message || err), requestId }); + } +} + const rl = readline.createInterface({ input: process.stdin }); rl.on('line', (line) => { @@ -29,6 +43,24 @@ rl.on('line', (line) => { case 'pty:getScrollback': mgr.getScrollback(data.id, data.requestId); break; case 'pty:getShells': mgr.getShells(data.requestId); break; case 'pty:gracefulKillAll': mgr.gracefulKillAll(data.timeout); break; + case 'clipboard:readFiles': + respondAsync('clipboard:files', data.requestId, async () => ({ + paths: await clipboard.readClipboardFilePaths(), + })); + break; + case 'clipboard:readImage': + respondAsync('clipboard:image', data.requestId, async () => ({ + path: await clipboard.readClipboardImageAsFilePath(), + })); + break; + case 'file:saveBytes': + respondAsync('file:savedBytes', data.requestId, async () => ({ + path: await clipboard.saveDroppedBytesToTempFile( + Buffer.from(data.bytes || []), + data.filename || 'file', + ), + })); + 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 99aad47..bd2aaa1 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -11,7 +11,10 @@ use std::{ sync::{Arc, Mutex}, time::{Duration, SystemTime, UNIX_EPOCH}, }; -use tauri::{AppHandle, Emitter, Manager, RunEvent}; +use tauri::{ + menu::{AboutMetadata, Menu, PredefinedMenuItem, Submenu}, + AppHandle, DragDropEvent, Emitter, Manager, RunEvent, WindowEvent, +}; use tauri_plugin_shell::{process::CommandEvent, ShellExt}; enum SidecarMsg { @@ -213,6 +216,46 @@ fn pty_get_scrollback( .and_then(|data| data.as_str().map(String::from))) } +#[tauri::command] +fn read_clipboard_file_paths( + state: tauri::State<'_, SidecarState>, +) -> Result, String> { + let response = + request_from_sidecar_timeout(&state, "clipboard:readFiles", serde_json::json!({}), Duration::from_secs(5))?; + Ok(response + .get("paths") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default()) +} + +#[tauri::command] +fn read_clipboard_image_as_file_path( + state: tauri::State<'_, SidecarState>, +) -> Result, String> { + let response = + request_from_sidecar_timeout(&state, "clipboard:readImage", serde_json::json!({}), Duration::from_secs(10))?; + Ok(response + .get("path") + .and_then(|path| path.as_str().map(String::from))) +} + +#[tauri::command] +fn save_dropped_bytes_to_temp_file( + state: tauri::State<'_, SidecarState>, + bytes: Vec, + filename: String, +) -> Result, String> { + let response = request_from_sidecar_timeout( + &state, + "file:saveBytes", + serde_json::json!({ "bytes": bytes, "filename": filename }), + Duration::from_secs(10), + )?; + Ok(response + .get("path") + .and_then(|path| path.as_str().map(String::from))) +} + #[tauri::command] fn shutdown_sidecar(state: tauri::State<'_, SidecarState>) { let _ = state.tx.send(SidecarMsg::Shutdown); @@ -311,6 +354,7 @@ fn start_sidecar(app: &AppHandle) -> Result { .sidecar("node") .map_err(|err| format!("failed to resolve bundled Node.js runtime: {err}"))? .arg(&sidecar_arg_path) + .env("MOUSETERM_DEBUG_CLIPBOARD", "1") .set_raw_out(false) .spawn() .map_err(|err| format!("failed to start Node.js sidecar: {err}"))?; @@ -420,6 +464,57 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_updater::Builder::new().build()) + // Replace Tauri's default menu, which binds Cmd+V to a native Paste + // action that fights with the webview's DOM keydown handler. The + // terminal owns Cmd+C / Cmd+V / Cmd+X in JS (see `Pond.tsx`). + .menu(|handle| { + let pkg = handle.package_info(); + let about = AboutMetadata { + name: Some(pkg.name.clone()), + version: Some(pkg.version.to_string()), + ..Default::default() + }; + let mut items: Vec>> = Vec::new(); + #[cfg(target_os = "macos")] + items.push(Box::new(Submenu::with_items( + handle, + pkg.name.clone(), + true, + &[ + &PredefinedMenuItem::about(handle, None, Some(about))?, + &PredefinedMenuItem::separator(handle)?, + &PredefinedMenuItem::services(handle, None)?, + &PredefinedMenuItem::separator(handle)?, + &PredefinedMenuItem::hide(handle, None)?, + &PredefinedMenuItem::hide_others(handle, None)?, + &PredefinedMenuItem::separator(handle)?, + &PredefinedMenuItem::quit(handle, None)?, + ], + )?)); + items.push(Box::new(Submenu::with_items( + handle, + "Window", + true, + &[ + &PredefinedMenuItem::minimize(handle, None)?, + &PredefinedMenuItem::maximize(handle, None)?, + #[cfg(target_os = "macos")] + &PredefinedMenuItem::separator(handle)?, + &PredefinedMenuItem::close_window(handle, None)?, + ], + )?)); + let refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = items.iter().map(|b| b.as_ref()).collect(); + Menu::with_items(handle, &refs) + }) + .on_window_event(|window, event| { + if let WindowEvent::DragDrop(DragDropEvent::Drop { paths, .. }) = event { + let payload: Vec = paths + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + let _ = window.emit("mouseterm://files-dropped", serde_json::json!({ "paths": payload })); + } + }) .setup(|app| { init_log(); append_log("[app] setup started"); @@ -453,6 +548,9 @@ pub fn run() { pty_request_init, shutdown_sidecar, get_available_shells, + read_clipboard_file_paths, + read_clipboard_image_as_file_path, + save_dropped_bytes_to_temp_file, ]) .build(tauri::generate_context!()) .expect("error while building MouseTerm") diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 68c5331..523f44e 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -27,6 +27,7 @@ export class TauriAdapter implements PlatformAdapter { private listHandlers = new Set<(detail: { ptys: PtyInfo[] }) => void>(); private replayHandlers = new Set<(detail: { id: string; data: string }) => void>(); private alarmStateHandlers = new Set<(detail: AlarmStateDetail) => void>(); + private filesDroppedHandlers = new Set<(paths: string[]) => void>(); private unlistenFns: Array<() => void> = []; private alarmManager = new AlarmManager(); @@ -76,6 +77,14 @@ export class TauriAdapter implements PlatformAdapter { } }), ); + + this.unlistenFns.push( + await listen<{ paths: string[] }>("mouseterm://files-dropped", (event) => { + const paths = event.payload.paths ?? []; + if (paths.length === 0) return; + for (const handler of this.filesDroppedHandlers) handler(paths); + }), + ); } shutdown(): void { @@ -121,6 +130,32 @@ export class TauriAdapter implements PlatformAdapter { } catch { return null; } } + async readClipboardFilePaths(): Promise { + try { + return await rawInvoke("read_clipboard_file_paths"); + } catch { return null; } + } + + async readClipboardImageAsFilePath(): Promise { + try { + return await rawInvoke("read_clipboard_image_as_file_path"); + } catch { return null; } + } + + async saveDroppedBytesToTempFile(bytes: Uint8Array, filename: string): Promise { + try { + return await rawInvoke("save_dropped_bytes_to_temp_file", { + bytes: Array.from(bytes), + filename, + }); + } catch { return null; } + } + + onFilesDropped(handler: (paths: string[]) => void): () => void { + this.filesDroppedHandlers.add(handler); + return () => { this.filesDroppedHandlers.delete(handler); }; + } + onPtyData(handler: (detail: { id: string; data: string }) => void): void { this.dataHandlers.add(handler); } diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts index 0c20fc6..5b78c9d 100644 --- a/vscode-ext/src/extension.ts +++ b/vscode-ext/src/extension.ts @@ -78,7 +78,12 @@ export function activate(context: vscode.ExtensionContext) { }); context.subscriptions.push( - vscode.window.registerWebviewViewProvider('mouseterm.view', provider), + vscode.window.registerWebviewViewProvider('mouseterm.view', provider, { + // Keep the webview script + xterm DOM alive when the Panel is hidden + // (close/toggle), so PTYs and scrollback are preserved across re-show + // without going through the reconnect dance. + webviewOptions: { retainContextWhenHidden: true }, + }), vscode.window.registerWebviewPanelSerializer('mouseterm', { async deserializeWebviewPanel(panel: vscode.WebviewPanel, state: unknown) { setupPanel(context, panel, state, () => provider.getSelectedShell()); diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index e3bc55c..fc0928d 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -5,6 +5,12 @@ import type { PersistedSession } from '../../lib/src/lib/session-types'; import type { WebviewMessage, ExtensionMessage } from './message-types'; import { log } from './log'; +const clipboardOps = require('../../lib/clipboard-ops.cjs') as { + readClipboardFilePaths(): Promise; + readClipboardImageAsFilePath(): Promise; + saveDroppedBytesToTempFile(bytes: Buffer | Uint8Array, filename: string): Promise; +}; + // Global set of PTY IDs claimed by any router instance. // Prevents reconnecting routers from stealing PTYs owned by other webviews. const globalOwnedPtyIds = new Set(); @@ -181,6 +187,36 @@ export function attachRouter( } satisfies ExtensionMessage); }); break; + case 'clipboard:readFiles': + clipboardOps.readClipboardFilePaths() + .then((paths) => webview.postMessage({ + type: 'clipboard:files', paths: paths.length ? paths : null, requestId: msg.requestId, + } satisfies ExtensionMessage)) + .catch((err) => { + log.info(`[clipboard] readFiles failed: ${err?.message ?? err}`); + webview.postMessage({ type: 'clipboard:files', paths: null, requestId: msg.requestId } satisfies ExtensionMessage); + }); + break; + case 'clipboard:readImage': + clipboardOps.readClipboardImageAsFilePath() + .then((path) => webview.postMessage({ + type: 'clipboard:image', path, requestId: msg.requestId, + } satisfies ExtensionMessage)) + .catch((err) => { + log.info(`[clipboard] readImage failed: ${err?.message ?? err}`); + webview.postMessage({ type: 'clipboard:image', path: null, requestId: msg.requestId } satisfies ExtensionMessage); + }); + break; + case 'file:saveBytes': + clipboardOps.saveDroppedBytesToTempFile(Buffer.from(msg.bytes), msg.filename) + .then((path) => webview.postMessage({ + type: 'file:savedBytes', path, requestId: msg.requestId, + } satisfies ExtensionMessage)) + .catch((err) => { + log.info(`[clipboard] saveBytes failed: ${err?.message ?? err}`); + webview.postMessage({ type: 'file:savedBytes', path: null, requestId: msg.requestId } satisfies ExtensionMessage); + }); + break; case 'mouseterm:init': { // Webview has (re-)initialized — subscribe to live events. // Tear down previous subscriptions first (webview was destroyed and recreated). diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index 1136145..ba8679b 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -9,6 +9,9 @@ export type WebviewMessage = | { type: 'pty:getCwd'; id: string; requestId?: string } | { type: 'pty:getScrollback'; id: string; requestId?: string } | { type: 'pty:getShells'; requestId?: string } + | { type: 'clipboard:readFiles'; requestId: string } + | { type: 'clipboard:readImage'; requestId: string } + | { type: 'file:saveBytes'; filename: string; bytes: number[]; requestId: string } | { type: 'mouseterm:init' } | { type: 'mouseterm:saveState'; state: unknown } | { type: 'mouseterm:flushSessionSaveDone'; requestId: string } @@ -41,6 +44,9 @@ export type ExtensionMessage = | { type: 'pty:cwd'; id: string; cwd: string | null; requestId?: string } | { type: 'pty:scrollback'; id: string; data: string | null; requestId?: string } | { type: 'pty:shells'; shells: Array<{ name: string; path: string; args: string[] }>; requestId?: string } + | { type: 'clipboard:files'; paths: string[] | null; requestId: string } + | { type: 'clipboard:image'; path: string | null; requestId: string } + | { type: 'file:savedBytes'; path: string | null; requestId: string } | { type: 'mouseterm:newTerminal'; shell?: string; args?: string[] } | { type: 'mouseterm:selectedShell'; shell?: string; args?: string[] } | { type: 'mouseterm:flushSessionSave'; requestId: string } diff --git a/vscode-ext/src/webview-view-provider.ts b/vscode-ext/src/webview-view-provider.ts index 15917c3..457713f 100644 --- a/vscode-ext/src/webview-view-provider.ts +++ b/vscode-ext/src/webview-view-provider.ts @@ -6,6 +6,7 @@ import { getSavedSessionState, saveSessionState, mergeAlarmStates } from './sess import type { ExtensionMessage } from './message-types'; import * as ptyManager from './pty-manager'; import { resolveSelectedShell } from './shell-selection'; +import { log } from './log'; export class MouseTermViewProvider implements vscode.WebviewViewProvider { private view: vscode.WebviewView | undefined; @@ -79,6 +80,7 @@ export class MouseTermViewProvider implements vscode.WebviewViewProvider { }); view.onDidDispose(() => { + log.info('[view] onDidDispose fired — releasing router (PTYs remain alive)'); this.routerDisposable?.dispose(); this.routerDisposable = undefined; this.view = undefined; From 375006f1c737eebbbfa396290556d336c658eba4 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 22 Apr 2026 12:28:54 -0700 Subject: [PATCH 06/17] copy-paste matches mac-native behavior --- lib/src/lib/shell-escape.test.ts | 50 ++++++++++++++++++++++++-------- lib/src/lib/shell-escape.ts | 8 ++++- standalone/src-tauri/src/lib.rs | 1 - 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/lib/src/lib/shell-escape.test.ts b/lib/src/lib/shell-escape.test.ts index 47c23cb..3ed135d 100644 --- a/lib/src/lib/shell-escape.test.ts +++ b/lib/src/lib/shell-escape.test.ts @@ -2,32 +2,58 @@ import { describe, it, expect } from 'vitest'; import { shellEscapePosix, shellEscapeWindows } from './shell-escape'; describe('shellEscapePosix', () => { - it('wraps simple paths in single quotes', () => { - expect(shellEscapePosix('/tmp/a.png')).toBe(`'/tmp/a.png'`); + it('leaves safe paths untouched', () => { + expect(shellEscapePosix('/tmp/a.png')).toBe('/tmp/a.png'); }); - it('handles spaces', () => { - expect(shellEscapePosix('/tmp/a file.png')).toBe(`'/tmp/a file.png'`); + it('backslash-escapes spaces', () => { + expect(shellEscapePosix('/tmp/a file.png')).toBe('/tmp/a\\ file.png'); + }); + + it('backslash-escapes multiple spaces', () => { + expect(shellEscapePosix('a b c')).toBe('a\\ b\\ c'); }); - it('escapes embedded single quotes', () => { - expect(shellEscapePosix(`it's.png`)).toBe(`'it'\\''s.png'`); + it('backslash-escapes single quotes', () => { + expect(shellEscapePosix(`it's.png`)).toBe(`it\\'s.png`); }); - it('leaves double quotes untouched inside single quotes', () => { - expect(shellEscapePosix('a"b.png')).toBe(`'a"b.png'`); + it('backslash-escapes double quotes', () => { + expect(shellEscapePosix('a"b.png')).toBe('a\\"b.png'); }); - it('preserves backslashes as literal', () => { - expect(shellEscapePosix('a\\b.png')).toBe(`'a\\b.png'`); + it('backslash-escapes backslashes', () => { + expect(shellEscapePosix('a\\b.png')).toBe('a\\\\b.png'); + }); + + it('backslash-escapes shell metacharacters', () => { + expect(shellEscapePosix('a$b')).toBe('a\\$b'); + expect(shellEscapePosix('a`b')).toBe('a\\`b'); + expect(shellEscapePosix('a&b')).toBe('a\\&b'); + expect(shellEscapePosix('a|b')).toBe('a\\|b'); + expect(shellEscapePosix('a;b')).toBe('a\\;b'); + expect(shellEscapePosix('a(b)c')).toBe('a\\(b\\)c'); + expect(shellEscapePosix('ac')).toBe('a\\c'); + expect(shellEscapePosix('a[b]c')).toBe('a\\[b\\]c'); + expect(shellEscapePosix('a{b}c')).toBe('a\\{b\\}c'); + expect(shellEscapePosix('a*b')).toBe('a\\*b'); + expect(shellEscapePosix('a?b')).toBe('a\\?b'); + expect(shellEscapePosix('a#b')).toBe('a\\#b'); + expect(shellEscapePosix('a~b')).toBe('a\\~b'); + expect(shellEscapePosix('a!b')).toBe('a\\!b'); }); it('handles empty string', () => { expect(shellEscapePosix('')).toBe(`''`); }); - it('preserves unicode', () => { - expect(shellEscapePosix('/tmp/café.png')).toBe(`'/tmp/café.png'`); + it('preserves unicode (narrow no-break space is not U+0020 — stays)', () => { + expect(shellEscapePosix('/tmp/café.png')).toBe('/tmp/café.png'); + expect(shellEscapePosix('a b')).toBe('a b'); + }); + + it('preserves safe punctuation', () => { + expect(shellEscapePosix('/a-b_c.d+e,f%g@h:i=j/k.png')).toBe('/a-b_c.d+e,f%g@h:i=j/k.png'); }); }); diff --git a/lib/src/lib/shell-escape.ts b/lib/src/lib/shell-escape.ts index c0a2ea1..3531e30 100644 --- a/lib/src/lib/shell-escape.ts +++ b/lib/src/lib/shell-escape.ts @@ -7,9 +7,15 @@ function detectIsWindows(): boolean { return p.includes('win'); } +// Matches macOS Terminal's drag-and-drop format: backslash-escape each shell +// metacharacter instead of wrapping in quotes. TUIs like `claude` recognize +// backslash-escaped tokens as filesystem paths where a single-quoted whole +// path gets treated as opaque pasted text. +const POSIX_UNSAFE = /([ \t\n!"#$&'()*;<>?[\\\]`{|}~])/g; + export function shellEscapePosix(input: string): string { if (input === '') return "''"; - return `'${input.replace(/'/g, `'\\''`)}'`; + return input.replace(POSIX_UNSAFE, '\\$1'); } export function shellEscapeWindows(input: string): string { diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index bd2aaa1..8bbc3fd 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -354,7 +354,6 @@ fn start_sidecar(app: &AppHandle) -> Result { .sidecar("node") .map_err(|err| format!("failed to resolve bundled Node.js runtime: {err}"))? .arg(&sidecar_arg_path) - .env("MOUSETERM_DEBUG_CLIPBOARD", "1") .set_raw_out(false) .spawn() .map_err(|err| format!("failed to start Node.js sidecar: {err}"))?; From f9193c5338c9265517480c534d2a832e21bd7207 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Wed, 22 Apr 2026 19:56:31 +0000 Subject: [PATCH 07/17] Codex review R1: fix spec/code mismatch on paste tier order, escape \\r in shellEscapePosix --- docs/specs/mouse-and-clipboard.md | 6 +++--- lib/src/lib/shell-escape.test.ts | 4 ++++ lib/src/lib/shell-escape.ts | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index 3740959..2a9113f 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -302,9 +302,9 @@ The bracketed-paste mode is read at paste time from xterm's public `terminal.mod Paste reads the clipboard in three tiers, falling through in order: -1. **Plain text.** `navigator.clipboard.readText()`. If non-empty, the string is written to the PTY (with bracketed-paste wrapping when enabled by the inside program). -2. **File references.** If the clipboard has no text but carries OS file references (Finder/Explorer Copy of a file), each path is shell-escaped and the space-joined list is written to the PTY with a trailing space so the next token starts cleanly. -3. **Raw image data.** If neither of the above matches and the clipboard holds image bytes (e.g. a `Cmd+Shift+4` screenshot), the bytes are written to `$TMPDIR/mouseterm-drops/.png` and that single path is pasted as in tier 2. +1. **File references.** The platform adapter checks for OS file references (Finder/Explorer Copy of a file) via the sidecar/extension host. If present, each path is shell-escaped and the space-joined list is written to the PTY with a trailing space so the next token starts cleanly. Files are checked first 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. +2. **Plain text.** `navigator.clipboard.readText()`. If non-empty, the string is written to the PTY (with bracketed-paste wrapping when enabled by the inside program). +3. **Raw image data.** If neither of the above matches and the clipboard holds image bytes (e.g. a `Cmd+Shift+4` screenshot), the bytes are written to `$TMPDIR/mouseterm-drops/.png` and that single path is pasted as in tier 1. Each tier is implemented by a shared Node module (`standalone/sidecar/clipboard-ops.js`) that shells out to the OS-native clipboard tool: `osascript` on macOS, `Get-Clipboard` on Windows, `wl-paste`/`xclip` on Linux. The Tauri build reaches it through the existing sidecar; the VSCode build calls into the same module from its extension host. If every tier comes back empty, paste is a silent no-op. diff --git a/lib/src/lib/shell-escape.test.ts b/lib/src/lib/shell-escape.test.ts index 3ed135d..c7f2619 100644 --- a/lib/src/lib/shell-escape.test.ts +++ b/lib/src/lib/shell-escape.test.ts @@ -26,6 +26,10 @@ describe('shellEscapePosix', () => { expect(shellEscapePosix('a\\b.png')).toBe('a\\\\b.png'); }); + it('backslash-escapes carriage returns (security: prevents command injection)', () => { + expect(shellEscapePosix('a\rb')).toBe('a\\\rb'); + }); + it('backslash-escapes shell metacharacters', () => { expect(shellEscapePosix('a$b')).toBe('a\\$b'); expect(shellEscapePosix('a`b')).toBe('a\\`b'); diff --git a/lib/src/lib/shell-escape.ts b/lib/src/lib/shell-escape.ts index 3531e30..33afc8a 100644 --- a/lib/src/lib/shell-escape.ts +++ b/lib/src/lib/shell-escape.ts @@ -11,7 +11,7 @@ function detectIsWindows(): boolean { // metacharacter instead of wrapping in quotes. TUIs like `claude` recognize // backslash-escaped tokens as filesystem paths where a single-quoted whole // path gets treated as opaque pasted text. -const POSIX_UNSAFE = /([ \t\n!"#$&'()*;<>?[\\\]`{|}~])/g; +const POSIX_UNSAFE = /([ \t\n\r!"#$&'()*;<>?[\\\]`{|}~])/g; export function shellEscapePosix(input: string): string { if (input === '') return "''"; From c3345b480a8cbf170e71e5d9bd48b656cdaa459d Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Wed, 22 Apr 2026 15:33:12 -0700 Subject: [PATCH 08/17] Drop VSCode drag-to-paste: WebviewView can't receive OS file drops. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit microsoft/vscode#111092 closed this as out-of-scope. The DOM onDrop handler on TerminalPane never fired in either build anyway (Tauri intercepts natively via WindowEvent::DragDrop). Remove the dead saveDroppedBytesToTempFile plumbing across platform adapters, message router, Tauri command, sidecar, and tests; update spec §8.7 / §9.2. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/specs/mouse-and-clipboard.md | 6 ++-- lib/src/components/TerminalPane.tsx | 27 +------------- lib/src/lib/platform/fake-adapter.ts | 1 - lib/src/lib/platform/types.ts | 3 +- lib/src/lib/platform/vscode-adapter.ts | 9 ----- lib/src/lib/reconnect.test.ts | 1 - lib/src/lib/session-restore.test.ts | 1 - lib/src/lib/session-save.test.ts | 1 - standalone/sidecar/clipboard-ops.js | 27 -------------- standalone/sidecar/clipboard-ops.test.js | 46 ------------------------ standalone/sidecar/main.js | 8 ----- standalone/src-tauri/src/lib.rs | 18 ---------- standalone/src/tauri-adapter.ts | 9 ----- vscode-ext/src/message-router.ts | 11 ------ vscode-ext/src/message-types.ts | 2 -- 15 files changed, 6 insertions(+), 164 deletions(-) diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index 2a9113f..fc7f755 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -312,7 +312,9 @@ Content-aware transformations, paste history, credential warnings, and middle-cl ### 8.7 Drag-to-Paste -Dragging files onto a terminal pane mirrors the paste chain above: escaped paths are typed at the current prompt, space-joined with a trailing space. Tauri receives the drop natively via `WindowEvent::DragDrop` and routes paths to the focused pane. VSCode webviews are sandboxed — `File.path` is not exposed — so bytes are copied into `$TMPDIR/mouseterm-drops/-` and that temp path is pasted. This mismatch is intentional: under Tauri the original path is preserved; under VSCode the user gets a usable path with a tolerable byte-copy cost. +Dragging files onto a terminal pane mirrors the paste chain above: escaped paths are typed at the current prompt, space-joined with a trailing space. Tauri receives the drop natively via `WindowEvent::DragDrop` and routes paths to the focused pane. + +Drag-to-paste is **not supported in the VSCode build**: VSCode's `WebviewView` (sidebar/panel) is excluded from external-file drop routing by the workbench, so the webview iframe never receives `dragover`/`drop` events for files dragged from the OS. See §9.2. VSCode users paste instead (§8.1/§8.5). ### 8.8 Right-Click and Menu Paste @@ -348,4 +350,4 @@ The following are explicitly not implemented today; they may be added in respons - A "literal next keystroke" terminal-level shortcut (Ctrl+Alt+V or similar) for programs that don't support Ctrl+Q-style `quoted-insert`. - Middle-click paste / X11 PRIMARY selection integration on Linux. - Drop-position-aware pane routing (currently drops always go to the focused pane). -- Preserving the original path when dragging into the VSCode build (blocked by webview sandboxing of `File.path`). +- Drag-to-paste in the VSCode build. `WebviewView` is excluded from external-file drop routing by the workbench and there is no API to opt in (see [microsoft/vscode#111092](https://github.com/microsoft/vscode/issues/111092), closed as out-of-scope). Users paste via Ctrl+V / Cmd+V instead. diff --git a/lib/src/components/TerminalPane.tsx b/lib/src/components/TerminalPane.tsx index 419f8f9..ea4d8e0 100644 --- a/lib/src/components/TerminalPane.tsx +++ b/lib/src/components/TerminalPane.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useEffect, useRef } from 'react'; import '@xterm/xterm/css/xterm.css'; import { getOrCreateTerminal, @@ -7,8 +7,6 @@ import { refitTerminal, focusTerminal, } from '../lib/terminal-registry'; -import { pasteFilePaths } from '../lib/clipboard'; -import { getPlatform } from '../lib/platform'; import { SelectionOverlay } from './SelectionOverlay'; import { SelectionPopup } from './SelectionPopup'; @@ -51,33 +49,10 @@ export function TerminalPane({ id, isFocused = true }: TerminalPaneProps) { focusTerminal(id, isFocused); }, [id, isFocused]); - const onDragOver = useCallback((e: React.DragEvent) => { - if (e.dataTransfer.types.includes('Files')) { - e.preventDefault(); - e.dataTransfer.dropEffect = 'copy'; - } - }, []); - - const onDrop = useCallback(async (e: React.DragEvent) => { - const files = Array.from(e.dataTransfer.files); - if (files.length === 0) return; - e.preventDefault(); - const platform = getPlatform(); - const paths: string[] = []; - for (const file of files) { - const bytes = new Uint8Array(await file.arrayBuffer()); - const p = await platform.saveDroppedBytesToTempFile(bytes, file.name); - if (p) paths.push(p); - } - if (paths.length > 0) pasteFilePaths(id, paths); - }, [id]); - return (
diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index 155fcd1..fffafbc 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -129,7 +129,6 @@ export class FakePtyAdapter implements PlatformAdapter { async readClipboardFilePaths(): Promise { return null; } async readClipboardImageAsFilePath(): Promise { return null; } - async saveDroppedBytesToTempFile(_bytes: Uint8Array, _filename: string): Promise { return null; } requestInit(): void {} onPtyList(_handler: (detail: { ptys: PtyInfo[] }) => void): void {} diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index 82cba6c..ffeed8e 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -26,10 +26,9 @@ export interface PlatformAdapter { getCwd(id: string): Promise; getScrollback(id: string): Promise; - // Clipboard and drag-drop support for file references and raw images. + // Clipboard support for file references and raw images. readClipboardFilePaths(): Promise; readClipboardImageAsFilePath(): Promise; - saveDroppedBytesToTempFile(bytes: Uint8Array, filename: string): Promise; // Only present on adapters with a native (non-DOM) drag-drop source. onFilesDropped?(handler: (paths: string[]) => void): () => void; diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 80d4ef2..d84ca4e 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -146,15 +146,6 @@ export class VSCodeAdapter implements PlatformAdapter { ); } - saveDroppedBytesToTempFile(bytes: Uint8Array, filename: string): Promise { - return this.requestResponse( - 'file:saveBytes', 'file:savedBytes', - { filename, bytes: Array.from(bytes) }, - (msg) => msg.path, - 10000, - ); - } - onPtyData(handler: (detail: { id: string; data: string }) => void): void { this.dataHandlers.add(handler); } diff --git a/lib/src/lib/reconnect.test.ts b/lib/src/lib/reconnect.test.ts index 1f0228a..26d4bbf 100644 --- a/lib/src/lib/reconnect.test.ts +++ b/lib/src/lib/reconnect.test.ts @@ -30,7 +30,6 @@ function createPlatform(ptys: PtyInfo[], savedState: PersistedSession | null): P getScrollback: vi.fn(async () => null), readClipboardFilePaths: vi.fn(async () => null), readClipboardImageAsFilePath: vi.fn(async () => null), - saveDroppedBytesToTempFile: vi.fn(async () => null), onPtyData: vi.fn(), offPtyData: vi.fn(), onPtyExit: vi.fn(), diff --git a/lib/src/lib/session-restore.test.ts b/lib/src/lib/session-restore.test.ts index d94a7e6..6371c93 100644 --- a/lib/src/lib/session-restore.test.ts +++ b/lib/src/lib/session-restore.test.ts @@ -27,7 +27,6 @@ function createPlatform(savedState: PersistedSession | null): PlatformAdapter { getScrollback: vi.fn(async () => null), readClipboardFilePaths: vi.fn(async () => null), readClipboardImageAsFilePath: vi.fn(async () => null), - saveDroppedBytesToTempFile: vi.fn(async () => null), onPtyData: vi.fn(), offPtyData: vi.fn(), onPtyExit: vi.fn(), diff --git a/lib/src/lib/session-save.test.ts b/lib/src/lib/session-save.test.ts index 2288f5f..b8f77c8 100644 --- a/lib/src/lib/session-save.test.ts +++ b/lib/src/lib/session-save.test.ts @@ -30,7 +30,6 @@ function createPlatform(savedState: PersistedSession | null): PlatformAdapter { getScrollback: vi.fn(async () => 'echo hello\n'), readClipboardFilePaths: vi.fn(async () => null), readClipboardImageAsFilePath: vi.fn(async () => null), - saveDroppedBytesToTempFile: vi.fn(async () => null), onPtyData: () => {}, offPtyData: () => {}, onPtyExit: () => {}, diff --git a/standalone/sidecar/clipboard-ops.js b/standalone/sidecar/clipboard-ops.js index 3817c43..cd93e40 100644 --- a/standalone/sidecar/clipboard-ops.js +++ b/standalone/sidecar/clipboard-ops.js @@ -15,17 +15,6 @@ function debugLog(...parts) { try { process.stderr.write(`[clipboard] ${parts.join(' ')}\n`); } catch {} } -function tempDropsDir(osModule = os) { - return path.join(osModule.tmpdir(), 'mouseterm-drops'); -} - -function sanitizeFilename(name) { - const base = path.basename(String(name || '') || 'file'); - const clean = base.replace(/[^A-Za-z0-9._-]/g, '_'); - const trimmed = clean.length > 120 ? clean.slice(-120) : clean; - return trimmed || 'file'; -} - async function ensureDir(dir, fsp) { await fsp.mkdir(dir, { recursive: true }); } @@ -251,24 +240,8 @@ async function readClipboardImageAsFilePath(runtime = {}) { return null; } -async function saveDroppedBytesToTempFile(bytes, filename, runtime = {}) { - const osModule = runtime.osModule || os; - const cryptoModule = runtime.cryptoModule || crypto; - const fsp = (runtime.fsModule && runtime.fsModule.promises) || fs.promises; - const dir = tempDropsDir(osModule); - await ensureDir(dir, fsp); - const name = sanitizeFilename(filename); - const out = path.join(dir, `${cryptoModule.randomUUID()}-${name}`); - const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes); - await fsp.writeFile(out, buf); - return out; -} - module.exports = { readClipboardFilePaths, readClipboardImageAsFilePath, - saveDroppedBytesToTempFile, - sanitizeFilename, - tempDropsDir, parseUriList, }; diff --git a/standalone/sidecar/clipboard-ops.test.js b/standalone/sidecar/clipboard-ops.test.js index 0c9f007..9d5912b 100644 --- a/standalone/sidecar/clipboard-ops.test.js +++ b/standalone/sidecar/clipboard-ops.test.js @@ -5,9 +5,6 @@ const path = require('node:path'); const { readClipboardFilePaths, readClipboardImageAsFilePath, - saveDroppedBytesToTempFile, - sanitizeFilename, - tempDropsDir, parseUriList, } = require('./clipboard-ops'); @@ -42,22 +39,6 @@ function fakeFs() { }; } -test('sanitizeFilename strips weird chars and caps length', () => { - assert.equal(sanitizeFilename('hello.png'), 'hello.png'); - assert.equal(sanitizeFilename("it's a photo.png"), 'it_s_a_photo.png'); - assert.equal(sanitizeFilename('../../etc/passwd'), 'passwd'); - assert.equal(sanitizeFilename(''), 'file'); - assert.equal(sanitizeFilename(null), 'file'); - const long = 'a'.repeat(300) + '.png'; - const out = sanitizeFilename(long); - assert.equal(out.length, 120); - assert.ok(out.endsWith('.png')); -}); - -test('tempDropsDir uses os.tmpdir()/mouseterm-drops', () => { - assert.equal(tempDropsDir(fakeOs('/t')), path.join('/t', 'mouseterm-drops')); -}); - test('parseUriList decodes file URIs and ignores comments/non-file', () => { const input = [ '# comment', @@ -145,33 +126,6 @@ test('readClipboardFilePaths on linux falls back when first tool fails', async ( assert.deepEqual(paths, ['/tmp/fb.png']); }); -test('saveDroppedBytesToTempFile writes sanitized name under temp dir', async () => { - const fs = fakeFs(); - const out = await saveDroppedBytesToTempFile( - new Uint8Array([1, 2, 3]), - "it's shot.png", - { - osModule: fakeOs('/t'), - cryptoModule: fakeCrypto('uuid-A'), - fsModule: fs.module, - }, - ); - assert.equal(out, path.join('/t', 'mouseterm-drops', 'uuid-A-it_s_shot.png')); - assert.equal(fs.writes.length, 1); - assert.equal(fs.writes[0][0], out); - assert.deepEqual(Array.from(fs.writes[0][1]), [1, 2, 3]); -}); - -test('saveDroppedBytesToTempFile accepts Buffer directly', async () => { - const fs = fakeFs(); - const out = await saveDroppedBytesToTempFile( - Buffer.from('hello'), - 'a.txt', - { osModule: fakeOs('/t'), cryptoModule: fakeCrypto('u'), fsModule: fs.module }, - ); - assert.equal(fs.files.get(out).toString(), 'hello'); -}); - test('readClipboardImageAsFilePath on mac returns temp path on success', async () => { const fs = fakeFs(); const result = await readClipboardImageAsFilePath({ diff --git a/standalone/sidecar/main.js b/standalone/sidecar/main.js index 0545211..b247dd6 100644 --- a/standalone/sidecar/main.js +++ b/standalone/sidecar/main.js @@ -53,14 +53,6 @@ rl.on('line', (line) => { path: await clipboard.readClipboardImageAsFilePath(), })); break; - case 'file:saveBytes': - respondAsync('file:savedBytes', data.requestId, async () => ({ - path: await clipboard.saveDroppedBytesToTempFile( - Buffer.from(data.bytes || []), - data.filename || 'file', - ), - })); - 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 8bbc3fd..b8e04a9 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -239,23 +239,6 @@ fn read_clipboard_image_as_file_path( .and_then(|path| path.as_str().map(String::from))) } -#[tauri::command] -fn save_dropped_bytes_to_temp_file( - state: tauri::State<'_, SidecarState>, - bytes: Vec, - filename: String, -) -> Result, String> { - let response = request_from_sidecar_timeout( - &state, - "file:saveBytes", - serde_json::json!({ "bytes": bytes, "filename": filename }), - Duration::from_secs(10), - )?; - Ok(response - .get("path") - .and_then(|path| path.as_str().map(String::from))) -} - #[tauri::command] fn shutdown_sidecar(state: tauri::State<'_, SidecarState>) { let _ = state.tx.send(SidecarMsg::Shutdown); @@ -549,7 +532,6 @@ pub fn run() { get_available_shells, read_clipboard_file_paths, read_clipboard_image_as_file_path, - save_dropped_bytes_to_temp_file, ]) .build(tauri::generate_context!()) .expect("error while building MouseTerm") diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 523f44e..950c4bb 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -142,15 +142,6 @@ export class TauriAdapter implements PlatformAdapter { } catch { return null; } } - async saveDroppedBytesToTempFile(bytes: Uint8Array, filename: string): Promise { - try { - return await rawInvoke("save_dropped_bytes_to_temp_file", { - bytes: Array.from(bytes), - filename, - }); - } catch { return null; } - } - onFilesDropped(handler: (paths: string[]) => void): () => void { this.filesDroppedHandlers.add(handler); return () => { this.filesDroppedHandlers.delete(handler); }; diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index fc0928d..4f117ce 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -8,7 +8,6 @@ import { log } from './log'; const clipboardOps = require('../../lib/clipboard-ops.cjs') as { readClipboardFilePaths(): Promise; readClipboardImageAsFilePath(): Promise; - saveDroppedBytesToTempFile(bytes: Buffer | Uint8Array, filename: string): Promise; }; // Global set of PTY IDs claimed by any router instance. @@ -207,16 +206,6 @@ export function attachRouter( webview.postMessage({ type: 'clipboard:image', path: null, requestId: msg.requestId } satisfies ExtensionMessage); }); break; - case 'file:saveBytes': - clipboardOps.saveDroppedBytesToTempFile(Buffer.from(msg.bytes), msg.filename) - .then((path) => webview.postMessage({ - type: 'file:savedBytes', path, requestId: msg.requestId, - } satisfies ExtensionMessage)) - .catch((err) => { - log.info(`[clipboard] saveBytes failed: ${err?.message ?? err}`); - webview.postMessage({ type: 'file:savedBytes', path: null, requestId: msg.requestId } satisfies ExtensionMessage); - }); - break; case 'mouseterm:init': { // Webview has (re-)initialized — subscribe to live events. // Tear down previous subscriptions first (webview was destroyed and recreated). diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index ba8679b..8eb180c 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -11,7 +11,6 @@ export type WebviewMessage = | { type: 'pty:getShells'; requestId?: string } | { type: 'clipboard:readFiles'; requestId: string } | { type: 'clipboard:readImage'; requestId: string } - | { type: 'file:saveBytes'; filename: string; bytes: number[]; requestId: string } | { type: 'mouseterm:init' } | { type: 'mouseterm:saveState'; state: unknown } | { type: 'mouseterm:flushSessionSaveDone'; requestId: string } @@ -46,7 +45,6 @@ export type ExtensionMessage = | { type: 'pty:shells'; shells: Array<{ name: string; path: string; args: string[] }>; requestId?: string } | { type: 'clipboard:files'; paths: string[] | null; requestId: string } | { type: 'clipboard:image'; path: string | null; requestId: string } - | { type: 'file:savedBytes'; path: string | null; requestId: string } | { type: 'mouseterm:newTerminal'; shell?: string; args?: string[] } | { type: 'mouseterm:selectedShell'; shell?: string; args?: string[] } | { type: 'mouseterm:flushSessionSave'; requestId: string } From 61f3d6b4b7792559dc0257fe4cbdfa2df9af3c11 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Wed, 22 Apr 2026 22:57:45 +0000 Subject: [PATCH 09/17] Claude Code simplify: trim clipboard-ops helpers and per-call Windows detect - Drop DEBUG/debugLog infrastructure, the ensureDir/fileNonEmpty/silentUnlink/ dropsFilePath/collectSpawnStdout wrappers, and the parallel `spawn` code path in the linux image branch. Use execFile with { encoding: 'buffer' } to reuse the single `exec` runtime hook for binary output, and inline mkdir/stat/unlink in readClipboardImageAsFilePath. - Compute IS_WINDOWS once at module load in shell-escape.ts instead of sniffing navigator on every shellEscapePath call, matching IS_MAC's pattern. Net: ~75 fewer lines in clipboard-ops.js; all 249 lib tests and all 10 clipboard-ops tests still pass. Co-Authored-By: Claude Opus 4.7 --- lib/src/lib/shell-escape.ts | 7 +- standalone/sidecar/clipboard-ops.js | 99 +++++------------------- standalone/sidecar/clipboard-ops.test.js | 21 ++--- 3 files changed, 27 insertions(+), 100 deletions(-) diff --git a/lib/src/lib/shell-escape.ts b/lib/src/lib/shell-escape.ts index 33afc8a..a99996a 100644 --- a/lib/src/lib/shell-escape.ts +++ b/lib/src/lib/shell-escape.ts @@ -1,11 +1,11 @@ import { IS_MAC } from './platform'; -function detectIsWindows(): boolean { +const IS_WINDOWS: boolean = (() => { if (typeof navigator === 'undefined') return false; const nav = navigator as Navigator & { userAgentData?: { platform?: string } }; const p = (nav.userAgentData?.platform ?? nav.platform ?? nav.userAgent ?? '').toLowerCase(); return p.includes('win'); -} +})(); // Matches macOS Terminal's drag-and-drop format: backslash-escape each shell // metacharacter instead of wrapping in quotes. TUIs like `claude` recognize @@ -23,6 +23,5 @@ export function shellEscapeWindows(input: string): string { } export function shellEscapePath(input: string): string { - if (!IS_MAC && detectIsWindows()) return shellEscapeWindows(input); - return shellEscapePosix(input); + return !IS_MAC && IS_WINDOWS ? shellEscapeWindows(input) : shellEscapePosix(input); } diff --git a/standalone/sidecar/clipboard-ops.js b/standalone/sidecar/clipboard-ops.js index cd93e40..908fe54 100644 --- a/standalone/sidecar/clipboard-ops.js +++ b/standalone/sidecar/clipboard-ops.js @@ -2,54 +2,12 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const crypto = require('node:crypto'); -const { execFile, spawn } = require('node:child_process'); +const { execFile } = require('node:child_process'); const { promisify } = require('node:util'); const execFileP = promisify(execFile); const MAX_BUFFER = 16 * 1024 * 1024; -const DEBUG = process.env.MOUSETERM_DEBUG_CLIPBOARD === '1'; - -function debugLog(...parts) { - if (!DEBUG) return; - try { process.stderr.write(`[clipboard] ${parts.join(' ')}\n`); } catch {} -} - -async function ensureDir(dir, fsp) { - await fsp.mkdir(dir, { recursive: true }); -} - -async function fileNonEmpty(p, fsp) { - try { - const st = await fsp.stat(p); - return st.size > 0; - } catch { - return false; - } -} - -async function silentUnlink(p, fsp) { - try { await fsp.unlink(p); } catch {} -} - -function collectSpawnStdout(spawnFn, cmd, args) { - return new Promise((resolve) => { - let child; - try { - child = spawnFn(cmd, args); - } catch { - resolve(null); - return; - } - const chunks = []; - child.stdout.on('data', (c) => chunks.push(c)); - child.on('error', () => resolve(null)); - child.on('close', (code) => { - if (code === 0 && chunks.length > 0) resolve(Buffer.concat(chunks)); - else resolve(null); - }); - }); -} const MAC_FILE_PATHS_SCRIPT = [ 'use framework "AppKit"', @@ -73,20 +31,10 @@ const MAC_FILE_PATHS_SCRIPT = [ async function readFilePathsMac(runtime) { const exec = runtime.exec || execFileP; - if (DEBUG) { - try { - const { stdout } = await exec('osascript', ['-e', 'clipboard info'], { maxBuffer: MAX_BUFFER }); - debugLog('clipboard info:', JSON.stringify(stdout.trim())); - } catch (err) { - debugLog('clipboard info failed:', err && err.message || err); - } - } try { const { stdout } = await exec('osascript', ['-e', MAC_FILE_PATHS_SCRIPT], { maxBuffer: MAX_BUFFER }); - debugLog('files script stdout:', JSON.stringify(stdout)); return stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean); - } catch (err) { - debugLog('files script error:', err && err.message || err); + } catch { return []; } } @@ -144,10 +92,6 @@ async function readClipboardFilePaths(runtime = {}) { return readFilePathsLinux(runtime); } -function dropsFilePath(osModule, cryptoModule, name) { - return path.join(osModule.tmpdir(), 'mouseterm-drops', `${cryptoModule.randomUUID()}-${name}`); -} - async function readImageMac(out, runtime) { const exec = runtime.exec || execFileP; const script = [ @@ -171,10 +115,8 @@ async function readImageMac(out, runtime) { ].join('\n'); try { const { stdout } = await exec('osascript', ['-e', script], { maxBuffer: MAX_BUFFER }); - debugLog('image script stdout:', JSON.stringify(stdout)); return stdout.trim() === 'ok'; - } catch (err) { - debugLog('image script error:', err && err.message || err); + } catch { return false; } } @@ -201,18 +143,20 @@ async function readImageWindows(out, runtime) { async function readImageLinux(out, runtime, fsp) { const env = runtime.env || process.env; - const spawnFn = runtime.spawn || spawn; + const exec = runtime.exec || execFileP; const wayland = Boolean(env.WAYLAND_DISPLAY); const attempts = wayland ? [['wl-paste', ['--type', 'image/png']], ['xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o']]] : [['xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o']], ['wl-paste', ['--type', 'image/png']]]; for (const [cmd, args] of attempts) { - const buf = await collectSpawnStdout(spawnFn, cmd, args); - if (buf && buf.length > 0) { - await fsp.writeFile(out, buf); - return true; - } + try { + const { stdout } = await exec(cmd, args, { encoding: 'buffer', maxBuffer: MAX_BUFFER }); + if (stdout && stdout.length > 0) { + await fsp.writeFile(out, stdout); + return true; + } + } catch {} } return false; } @@ -223,20 +167,15 @@ async function readClipboardImageAsFilePath(runtime = {}) { const cryptoModule = runtime.cryptoModule || crypto; const fsp = (runtime.fsModule && runtime.fsModule.promises) || fs.promises; - const out = dropsFilePath(osModule, cryptoModule, 'clipboard.png'); + const out = path.join(osModule.tmpdir(), 'mouseterm-drops', `${cryptoModule.randomUUID()}-clipboard.png`); try { - await ensureDir(path.dirname(out), fsp); - } catch { - return null; - } - - let ok = false; - if (platform === 'darwin') ok = await readImageMac(out, runtime); - else if (platform === 'win32') ok = await readImageWindows(out, runtime); - else ok = await readImageLinux(out, runtime, fsp); - - if (ok && await fileNonEmpty(out, fsp)) return out; - await silentUnlink(out, fsp); + await fsp.mkdir(path.dirname(out), { recursive: true }); + const ok = platform === 'darwin' ? await readImageMac(out, runtime) + : platform === 'win32' ? await readImageWindows(out, runtime) + : await readImageLinux(out, runtime, fsp); + if (ok && (await fsp.stat(out)).size > 0) return out; + } catch {} + try { await fsp.unlink(out); } catch {} return null; } diff --git a/standalone/sidecar/clipboard-ops.test.js b/standalone/sidecar/clipboard-ops.test.js index 9d5912b..f498387 100644 --- a/standalone/sidecar/clipboard-ops.test.js +++ b/standalone/sidecar/clipboard-ops.test.js @@ -157,29 +157,18 @@ test('readClipboardImageAsFilePath returns null when osascript returns empty', a assert.equal(result, null); }); -test('readClipboardImageAsFilePath on linux writes buffer from spawn stdout', async () => { +test('readClipboardImageAsFilePath on linux writes buffer from exec stdout', async () => { const fs = fakeFs(); - const EventEmitter = require('node:events'); - function fakeSpawn(cmd) { - const child = new EventEmitter(); - child.stdout = new EventEmitter(); - setImmediate(() => { - if (cmd === 'xclip') { - child.stdout.emit('data', Buffer.from([0x89, 0x50, 0x4E, 0x47])); - child.emit('close', 0); - } else { - child.emit('close', 1); - } - }); - return child; - } const result = await readClipboardImageAsFilePath({ platform: 'linux', env: {}, osModule: fakeOs('/t'), cryptoModule: fakeCrypto('uuid-L'), fsModule: fs.module, - spawn: fakeSpawn, + exec: async (cmd) => { + if (cmd === 'xclip') return { stdout: Buffer.from([0x89, 0x50, 0x4E, 0x47]) }; + throw new Error('no tool'); + }, }); assert.equal(result, path.join('/t', 'mouseterm-drops', 'uuid-L-clipboard.png')); assert.equal(fs.writes.length, 1); From 3b4edd175f9c3c162ed3e5890df94652f5fd4678 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Wed, 22 Apr 2026 22:58:14 +0000 Subject: [PATCH 10/17] Claude Code simplify: fix retainContextWhenHidden spec to match code --- docs/specs/vscode.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index d6ebef6..eced577 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -58,7 +58,7 @@ Frontend Library (lib/src/) - **Shell login args are shell-specific.** The shared `pty-core.js` launches POSIX shells with `-l` only for shells that accept it. `csh`/`tcsh` must be spawned without `-l` so both the standalone app and VS Code extension can open a usable terminal for users whose login shell is C shell-derived. - **mergeAlarmStates on every save path.** Both the frontend periodic save (`onSaveState` callback) and the backend deactivate refresh (`refreshSavedSessionStateFromPtys`) must merge current alarm states. Missing this causes alarm state to revert on restore. - **Scrollback trailing newline.** Restored scrollback must end with `\n` to avoid zsh printing a `%` artifact at the top of the terminal. -- **retainContextWhenHidden.** Set on `WebviewPanel` (editor tabs) but NOT on `WebviewView` (bottom panel). The view relies on reconnect/replay when it becomes visible again; the panel keeps its DOM alive. +- **retainContextWhenHidden.** Set on both `WebviewPanel` (editor tabs) and `WebviewView` (bottom panel) so that xterm.js DOM, scrollback, and PTY subscriptions survive panel hide/show without going through the reconnect dance. - **Two save sources.** Session state is saved from two places: the frontend (debounced 500ms + 30s interval via `mouseterm:saveState`) and the backend (deactivate flushes webviews then refreshes from live PTYs). Both paths must produce consistent state. ### Extension manifest (current) From 84b44f2d2f6a124de86cf71d8b532658f4a48c4f Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Wed, 22 Apr 2026 23:02:35 +0000 Subject: [PATCH 11/17] Codex review R2: harden clipboard image temp files --- docs/specs/mouse-and-clipboard.md | 2 +- standalone/sidecar/clipboard-ops.js | 29 ++++++++++++++++++------ standalone/sidecar/clipboard-ops.test.js | 28 +++++++++++++++++++---- 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index fc7f755..d6c3707 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -304,7 +304,7 @@ Paste reads the clipboard in three tiers, falling through in order: 1. **File references.** The platform adapter checks for OS file references (Finder/Explorer Copy of a file) via the sidecar/extension host. If present, each path is shell-escaped and the space-joined list is written to the PTY with a trailing space so the next token starts cleanly. Files are checked first 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. 2. **Plain text.** `navigator.clipboard.readText()`. If non-empty, the string is written to the PTY (with bracketed-paste wrapping when enabled by the inside program). -3. **Raw image data.** If neither of the above matches and the clipboard holds image bytes (e.g. a `Cmd+Shift+4` screenshot), the bytes are written to `$TMPDIR/mouseterm-drops/.png` and that single path is pasted as in tier 1. +3. **Raw image data.** If neither of the above matches and the clipboard holds image bytes (e.g. a `Cmd+Shift+4` screenshot), the bytes are written to a newly-created private temp directory as `.png` and that single path is pasted as in tier 1. On Unix-like systems the temp directory is owner-only and the image file is written owner-readable/writable to avoid exposing clipboard screenshots to other local users. Each tier is implemented by a shared Node module (`standalone/sidecar/clipboard-ops.js`) that shells out to the OS-native clipboard tool: `osascript` on macOS, `Get-Clipboard` on Windows, `wl-paste`/`xclip` on Linux. The Tauri build reaches it through the existing sidecar; the VSCode build calls into the same module from its extension host. If every tier comes back empty, paste is a silent no-op. diff --git a/standalone/sidecar/clipboard-ops.js b/standalone/sidecar/clipboard-ops.js index 908fe54..dc951e0 100644 --- a/standalone/sidecar/clipboard-ops.js +++ b/standalone/sidecar/clipboard-ops.js @@ -33,7 +33,7 @@ async function readFilePathsMac(runtime) { const exec = runtime.exec || execFileP; try { const { stdout } = await exec('osascript', ['-e', MAC_FILE_PATHS_SCRIPT], { maxBuffer: MAX_BUFFER }); - return stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean); + return splitNonEmptyLines(stdout); } catch { return []; } @@ -48,7 +48,7 @@ async function readFilePathsWindows(runtime) { ['-NoProfile', '-NonInteractive', '-Command', cmd], { maxBuffer: MAX_BUFFER }, ); - return stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean); + return splitNonEmptyLines(stdout); } catch { return []; } @@ -153,7 +153,7 @@ async function readImageLinux(out, runtime, fsp) { try { const { stdout } = await exec(cmd, args, { encoding: 'buffer', maxBuffer: MAX_BUFFER }); if (stdout && stdout.length > 0) { - await fsp.writeFile(out, stdout); + await fsp.writeFile(out, stdout, { mode: 0o600 }); return true; } } catch {} @@ -167,15 +167,26 @@ async function readClipboardImageAsFilePath(runtime = {}) { const cryptoModule = runtime.cryptoModule || crypto; const fsp = (runtime.fsModule && runtime.fsModule.promises) || fs.promises; - const out = path.join(osModule.tmpdir(), 'mouseterm-drops', `${cryptoModule.randomUUID()}-clipboard.png`); + let dir = null; + let out = null; try { - await fsp.mkdir(path.dirname(out), { recursive: true }); + dir = await fsp.mkdtemp(path.join(osModule.tmpdir(), 'mouseterm-drops-')); + await fsp.chmod?.(dir, 0o700); + out = path.join(dir, `${cryptoModule.randomUUID()}-clipboard.png`); const ok = platform === 'darwin' ? await readImageMac(out, runtime) : platform === 'win32' ? await readImageWindows(out, runtime) : await readImageLinux(out, runtime, fsp); - if (ok && (await fsp.stat(out)).size > 0) return out; + if (ok && (await fsp.stat(out)).size > 0) { + await fsp.chmod?.(out, 0o600); + return out; + } } catch {} - try { await fsp.unlink(out); } catch {} + if (out) { + try { await fsp.unlink(out); } catch {} + } + if (dir) { + try { await fsp.rmdir(dir); } catch {} + } return null; } @@ -183,4 +194,8 @@ module.exports = { readClipboardFilePaths, readClipboardImageAsFilePath, parseUriList, + splitNonEmptyLines, }; +function splitNonEmptyLines(stdout) { + return stdout.split(/\r?\n/).filter((s) => s.length > 0); +} diff --git a/standalone/sidecar/clipboard-ops.test.js b/standalone/sidecar/clipboard-ops.test.js index f498387..6a065a0 100644 --- a/standalone/sidecar/clipboard-ops.test.js +++ b/standalone/sidecar/clipboard-ops.test.js @@ -6,6 +6,7 @@ const { readClipboardFilePaths, readClipboardImageAsFilePath, parseUriList, + splitNonEmptyLines, } = require('./clipboard-ops'); function fakeOs(tmp = '/tmp/test') { @@ -20,25 +21,38 @@ function fakeFs() { const writes = []; const files = new Map(); const unlinks = []; + const chmods = []; + const rmdirs = []; return { writes, files, unlinks, + chmods, + rmdirs, module: { promises: { - async mkdir() {}, - async writeFile(p, buf) { writes.push([p, buf]); files.set(p, buf); }, + async mkdtemp(prefix) { return `${prefix}dir-0`; }, + async chmod(p, mode) { chmods.push([p, mode]); }, + async writeFile(p, buf, opts) { writes.push([p, buf, opts]); files.set(p, buf); }, async stat(p) { const b = files.get(p); if (!b) throw new Error('ENOENT'); return { size: b.length }; }, async unlink(p) { unlinks.push(p); files.delete(p); }, + async rmdir(p) { rmdirs.push(p); }, }, }, }; } +test('splitNonEmptyLines preserves leading and trailing path spaces', () => { + assert.deepEqual(splitNonEmptyLines(' /tmp/leading.png\n/tmp/trailing.png \n'), [ + ' /tmp/leading.png', + '/tmp/trailing.png ', + ]); +}); + test('parseUriList decodes file URIs and ignores comments/non-file', () => { const input = [ '# comment', @@ -142,7 +156,11 @@ test('readClipboardImageAsFilePath on mac returns temp path on success', async ( return { stdout: 'ok\n' }; }, }); - assert.equal(result, path.join('/t', 'mouseterm-drops', 'uuid-I-clipboard.png')); + assert.equal(result, path.join('/t', 'mouseterm-drops-dir-0', 'uuid-I-clipboard.png')); + assert.deepEqual(fs.chmods, [ + [path.join('/t', 'mouseterm-drops-dir-0'), 0o700], + [path.join('/t', 'mouseterm-drops-dir-0', 'uuid-I-clipboard.png'), 0o600], + ]); }); test('readClipboardImageAsFilePath returns null when osascript returns empty', async () => { @@ -155,6 +173,7 @@ test('readClipboardImageAsFilePath returns null when osascript returns empty', a exec: async () => ({ stdout: '' }), }); assert.equal(result, null); + assert.deepEqual(fs.rmdirs, [path.join('/t', 'mouseterm-drops-dir-0')]); }); test('readClipboardImageAsFilePath on linux writes buffer from exec stdout', async () => { @@ -170,6 +189,7 @@ test('readClipboardImageAsFilePath on linux writes buffer from exec stdout', asy throw new Error('no tool'); }, }); - assert.equal(result, path.join('/t', 'mouseterm-drops', 'uuid-L-clipboard.png')); + assert.equal(result, path.join('/t', 'mouseterm-drops-dir-0', 'uuid-L-clipboard.png')); assert.equal(fs.writes.length, 1); + assert.deepEqual(fs.writes[0][2], { mode: 0o600 }); }); From bd77548f329baaa9937789e495382f6b7eec7306 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Wed, 22 Apr 2026 16:18:37 -0700 Subject: [PATCH 12/17] Claude Code review R3: test doPaste three-tier fallthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locks in the file-ref → text → image ordering and the invariant that `readClipboard.readText` is never called when file refs are present (avoids the macOS WKWebView paste-permission popup documented in clipboard.ts). Co-Authored-By: Claude Opus 4.7 --- lib/src/lib/clipboard.test.ts | 96 +++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 lib/src/lib/clipboard.test.ts diff --git a/lib/src/lib/clipboard.test.ts b/lib/src/lib/clipboard.test.ts new file mode 100644 index 0000000..f3c708e --- /dev/null +++ b/lib/src/lib/clipboard.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + readClipboardFilePaths: vi.fn<() => Promise>(), + readClipboardImageAsFilePath: vi.fn<() => Promise>(), + writePty: vi.fn<(id: string, data: string) => void>(), + readText: vi.fn<() => Promise>(), +})); + +vi.mock('./platform', () => ({ + IS_MAC: false, + getPlatform: () => ({ + readClipboardFilePaths: mocks.readClipboardFilePaths, + readClipboardImageAsFilePath: mocks.readClipboardImageAsFilePath, + writePty: mocks.writePty, + }), +})); + +vi.mock('./mouse-selection', () => ({ + getMouseSelectionState: () => ({ bracketedPaste: false }), +})); + +import { doPaste } from './clipboard'; + +describe('doPaste three-tier fallthrough', () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.defineProperty(globalThis, 'navigator', { + value: { clipboard: { readText: mocks.readText } }, + configurable: true, + }); + }); + + it('uses file refs when present and never reads text or image', async () => { + mocks.readClipboardFilePaths.mockResolvedValue(['/tmp/a.png', '/tmp/b file.png']); + mocks.readText.mockResolvedValue('should not be read'); + 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 '); + }); + + it('falls through to text when no file refs', async () => { + mocks.readClipboardFilePaths.mockResolvedValue(null); + mocks.readText.mockResolvedValue('hello world'); + mocks.readClipboardImageAsFilePath.mockResolvedValue('/tmp/img.png'); + + await doPaste('t1'); + + expect(mocks.readClipboardImageAsFilePath).not.toHaveBeenCalled(); + expect(mocks.writePty).toHaveBeenCalledWith('t1', 'hello world'); + }); + + it('falls through to image when no files and no text', async () => { + mocks.readClipboardFilePaths.mockResolvedValue([]); + mocks.readText.mockResolvedValue(''); + mocks.readClipboardImageAsFilePath.mockResolvedValue('/tmp/img.png'); + + await doPaste('t1'); + + expect(mocks.writePty).toHaveBeenCalledWith('t1', '/tmp/img.png '); + }); + + it('is a no-op when all tiers come back empty', async () => { + mocks.readClipboardFilePaths.mockResolvedValue(null); + mocks.readText.mockResolvedValue(''); + mocks.readClipboardImageAsFilePath.mockResolvedValue(null); + + await doPaste('t1'); + + expect(mocks.writePty).not.toHaveBeenCalled(); + }); + + it('swallows file-ref adapter errors and falls through to text', async () => { + mocks.readClipboardFilePaths.mockRejectedValue(new Error('boom')); + mocks.readText.mockResolvedValue('fallback'); + + await doPaste('t1'); + + expect(mocks.writePty).toHaveBeenCalledWith('t1', 'fallback'); + }); + + it('swallows image adapter errors silently', async () => { + mocks.readClipboardFilePaths.mockResolvedValue(null); + mocks.readText.mockResolvedValue(''); + mocks.readClipboardImageAsFilePath.mockRejectedValue(new Error('boom')); + + await doPaste('t1'); + + expect(mocks.writePty).not.toHaveBeenCalled(); + }); +}); From 1c7c62e1088d4129e2bda975a3e758129e198745 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Wed, 22 Apr 2026 16:18:59 -0700 Subject: [PATCH 13/17] Claude Code review R3: restore regex literals in smart-token Swap `new RegExp(string)` back for literals. The string form added a layer of backslash-escaping (e.g. `'^[A-Za-z]:\\\\\\S*$'`) with no motivating reason. Literals parse once at module load and read more directly. Co-Authored-By: Claude Opus 4.7 --- lib/src/lib/smart-token.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/src/lib/smart-token.ts b/lib/src/lib/smart-token.ts index f2dd329..c787dee 100644 --- a/lib/src/lib/smart-token.ts +++ b/lib/src/lib/smart-token.ts @@ -19,13 +19,13 @@ interface Pattern { } const PATTERNS: Pattern[] = [ - { kind: 'url', re: new RegExp('^https?://\\S+$') }, - { kind: 'url', re: new RegExp('^file://\\S+$') }, - { kind: 'path', re: new RegExp('^\\S+:\\d+(:\\d+)?$') }, // error-location first (so it beats generic path) - { kind: 'path', re: new RegExp('^~/\\S*$') }, - { kind: 'path', re: new RegExp('^/\\S+$') }, - { kind: 'path', re: new RegExp('^\\.{1,2}/\\S*$') }, - { kind: 'path', re: new RegExp('^[A-Za-z]:\\\\\\S*$') }, + { kind: 'url', re: /^https?:\/\/\S+$/ }, + { kind: 'url', re: /^file:\/\/\S+$/ }, + { kind: 'path', re: /^\S+:\d+(:\d+)?$/ }, // error-location first (so it beats generic path) + { kind: 'path', re: /^~\/\S*$/ }, + { kind: 'path', re: /^\/\S+$/ }, + { kind: 'path', re: /^\.\.?\/\S*$/ }, + { kind: 'path', re: /^[A-Za-z]:\\\S*$/ }, ]; const TRAILING_PUNCT = /[.,;:!?'"]+$/; From 293229a25cfa12096b91785ec696aa376d96ccda Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Wed, 22 Apr 2026 16:19:15 -0700 Subject: [PATCH 14/17] Claude Code review R3: drop unrelated TerminalPane JSX reformat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure formatting, no behavior change — shouldn't have ridden along in the clipboard PR. Co-Authored-By: Claude Opus 4.7 --- lib/src/components/TerminalPane.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/src/components/TerminalPane.tsx b/lib/src/components/TerminalPane.tsx index ea4d8e0..8dd4364 100644 --- a/lib/src/components/TerminalPane.tsx +++ b/lib/src/components/TerminalPane.tsx @@ -50,10 +50,7 @@ export function TerminalPane({ id, isFocused = true }: TerminalPaneProps) { }, [id, isFocused]); return ( -
+
From 4e78068c8aa809b0d18fe9c731471e83a8633cb6 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Wed, 22 Apr 2026 16:19:45 -0700 Subject: [PATCH 15/17] Claude Code review R3: test shellEscapePath OS dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the Mac/Linux → posix and Windows → windows branching in shellEscapePath, which was previously only tested via its two sub-functions in isolation. Uses vi.resetModules + navigator stubs to re-import the module under each simulated platform. Co-Authored-By: Claude Opus 4.7 --- lib/src/lib/shell-escape.test.ts | 40 +++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/lib/src/lib/shell-escape.test.ts b/lib/src/lib/shell-escape.test.ts index c7f2619..e5064c1 100644 --- a/lib/src/lib/shell-escape.test.ts +++ b/lib/src/lib/shell-escape.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; import { shellEscapePosix, shellEscapeWindows } from './shell-escape'; describe('shellEscapePosix', () => { @@ -78,3 +78,41 @@ describe('shellEscapeWindows', () => { expect(shellEscapeWindows('')).toBe(`""`); }); }); + +describe('shellEscapePath OS dispatch', () => { + const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); + + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + if (originalNavigator) Object.defineProperty(globalThis, 'navigator', originalNavigator); + vi.resetModules(); + vi.doUnmock('./platform'); + }); + + async function importShellEscape(opts: { isMac: boolean; platform: string }) { + vi.doMock('./platform', () => ({ IS_MAC: opts.isMac })); + Object.defineProperty(globalThis, 'navigator', { + value: { platform: opts.platform, userAgent: opts.platform }, + configurable: true, + }); + return import('./shell-escape'); + } + + it('uses posix escape on macOS', async () => { + const { shellEscapePath } = await importShellEscape({ isMac: true, platform: 'MacIntel' }); + expect(shellEscapePath('a b.png')).toBe('a\\ b.png'); + }); + + it('uses posix escape on Linux', async () => { + const { shellEscapePath } = await importShellEscape({ isMac: false, platform: 'Linux x86_64' }); + expect(shellEscapePath('a b.png')).toBe('a\\ b.png'); + }); + + it('uses windows escape on Windows', async () => { + const { shellEscapePath } = await importShellEscape({ isMac: false, platform: 'Win32' }); + expect(shellEscapePath('a b.png')).toBe(`"a b.png"`); + }); +}); From 88522819693b0dcbb000a5b8e0940e7e96fcb90a Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Wed, 22 Apr 2026 16:20:32 -0700 Subject: [PATCH 16/17] Claude Code review R3: quote paths with newlines in posix shell-escape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backslash-escaping newlines produces `\`, which bash reads as a line continuation and swallows both characters — so a file named \`foo\\nbar.txt\` pasted at the terminal becomes \`foobar.txt\`, silently corrupting legal Unix filenames. Fall back to single-quote wrapping when the path contains \\n or \\r, using the '\\'' idiom to embed literal single quotes. Updates the misleading "security: prevents command injection" test comment — this was never about injection, only round-trippability. Co-Authored-By: Claude Opus 4.7 --- lib/src/lib/shell-escape.test.ts | 12 ++++++++++-- lib/src/lib/shell-escape.ts | 11 ++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/lib/src/lib/shell-escape.test.ts b/lib/src/lib/shell-escape.test.ts index e5064c1..77e17c7 100644 --- a/lib/src/lib/shell-escape.test.ts +++ b/lib/src/lib/shell-escape.test.ts @@ -26,8 +26,16 @@ describe('shellEscapePosix', () => { expect(shellEscapePosix('a\\b.png')).toBe('a\\\\b.png'); }); - it('backslash-escapes carriage returns (security: prevents command injection)', () => { - expect(shellEscapePosix('a\rb')).toBe('a\\\rb'); + it('single-quote-wraps paths containing newlines (cannot backslash-escape: bash swallows `\\` as line continuation)', () => { + expect(shellEscapePosix('a\nb')).toBe("'a\nb'"); + }); + + it('single-quote-wraps paths containing carriage returns', () => { + expect(shellEscapePosix('a\rb')).toBe("'a\rb'"); + }); + + it("single-quote-wraps with the '\\'' idiom when input mixes newlines and single quotes", () => { + expect(shellEscapePosix("a'b\nc")).toBe("'a'\\''b\nc'"); }); it('backslash-escapes shell metacharacters', () => { diff --git a/lib/src/lib/shell-escape.ts b/lib/src/lib/shell-escape.ts index a99996a..b2c5e3d 100644 --- a/lib/src/lib/shell-escape.ts +++ b/lib/src/lib/shell-escape.ts @@ -11,10 +11,19 @@ const IS_WINDOWS: boolean = (() => { // metacharacter instead of wrapping in quotes. TUIs like `claude` recognize // backslash-escaped tokens as filesystem paths where a single-quoted whole // path gets treated as opaque pasted text. -const POSIX_UNSAFE = /([ \t\n\r!"#$&'()*;<>?[\\\]`{|}~])/g; +const POSIX_UNSAFE = /([ \t!"#$&'()*;<>?[\\\]`{|}~])/g; +const POSIX_NEEDS_QUOTES = /[\n\r]/; export function shellEscapePosix(input: string): string { if (input === '') return "''"; + // Newline/CR cannot round-trip through backslash-escape: bash reads + // `\` as a line continuation and *swallows* both the backslash + // and the newline, corrupting filenames that legally contain them. Fall + // back to single-quote wrapping for these, using the '\'' idiom to + // embed literal single quotes. + if (POSIX_NEEDS_QUOTES.test(input)) { + return `'${input.replace(/'/g, `'\\''`)}'`; + } return input.replace(POSIX_UNSAFE, '\\$1'); } From b095ee7f3c629fe3c6b0137bcef4f36b565515ae Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Wed, 22 Apr 2026 16:22:15 -0700 Subject: [PATCH 17/17] Claude Code review R3: unlink dropped clipboard images after 5 min MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each image paste creates \$TMPDIR/mouseterm-drops-XXXX/.png. In a long-lived sidecar these accumulated until the OS cleaned /tmp, which on macOS is per-user and persistent. Schedule an unref'd timer to remove the file and its parent directory 5 minutes after it's created — long enough that \`claude\`, \`cat\`, \`open\`, etc. have read it. The TTL is injectable via \`runtime.setTimeoutFn\` so tests can capture the scheduled callback without waiting. Co-Authored-By: Claude Opus 4.7 --- standalone/sidecar/clipboard-ops.js | 18 ++++++++++++++++++ standalone/sidecar/clipboard-ops.test.js | 18 ++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/standalone/sidecar/clipboard-ops.js b/standalone/sidecar/clipboard-ops.js index dc951e0..7362073 100644 --- a/standalone/sidecar/clipboard-ops.js +++ b/standalone/sidecar/clipboard-ops.js @@ -161,11 +161,28 @@ async function readImageLinux(out, runtime, fsp) { return false; } +// Delete dropped images after this window so $TMPDIR doesn't accumulate one +// file per image paste across a long-lived session. Long enough that any +// command the user launched against the path (claude, file, open, ...) has +// had time to read it. +const DROP_TTL_MS = 5 * 60 * 1000; + +function scheduleDropCleanup(filePath, fsp, setTimeoutFn) { + const timer = setTimeoutFn(() => { + // Fire-and-forget — no awaiting inside the timer callback. + Promise.resolve() + .then(() => fsp.unlink(filePath).catch(() => {})) + .then(() => fsp.rmdir(path.dirname(filePath)).catch(() => {})); + }, DROP_TTL_MS); + if (timer && typeof timer.unref === 'function') timer.unref(); +} + async function readClipboardImageAsFilePath(runtime = {}) { const platform = runtime.platform || process.platform; const osModule = runtime.osModule || os; const cryptoModule = runtime.cryptoModule || crypto; const fsp = (runtime.fsModule && runtime.fsModule.promises) || fs.promises; + const setTimeoutFn = runtime.setTimeoutFn || setTimeout; let dir = null; let out = null; @@ -178,6 +195,7 @@ async function readClipboardImageAsFilePath(runtime = {}) { : await readImageLinux(out, runtime, fsp); if (ok && (await fsp.stat(out)).size > 0) { await fsp.chmod?.(out, 0o600); + scheduleDropCleanup(out, fsp, setTimeoutFn); return out; } } catch {} diff --git a/standalone/sidecar/clipboard-ops.test.js b/standalone/sidecar/clipboard-ops.test.js index 6a065a0..a270ada 100644 --- a/standalone/sidecar/clipboard-ops.test.js +++ b/standalone/sidecar/clipboard-ops.test.js @@ -142,11 +142,13 @@ test('readClipboardFilePaths on linux falls back when first tool fails', async ( test('readClipboardImageAsFilePath on mac returns temp path on success', async () => { const fs = fakeFs(); + const timers = []; const result = await readClipboardImageAsFilePath({ platform: 'darwin', osModule: fakeOs('/t'), cryptoModule: fakeCrypto('uuid-I'), fsModule: fs.module, + setTimeoutFn: (cb, ms) => { timers.push({ cb, ms }); return { unref() {} }; }, exec: async (cmd, args) => { assert.equal(cmd, 'osascript'); const [, script] = args; @@ -156,11 +158,23 @@ test('readClipboardImageAsFilePath on mac returns temp path on success', async ( return { stdout: 'ok\n' }; }, }); - assert.equal(result, path.join('/t', 'mouseterm-drops-dir-0', 'uuid-I-clipboard.png')); + const expected = path.join('/t', 'mouseterm-drops-dir-0', 'uuid-I-clipboard.png'); + assert.equal(result, expected); assert.deepEqual(fs.chmods, [ [path.join('/t', 'mouseterm-drops-dir-0'), 0o700], - [path.join('/t', 'mouseterm-drops-dir-0', 'uuid-I-clipboard.png'), 0o600], + [expected, 0o600], ]); + // Cleanup was scheduled, but not yet run: the temp file still exists. + assert.equal(timers.length, 1); + assert.equal(timers[0].ms, 5 * 60 * 1000); + assert.equal(fs.unlinks.length, 0); + + // Firing the scheduled cleanup unlinks the file and its parent dir. + timers[0].cb(); + await new Promise((r) => setImmediate(r)); + await new Promise((r) => setImmediate(r)); + assert.deepEqual(fs.unlinks, [expected]); + assert.deepEqual(fs.rmdirs, [path.join('/t', 'mouseterm-drops-dir-0')]); }); test('readClipboardImageAsFilePath returns null when osascript returns empty', async () => {