fix(terminal): right-click pastes once into Codex (#29647)

This commit is contained in:
Kelvin Amoaba
2026-10-16 12:11:06 +00:00
committed by GitHub
parent 36d449ba67
commit 942ffe2f9e
7 changed files with 195 additions and 22 deletions
@@ -9,12 +9,13 @@ describe('installPreviewTerminalRightClickPaste', () => {
let container: HTMLElement
let selection: string
let clearSelection: ReturnType<typeof vi.fn<() => void>>
let focus: ReturnType<typeof vi.fn<() => 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,
@@ -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<Terminal, 'getSelection' | 'clearSelection'>
type PreviewRightClickTerminal = Pick<Terminal, 'getSelection' | 'clearSelection' | 'focus'>
/**
* 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)
}
}
@@ -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 (
@@ -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<RightClickPasteMouseEvent, 'button' | 'ctrlKey'>,
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()
}
@@ -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<PaneManager | null>
@@ -20,6 +24,7 @@ type TerminalContextMenuTrigger = {
point: { x: number; y: number }
menuOpenedAtRef: React.RefObject<number>
onContextMenuCapture: (event: React.MouseEvent<HTMLDivElement>) => void
onMouseDownCapture: (event: React.MouseEvent<HTMLDivElement>) => void
onPaneTitleContextMenu: (event: React.MouseEvent<HTMLElement>, paneId: number) => void
}
@@ -90,21 +95,32 @@ export function useTerminalContextMenuTrigger({
setOpen(true)
}
const onContextMenuCapture = (event: React.MouseEvent<HTMLDivElement>): 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<HTMLDivElement>): void => {
if (!isTerminalRightClickPasteGesture(event, rightClickToPaste)) {
return
}
const clickedPane = findClickedPane(event.target)
if (clickedPane) {
claimTerminalRightClickPasteMouseDown(event, clickedPane.terminal)
}
}
const onContextMenuCapture = (event: React.MouseEvent<HTMLDivElement>): 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<HTMLElement>, 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
}
}
@@ -58,6 +58,7 @@ type TerminalMenuState = {
paneCount: number
menuPaneId: number | null
onContextMenuCapture: (event: React.MouseEvent<HTMLDivElement>) => void
onMouseDownCapture: (event: React.MouseEvent<HTMLDivElement>) => void
onPaneTitleContextMenu: (event: React.MouseEvent<HTMLElement>, paneId: number) => void
onCopy: () => Promise<void>
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,
+56 -1
View File
@@ -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,