Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
52 changes: 51 additions & 1 deletion src/renderer/DetachedApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,21 @@ const DetachedApp: React.FC<DetachedAppProps> = ({ terminalId }) => {
window.terminalAPI.writePty(terminalId, data);
});

let mouseTrackingOn = false;
const unsubscribePtyData = window.terminalAPI.onPtyData((id, data) => {
if (id === terminalId) term.write(data);
if (id !== terminalId) return;
// Track mouse reporting modes so handleContextMenu can suppress paste
for (let i = 0; i < data.length; i++) {
if (data.charCodeAt(i) !== 0x1b) continue;
if (data.startsWith('\x1b[?1000h', i) || data.startsWith('\x1b[?1002h', i) ||
data.startsWith('\x1b[?1003h', i)) {
mouseTrackingOn = true;
} else if (data.startsWith('\x1b[?1000l', i) || data.startsWith('\x1b[?1002l', i) ||
data.startsWith('\x1b[?1003l', i)) {
mouseTrackingOn = false;
}
}
term.write(data);
});

const unsubscribePtyExit = window.terminalAPI.onPtyExit((id) => {
Expand All @@ -140,6 +153,33 @@ const DetachedApp: React.FC<DetachedAppProps> = ({ terminalId }) => {
// TerminalPanel so detached windows match main window behaviour.
// Skip the implicit paste when clipboard is image-only (issue #84) -
// see TerminalPanel.handleContextMenu for the rationale.

// Track recent left-button drag attempts for failed-selection detection.
let recentDragWithoutSelection = false;
let dragStartPos: { x: number; y: number } | null = null;
const DRAG_THRESHOLD = 5;

const handleLeftMouseDown = (e: MouseEvent) => {
if (e.button === 0) {
dragStartPos = { x: e.clientX, y: e.clientY };
recentDragWithoutSelection = false;
}
};
const handleLeftMouseUp = (e: MouseEvent) => {
if (e.button === 0 && dragStartPos) {
const dx = e.clientX - dragStartPos.x;
const dy = e.clientY - dragStartPos.y;
const wasDrag = Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD;
if (wasDrag && mouseTrackingOn && !term.hasSelection()) {
recentDragWithoutSelection = true;
setTimeout(() => { recentDragWithoutSelection = false; }, 3000);
} else {
recentDragWithoutSelection = false;
}
dragStartPos = null;
}
};

const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
Expand All @@ -149,6 +189,12 @@ const DetachedApp: React.FC<DetachedAppProps> = ({ terminalId }) => {
term.clearSelection();
return;
}
// When mouse tracking consumed a recent drag (user tried to select but
// the TUI swallowed it), suppress paste.
if (recentDragWithoutSelection) {
recentDragWithoutSelection = false;
return;
}
const hasImage = window.terminalAPI.clipboardHasImage();
const html = window.terminalAPI.clipboardReadHTML();
const plainText = window.terminalAPI.clipboardRead();
Expand Down Expand Up @@ -176,6 +222,8 @@ const DetachedApp: React.FC<DetachedAppProps> = ({ terminalId }) => {
containerEl.addEventListener('contextmenu', handleContextMenu, true);
containerEl.addEventListener('mousedown', handleRightMouseButton, true);
containerEl.addEventListener('mouseup', handleRightMouseButton, true);
containerEl.addEventListener('mousedown', handleLeftMouseDown, true);
containerEl.addEventListener('mouseup', handleLeftMouseUp, true);

term.focus();

Expand All @@ -188,6 +236,8 @@ const DetachedApp: React.FC<DetachedAppProps> = ({ terminalId }) => {
containerEl.removeEventListener('contextmenu', handleContextMenu, true);
containerEl.removeEventListener('mousedown', handleRightMouseButton, true);
containerEl.removeEventListener('mouseup', handleRightMouseButton, true);
containerEl.removeEventListener('mousedown', handleLeftMouseDown, true);
containerEl.removeEventListener('mouseup', handleLeftMouseUp, true);
term.dispose();
};
})();
Expand Down
42 changes: 42 additions & 0 deletions src/renderer/components/TerminalPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1565,6 +1565,39 @@ const TerminalPanel: React.FC<TerminalPanelProps> = ({ terminalId, floatTitleBar
containerRef.current.addEventListener('mousedown', handleRightMouseButton, true);
containerRef.current.addEventListener('mouseup', handleRightMouseButton, true);

// Track recent left-button drag attempts. When mouse reporting is on,
// xterm forwards the drag to the pty instead of creating a selection.
// We detect this "failed selection" so the right-click handler can avoid
// auto-pasting when the user clearly intended to copy, not paste.
let recentDragWithoutSelection = false;
let dragStartPos: { x: number; y: number } | null = null;
const DRAG_THRESHOLD = 5; // pixels to count as a drag vs. a click

const handleLeftMouseDown = (e: MouseEvent) => {
if (e.button === 0) {
dragStartPos = { x: e.clientX, y: e.clientY };
recentDragWithoutSelection = false;
}
};
const handleLeftMouseUp = (e: MouseEvent) => {
if (e.button === 0 && dragStartPos) {
const dx = e.clientX - dragStartPos.x;
const dy = e.clientY - dragStartPos.y;
const wasDrag = Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD;
if (wasDrag && mouseTrackingOn && !term.hasSelection()) {
recentDragWithoutSelection = true;
// Auto-clear after a timeout — if the user doesn't right-click
// within 3s, the drag was probably intentional TUI interaction.
setTimeout(() => { recentDragWithoutSelection = false; }, 3000);
} else {
recentDragWithoutSelection = false;
}
dragStartPos = null;
}
};
containerRef.current.addEventListener('mousedown', handleLeftMouseDown, true);
containerRef.current.addEventListener('mouseup', handleLeftMouseUp, true);

// Right-click: copy if selection, paste if no selection.
// Skip the implicit paste when the clipboard is image-only (issue #84):
// a TUI with mouse reporting on (e.g. Claude Code) consumes drag events
Expand All @@ -1581,6 +1614,13 @@ const TerminalPanel: React.FC<TerminalPanelProps> = ({ terminalId, floatTitleBar
term.clearSelection();
return;
}
// When mouse tracking consumed a recent drag (user tried to select but
// the TUI swallowed it), suppress paste. The user clearly intended to
// copy, not paste. Ctrl+V still pastes explicitly.
if (recentDragWithoutSelection) {
recentDragWithoutSelection = false;
return;
}
const hasImage = window.terminalAPI.clipboardHasImage();
const html = window.terminalAPI.clipboardReadHTML();
const plainText = window.terminalAPI.clipboardRead();
Expand Down Expand Up @@ -1637,6 +1677,8 @@ const TerminalPanel: React.FC<TerminalPanelProps> = ({ terminalId, floatTitleBar
containerEl.removeEventListener('copy', handleCopyEvent, true);
containerEl.removeEventListener('mousedown', handleRightMouseButton, true);
containerEl.removeEventListener('mouseup', handleRightMouseButton, true);
containerEl.removeEventListener('mousedown', handleLeftMouseDown, true);
containerEl.removeEventListener('mouseup', handleLeftMouseUp, true);
wheelRecoveryEl?.removeEventListener('wheel', wheelPreSyncHandler, true);
wheelRecoveryEl?.removeEventListener('wheel', wheelRecoveryHandler);
wheelRecoveryEl?.removeEventListener('dblclick', manualSyncHandler);
Expand Down
150 changes: 150 additions & 0 deletions tests/e2e/rightclick-paste-mouse-reporting-text.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// Regression test: right-click in a terminal with mouse reporting active
// and text on the clipboard must NOT auto-paste. When mouse reporting is on,
// drag-select is consumed by the pty (forwarded as SGR events), so xterm
// never creates a selection. The user thinks they selected text, but
// hasSelection() is false — causing the paste branch to fire erroneously.
//
// This test verifies the fix: when mouseTrackingOn is true and there is no
// xterm selection, right-click is a no-op (user can still Ctrl+V explicitly).
import { test, expect, Page } from '@playwright/test';
import { launchTmax } from './fixtures/launch';

async function installPtyWriteSpy(window: Page): Promise<void> {
await window.evaluate(() => {
(window as any).__ptyWrites = [];
const orig = (window as any).terminalAPI.writePty.bind((window as any).terminalAPI);
(window as any).terminalAPI.writePty = (id: string, data: string) => {
(window as any).__ptyWrites.push({ id, data });
return orig(id, data);
};
});
}

async function getPastedText(window: Page): Promise<string> {
const writes = await window.evaluate(() => (window as any).__ptyWrites.slice() as Array<{ id: string; data: string }>);
return writes.map(w => w.data).join('');
}

async function writeToTerminal(window: Page, text: string): Promise<void> {
await window.evaluate((t: string) => {
const id = (window as any).__terminalStore.getState().focusedTerminalId;
const entry = (window as any).__getTerminalEntry(id);
entry?.terminal.write(t);
}, text);
}

async function setClipboard(window: Page, text: string): Promise<void> {
await window.evaluate((t: string) => (window as any).terminalAPI.clipboardWrite(t), text);
}

test('right-click with mouse reporting on and text clipboard does NOT paste', async () => {
const { window, close } = await launchTmax();
try {
await window.waitForSelector('.terminal-panel', { timeout: 15_000 });
await window.waitForTimeout(800);
await installPtyWriteSpy(window);

// Enable SGR mouse reporting (?1000h ?1006h) as a TUI app would
await writeToTerminal(window, '\x1b[?1000h\x1b[?1006h\r\nSELECTABLE_TEXT\r\n');
await window.waitForTimeout(300);

// Focus the terminal
await window.click('.terminal-panel .xterm-screen');
await window.waitForTimeout(200);

// Put text on the clipboard (simulates a previous copy from elsewhere)
const CLIPBOARD_PAYLOAD = 'SHOULD_NOT_BE_PASTED';
await setClipboard(window, CLIPBOARD_PAYLOAD);
await window.waitForTimeout(100);

// Drag across the terminal text — with mouse reporting on, xterm forwards
// the drag to the pty as SGR events and does NOT create a selection.
const coords = await window.evaluate(() => {
const id = (window as any).__terminalStore.getState().focusedTerminalId;
const entry = (window as any).__getTerminalEntry(id);
const term = entry?.terminal;
const dim = (term as any)._core?._renderService?.dimensions;
const cw = dim?.css?.cell?.width ?? dim?.actualCellWidth ?? 9;
const ch = dim?.css?.cell?.height ?? dim?.actualCellHeight ?? 18;
const screen = document.querySelector('.terminal-panel .xterm-screen') as HTMLElement;
const rect = screen.getBoundingClientRect();
const startX = rect.left + 1;
const endX = rect.left + cw * 14 + cw * 0.6;
const y = rect.top + ch * 1.5;
return { startX, endX, y };
});

await window.mouse.move(coords.startX, coords.y);
await window.mouse.down();
await window.mouse.move(coords.endX, coords.y, { steps: 10 });
await window.mouse.up();
await window.waitForTimeout(250);

// Confirm xterm has no selection (mouse reporting swallowed the drag)
const sel = await window.evaluate(() => {
const id = (window as any).__terminalStore.getState().focusedTerminalId;
const entry = (window as any).__getTerminalEntry(id);
return entry?.terminal.hasSelection() ?? false;
});
expect(sel, 'mouse reporting should consume drag — no xterm selection').toBe(false);

// Reset spy to only track what right-click does
await window.evaluate(() => { (window as any).__ptyWrites = []; });

// Right-click — should NOT paste the clipboard text
await window.click('.terminal-panel .xterm-screen', { button: 'right' });
await window.waitForTimeout(500);

const pasted = await getPastedText(window);
expect(
pasted,
`pty must not receive clipboard text on right-click when mouse reporting is on; got: ${JSON.stringify(pasted)}`
).toBe('');
} finally {
await close();
}
});

test('right-click with mouse reporting OFF still pastes normally', async () => {
const { window, close } = await launchTmax();
try {
await window.waitForSelector('.terminal-panel', { timeout: 15_000 });
await window.waitForTimeout(1000);

// No mouse reporting — normal terminal (PSReadLine may enable ?1003h,
// but with no preceding drag, paste should still fire)

// Focus terminal
await window.click('.terminal-panel .xterm-screen');
await window.waitForTimeout(200);

const CLIPBOARD_PAYLOAD = 'PASTE_ME_E2E';
await setClipboard(window, CLIPBOARD_PAYLOAD);
await window.waitForTimeout(100);

// Right-click with no selection should paste
await window.click('.terminal-panel .xterm-screen', { button: 'right' });
await window.waitForTimeout(1000);

// Verify by reading the terminal buffer — the payload should be visible
const bufferText = await window.evaluate(() => {
const id = (window as any).__terminalStore.getState().focusedTerminalId;
const entry = (window as any).__getTerminalEntry(id);
const term = entry?.terminal;
if (!term) return '';
const lines: string[] = [];
for (let i = 0; i < term.buffer.active.length; i++) {
const line = term.buffer.active.getLine(i)?.translateToString(true) ?? '';
if (line.trim()) lines.push(line);
}
return lines.join('\n');
});

expect(
bufferText.includes(CLIPBOARD_PAYLOAD),
`terminal buffer should contain pasted text; got: ${JSON.stringify(bufferText.slice(-200))}`
).toBe(true);
} finally {
await close();
}
});