diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-right-click-paste.test.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-right-click-paste.test.ts index a972bf00740..805ecb543f7 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-right-click-paste.test.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-right-click-paste.test.ts @@ -9,12 +9,13 @@ describe('installPreviewTerminalRightClickPaste', () => { let container: HTMLElement let selection: string let clearSelection: ReturnType void>> + let focus: ReturnType void>> let rightClickToPaste: boolean const install = (): (() => void) => installPreviewTerminalRightClickPaste({ container, - getTerminal: () => ({ getSelection: () => selection, clearSelection }), + getTerminal: () => ({ getSelection: () => selection, clearSelection, focus }), isRightClickToPasteEnabled: () => rightClickToPaste, pasteClipboardText }) @@ -32,6 +33,7 @@ describe('installPreviewTerminalRightClickPaste', () => { document.body.appendChild(container) selection = '' clearSelection = vi.fn<() => void>() + focus = vi.fn<() => void>() rightClickToPaste = true Object.assign(window, { api: { ui: { writeTerminalClipboardText } } }) }) @@ -78,6 +80,43 @@ describe('installPreviewTerminalRightClickPaste', () => { expect(pasteClipboardText).not.toHaveBeenCalled() }) + // Why: xterm reports a right press to a mouse-tracking TUI, and Codex pastes on + // that report itself, so the press must never reach xterm's element (#25192). + const pressTerminalElement = ( + init: MouseEventInit + ): { reachedXterm: boolean; event: MouseEvent } => { + const xtermElement = document.createElement('div') + container.appendChild(xtermElement) + let reachedXterm = false + xtermElement.addEventListener('mousedown', () => { + reachedXterm = true + }) + const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true, ...init }) + xtermElement.dispatchEvent(event) + return { reachedXterm, event } + } + + it('claims a right press before xterm sees it and keeps the terminal focused', () => { + install() + const { reachedXterm, event } = pressTerminalElement({ button: 2 }) + expect(reachedXterm).toBe(false) + expect(event.defaultPrevented).toBe(true) + expect(focus).toHaveBeenCalledOnce() + }) + + it.each([ + ['Ctrl+right press', { button: 2, ctrlKey: true }, true], + ['left press', { button: 0 }, true], + ['right press with the setting off', { button: 2 }, false] + ])('lets a %s reach xterm', (_name, init, enabled) => { + rightClickToPaste = enabled + install() + const { reachedXterm, event } = pressTerminalElement(init) + expect(reachedXterm).toBe(true) + expect(event.defaultPrevented).toBe(false) + expect(focus).not.toHaveBeenCalled() + }) + it('falls through before the terminal exists and stops listening once disposed', () => { const dispose = installPreviewTerminalRightClickPaste({ container, diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-right-click-paste.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-right-click-paste.ts index 9baa3999751..5719abba2ef 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-right-click-paste.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-right-click-paste.ts @@ -1,8 +1,12 @@ import type { Terminal } from '@xterm/xterm' import { copyTerminalSelection } from '@/components/terminal-pane/terminal-selection-copy' import type { PreviewTerminalPasteSource } from './preview-terminal-paste' +import { + claimTerminalRightClickPasteMouseDown, + isTerminalRightClickPasteGesture +} from '@/components/terminal-pane/terminal-right-click-paste-gesture' -type PreviewRightClickTerminal = Pick +type PreviewRightClickTerminal = Pick /** * Terminal-style right-click for the preview terminal, mirroring the pane's @@ -40,6 +44,16 @@ export function installPreviewTerminalRightClickPaste({ } pasteClipboardText(document.activeElement, 'right-click') } + const onMouseDown = (event: MouseEvent): void => { + const terminal = getTerminal() + if (terminal && isTerminalRightClickPasteGesture(event, isRightClickToPasteEnabled())) { + claimTerminalRightClickPasteMouseDown(event, terminal) + } + } + container.addEventListener('mousedown', onMouseDown, true) container.addEventListener('contextmenu', onContextMenu) - return () => container.removeEventListener('contextmenu', onContextMenu) + return () => { + container.removeEventListener('mousedown', onMouseDown, true) + container.removeEventListener('contextmenu', onContextMenu) + } } diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx index 557441a9622..84704792c3a 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx @@ -136,7 +136,10 @@ export function TerminalPaneSurface({ data-pane-title-surface={titleUsesLightSurface ? 'light' : 'dark'} style={terminalContainerStyle} onContextMenuCapture={contextMenu.onContextMenuCapture} - onMouseDownCapture={handlePrimarySelectionMiddleMouseDown} + onMouseDownCapture={(event) => { + contextMenu.onMouseDownCapture(event) + handlePrimarySelectionMiddleMouseDown(event) + }} onAuxClickCapture={handlePrimarySelectionAuxClick} onDragOver={(event) => { if ( diff --git a/src/renderer/src/components/terminal-pane/terminal-right-click-paste-gesture.ts b/src/renderer/src/components/terminal-pane/terminal-right-click-paste-gesture.ts new file mode 100644 index 00000000000..f92daf18989 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-right-click-paste-gesture.ts @@ -0,0 +1,29 @@ +type RightClickPasteMouseEvent = { + button: number + ctrlKey: boolean + preventDefault: () => void + stopPropagation: () => void +} + +/** + * Why: with "Right-click to paste" on, Orca owns the right button. xterm would + * otherwise report the press to a mouse-tracking TUI, and Codex pastes the + * clipboard on that report itself, so one click pasted twice (#25192). + * Ctrl+right-click stays the app menu, so that press still reaches xterm. + */ +export function isTerminalRightClickPasteGesture( + event: Pick, + rightClickToPaste: boolean +): boolean { + return rightClickToPaste && event.button === 2 && !event.ctrlKey +} + +/** Stop the press before xterm's listener and keep the focus xterm would have taken. */ +export function claimTerminalRightClickPasteMouseDown( + event: RightClickPasteMouseEvent, + terminal: { focus: () => void } +): void { + event.preventDefault() + event.stopPropagation() + terminal.focus() +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-context-menu-trigger.ts b/src/renderer/src/components/terminal-pane/use-terminal-context-menu-trigger.ts index c28194f8fbf..62a9f5808b9 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-context-menu-trigger.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-context-menu-trigger.ts @@ -1,8 +1,12 @@ import { useEffect, useRef, useState } from 'react' -import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' import type { TerminalPasteSource } from './terminal-paste-model' import { copyTerminalSelection } from './terminal-selection-copy' import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/lib/close-all-context-menus' +import { + claimTerminalRightClickPasteMouseDown, + isTerminalRightClickPasteGesture +} from './terminal-right-click-paste-gesture' type UseTerminalContextMenuTriggerDeps = { managerRef: React.RefObject @@ -20,6 +24,7 @@ type TerminalContextMenuTrigger = { point: { x: number; y: number } menuOpenedAtRef: React.RefObject onContextMenuCapture: (event: React.MouseEvent) => void + onMouseDownCapture: (event: React.MouseEvent) => void onPaneTitleContextMenu: (event: React.MouseEvent, paneId: number) => void } @@ -90,21 +95,32 @@ export function useTerminalContextMenuTrigger({ setOpen(true) } - const onContextMenuCapture = (event: React.MouseEvent): void => { + const findClickedPane = (target: EventTarget | null): ManagedPane | null => { const manager = managerRef.current - if (!manager) { - event.preventDefault() - contextPaneIdRef.current = null + if (!manager || !(target instanceof Node)) { + return null + } + return manager.getPanes().find((pane) => pane.container.contains(target)) ?? null + } + + const onMouseDownCapture = (event: React.MouseEvent): void => { + if (!isTerminalRightClickPasteGesture(event, rightClickToPaste)) { return } + const clickedPane = findClickedPane(event.target) + if (clickedPane) { + claimTerminalRightClickPasteMouseDown(event, clickedPane.terminal) + } + } + + const onContextMenuCapture = (event: React.MouseEvent): void => { const target = event.target - if (!(target instanceof Node)) { + if (!managerRef.current || !(target instanceof Node)) { event.preventDefault() contextPaneIdRef.current = null return } - const clickedPane = manager.getPanes().find((pane) => pane.container.contains(target)) ?? null - openContextMenu(event, clickedPane?.id ?? null, event.currentTarget) + openContextMenu(event, findClickedPane(target)?.id ?? null, event.currentTarget) } const onPaneTitleContextMenu = (event: React.MouseEvent, paneId: number): void => { @@ -116,5 +132,13 @@ export function useTerminalContextMenuTrigger({ openContextMenu(event, paneId, boundsElement) } - return { open, setOpen, point, menuOpenedAtRef, onContextMenuCapture, onPaneTitleContextMenu } + return { + open, + setOpen, + point, + menuOpenedAtRef, + onContextMenuCapture, + onMouseDownCapture, + onPaneTitleContextMenu + } } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index 3289186e9b8..d498934e1ce 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -58,6 +58,7 @@ type TerminalMenuState = { paneCount: number menuPaneId: number | null onContextMenuCapture: (event: React.MouseEvent) => void + onMouseDownCapture: (event: React.MouseEvent) => void onPaneTitleContextMenu: (event: React.MouseEvent, paneId: number) => void onCopy: () => Promise onSelectAll: () => void @@ -132,14 +133,21 @@ export function useTerminalPaneContextMenu({ source ) - const { open, setOpen, point, menuOpenedAtRef, onContextMenuCapture, onPaneTitleContextMenu } = - useTerminalContextMenuTrigger({ - managerRef, - containerRef, - contextPaneIdRef, - rightClickToPaste, - pasteResolvedPane - }) + const { + open, + setOpen, + point, + menuOpenedAtRef, + onContextMenuCapture, + onMouseDownCapture, + onPaneTitleContextMenu + } = useTerminalContextMenuTrigger({ + managerRef, + containerRef, + contextPaneIdRef, + rightClickToPaste, + pasteResolvedPane + }) const { onSplitRight, onSplitDown } = useTerminalPaneSplitActions({ managerRef, @@ -297,6 +305,7 @@ export function useTerminalPaneContextMenu({ paneCount, menuPaneId, onContextMenuCapture, + onMouseDownCapture, onPaneTitleContextMenu, onCopy, onSelectAll, diff --git a/tests/e2e/terminal-paste-ownership.spec.ts b/tests/e2e/terminal-paste-ownership.spec.ts index 56c65a139aa..da7664dc38d 100644 --- a/tests/e2e/terminal-paste-ownership.spec.ts +++ b/tests/e2e/terminal-paste-ownership.spec.ts @@ -23,16 +23,20 @@ function keyboardPasteChords(): string[] { : ['Control+V', 'Control+Shift+V', 'Shift+Insert'] } -function pasteEchoScript(runId: string): string { +function pasteEchoScript(runId: string, options?: { mouseTracking?: boolean }): string { + const mouseTracking = options?.mouseTracking === true return ` process.stdin.setEncoding('utf8') if (process.stdin.isTTY) process.stdin.setRawMode(true) process.stdin.resume() let seq = 0 const interrupt = String.fromCharCode(3) +const esc = String.fromCharCode(27) +if (${mouseTracking}) process.stdout.write(esc + '[?1000h' + esc + '[?1006h') process.stdout.write('PASTE_READY_${runId}\\n') process.stdin.on('data', (chunk) => { if (chunk.includes(interrupt)) { + if (${mouseTracking}) process.stdout.write(esc + '[?1006l' + esc + '[?1000l') process.exit(0) } seq += 1 @@ -312,6 +316,57 @@ test.describe('terminal paste ownership', () => { } }) + test('right-click paste into a mouse-tracking terminal app sends the clipboard once and no right-button report', async ({ + electronApp, + orcaPage, + testRepoPath + }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await installTerminalPtyWriteSpy(electronApp) + + const ptyId = await waitForActivePanePtyId(orcaPage) + const runId = randomUUID() + const scriptPath = path.join(testRepoPath, `.orca-paste-right-click-${runId}.mjs`) + writeFileSync(scriptPath, pasteEchoScript(runId, { mouseTracking: true })) + let scriptStarted = false + + try { + await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`) + scriptStarted = true + await waitForTerminalOutput(orcaPage, `PASTE_READY_${runId}`, 10_000) + await orcaPage.evaluate(() => + window.__store?.getState().updateSettings({ terminalRightClickToPaste: true }) + ) + await expect + .poll(() => + orcaPage.evaluate(() => window.__store?.getState().settings?.terminalRightClickToPaste) + ) + .toBe(true) + + const payload = `ORCA_E2E_RIGHT_CLICK_PASTE_${runId}` + const encodedPayload = Buffer.from(payload, 'utf8').toString('base64') + await clearTerminalPtyWriteLog(electronApp) + await orcaPage.evaluate((text) => window.api.ui.writeClipboardText(text), payload) + await focusActiveTerminalInput(orcaPage) + + await rightClickActiveTerminalSurface(orcaPage) + await waitForTerminalOutput(orcaPage, encodedPayload, 10_000, 12_000) + + const writes = (await readTerminalPtyWrites(electronApp)).join('') + expect(countOccurrences(writes, payload), 'right-click PTY write count').toBe(1) + // Why: a TUI that pastes on its own right-button report would paste again (#25192). + expect(writes, 'right-button mouse report').not.toContain('\x1b[<2;') + } finally { + if (scriptStarted) { + await sendToTerminal(orcaPage, ptyId, '\x03').catch(() => undefined) + } + rmSync(scriptPath, { force: true }) + } + }) + test('Windows multiline keyboard paste normalizes terminal newlines with one PTY owner', async ({ electronApp, orcaPage,