Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5af4f12
Claude Code simplify: remove dead code in mouse-mode-observer dispose…
Apr 20, 2026
1ab1707
Codex review R1: fix indentation in selection-text.ts and SelectionOv…
Apr 20, 2026
3d217ad
Claude Code review R1: fix flashCopy race where beginDrag during flas…
Apr 21, 2026
5c5515f
Codex review R2: strip trailing punctuation before pattern matching i…
Apr 21, 2026
3767d33
copy-paste confirmed on mac standalone for a single image
nedtwigg Apr 22, 2026
375006f
copy-paste matches mac-native behavior
nedtwigg Apr 22, 2026
f9193c5
Codex review R1: fix spec/code mismatch on paste tier order, escape \…
Apr 22, 2026
77e7819
Merge branch 'main' into mouse-and-clipboard
nedtwigg Apr 22, 2026
2c96752
Merge branch 'mouse-and-clipboard' into copy-paste-images
nedtwigg Apr 22, 2026
c3345b4
Drop VSCode drag-to-paste: WebviewView can't receive OS file drops.
nedtwigg Apr 22, 2026
e396886
Merge branch 'main' into copy-paste-images
nedtwigg Apr 22, 2026
61f3d6b
Claude Code simplify: trim clipboard-ops helpers and per-call Windows…
Apr 22, 2026
3b4edd1
Claude Code simplify: fix retainContextWhenHidden spec to match code
Apr 22, 2026
84b44f2
Codex review R2: harden clipboard image temp files
Apr 22, 2026
bd77548
Claude Code review R3: test doPaste three-tier fallthrough
Apr 22, 2026
1c7c62e
Claude Code review R3: restore regex literals in smart-token
Apr 22, 2026
293229a
Claude Code review R3: drop unrelated TerminalPane JSX reformat
Apr 22, 2026
4e78068
Claude Code review R3: test shellEscapePath OS dispatch
Apr 22, 2026
8852281
Claude Code review R3: quote paths with newlines in posix shell-escape
Apr 22, 2026
b095ee7
Claude Code review R3: unlink dropped clipboard images after 5 min
Apr 22, 2026
22c9b16
Merge branch 'copy-paste-images' of https://github.com/diffplug/mouse…
Apr 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions docs/specs/mouse-and-clipboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,11 +300,23 @@ 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. **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 a newly-created private temp directory as `<uuid>.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.

### 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.

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

Right-click and OS Edit-menu paste are not currently implemented; users paste via the keyboard shortcuts in §8.2.

Expand Down Expand Up @@ -332,10 +344,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).
- 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.
2 changes: 1 addition & 1 deletion docs/specs/vscode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- **mergeAlertStates on every save path.** Both the frontend periodic save (`onSaveState` callback) and the backend deactivate refresh (`refreshSavedSessionStateFromPtys`) must merge current alert states. Missing this causes alert 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)
Expand Down
3 changes: 3 additions & 0 deletions lib/clipboard-ops.cjs
Original file line number Diff line number Diff line change
@@ -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');
12 changes: 11 additions & 1 deletion lib/src/components/Pond.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 AlertButtonActionResult,
Expand Down Expand Up @@ -1683,12 +1683,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();
Expand Down
4 changes: 2 additions & 2 deletions lib/src/components/SelectionOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
96 changes: 96 additions & 0 deletions lib/src/lib/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
readClipboardFilePaths: vi.fn<() => Promise<string[] | null>>(),
readClipboardImageAsFilePath: vi.fn<() => Promise<string | null>>(),
writePty: vi.fn<(id: string, data: string) => void>(),
readText: vi.fn<() => Promise<string>>(),
}));

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();
});
});
63 changes: 51 additions & 12 deletions lib/src/lib/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand Down Expand Up @@ -42,22 +43,60 @@ export async function copyRewrapped(terminalId: string): Promise<void> {
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<void> {
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<string> {
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<void> {
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]);
}
}
17 changes: 2 additions & 15 deletions lib/src/lib/mouse-mode-observer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn> }> = [];
const term2 = {
const terminal = {
parser: {
registerCsiHandler() {
const d = { dispose: vi.fn() };
Expand All @@ -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);
Expand Down
18 changes: 18 additions & 0 deletions lib/src/lib/mouse-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
__resetMouseSelectionForTests,
beginDrag,
endDrag,
flashCopy,
getMouseSelectionSnapshot,
getMouseSelectionState,
isDragging,
Expand Down Expand Up @@ -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');
Expand Down
3 changes: 3 additions & 0 deletions lib/src/lib/mouse-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions lib/src/lib/platform/fake-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ export class FakePtyAdapter implements PlatformAdapter {
async getCwd(_id: string): Promise<string | null> { return null; }
async getScrollback(_id: string): Promise<string | null> { return null; }

async readClipboardFilePaths(): Promise<string[] | null> { return null; }
async readClipboardImageAsFilePath(): Promise<string | null> { return null; }

requestInit(): void {}
onPtyList(_handler: (detail: { ptys: PtyInfo[] }) => void): void {}
offPtyList(_handler: (detail: { ptys: PtyInfo[] }) => void): void {}
Expand Down
6 changes: 6 additions & 0 deletions lib/src/lib/platform/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ export interface PlatformAdapter {
getCwd(id: string): Promise<string | null>;
getScrollback(id: string): Promise<string | null>;

// Clipboard support for file references and raw images.
readClipboardFilePaths(): Promise<string[] | null>;
readClipboardImageAsFilePath(): Promise<string | null>;
// 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;
Expand Down
16 changes: 16 additions & 0 deletions lib/src/lib/platform/vscode-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,22 @@ export class VSCodeAdapter implements PlatformAdapter {
return this.requestResponse('pty:getScrollback', 'pty:scrollback', { id }, (msg) => msg.data);
}

readClipboardFilePaths(): Promise<string[] | null> {
return this.requestResponse<string[] | null>(
'clipboard:readFiles', 'clipboard:files', {},
(msg) => msg.paths,
5000,
);
}

readClipboardImageAsFilePath(): Promise<string | null> {
return this.requestResponse<string | null>(
'clipboard:readImage', 'clipboard:image', {},
(msg) => msg.path,
10000,
);
}

onPtyData(handler: (detail: { id: string; data: string }) => void): void {
this.dataHandlers.add(handler);
}
Expand Down
Loading
Loading