fix(terminal): let Shift+middle-click paste in mouse-tracking panes (#21858)

* fix(terminal): let Shift+middle-click paste in mouse-tracking panes

Follow-up to #21834 (issue #21762). That fix arms the native-paste
suppression window for every terminal middle-click, then returns early
when the pane is in mouse-tracking mode so the TUI performs the paste
from the forwarded mouse report.

xterm's SelectionService.shouldForceSelection deliberately withholds
that report for a shifted click (Option-click on Mac), so the TUI never
pastes. With the native follow-up paste now suppressed as well,
Shift+middle-click in Claude Code, Codex, and other tracking TUIs pasted
nothing at all. Previously Chromium's native paste was the one paste.

Mirror xterm's platform rule: when the click's modifier forces
selection, fall through to Orca's own paste-to-PTY path (stop
propagation, focus, paste) exactly as in a non-tracking pane. The
auxclick handler gates stopPropagation the same way.

* test(terminal): pin Alt+middle-click to the TUI-owned path off Mac

---------

Co-authored-by: bench <bench@example.invalid>
This commit is contained in:
buf0-bot[bot]
2026-09-20 17:33:34 -07:00
committed by GitHub
co-authored by bench
parent e0b717dd60
commit 41f34f6ff2
2 changed files with 122 additions and 5 deletions
@@ -88,14 +88,17 @@ function buildController(pane: ManagedPane): TerminalPaneContextController {
function fireMiddleMouseDown(
handler: (event: React.MouseEvent<HTMLDivElement>) => void,
target: EventTarget
target: EventTarget,
modifiers: { shiftKey?: boolean; altKey?: boolean } = {}
): { defaultPrevented: boolean; propagationStopped: boolean } {
let defaultPrevented = false
let propagationStopped = false
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test-only stub; the handler only calls button/target/preventDefault/stopPropagation off the event.
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test-only stub; the handler only reads button/target/shiftKey/altKey and calls preventDefault/stopPropagation off the event.
handler({
button: 1,
target,
shiftKey: modifiers.shiftKey ?? false,
altKey: modifiers.altKey ?? false,
preventDefault: () => {
defaultPrevented = true
},
@@ -148,4 +151,104 @@ describe('issue 21762: middle-click native-paste suppression in mouse-tracking T
expect(outcome.propagationStopped).toBe(true)
expect(pane.terminal.focus).toHaveBeenCalled()
})
// Follow-up to #21834: xterm withholds the mouse report for a shifted click
// (SelectionService.shouldForceSelection), so the TUI never pastes. Arming
// suppression while also returning early would leave nothing pasted at all.
describe('Shift+middle-click in a mouse-tracking pane', () => {
it("takes Orca's own paste path: stops propagation and focuses the pane", () => {
const pane = buildTrackedPane('sgr')
const { result } = renderHook(() => useTerminalPaneMobileActions(buildController(pane)))
const outcome = fireMiddleMouseDown(
result.current.handlePrimarySelectionMiddleMouseDown,
pane.container,
{ shiftKey: true }
)
expect(armPrimarySelectionNativePasteSuppressionMock).toHaveBeenCalled()
expect(outcome.defaultPrevented).toBe(true)
expect(outcome.propagationStopped).toBe(true)
expect(pane.terminal.focus).toHaveBeenCalled()
})
it('keeps the unshifted click on the TUI-owned path (no #21762 regression)', () => {
const pane = buildTrackedPane('sgr')
const { result } = renderHook(() => useTerminalPaneMobileActions(buildController(pane)))
const outcome = fireMiddleMouseDown(
result.current.handlePrimarySelectionMiddleMouseDown,
pane.container,
{ shiftKey: false }
)
expect(armPrimarySelectionNativePasteSuppressionMock).toHaveBeenCalled()
expect(outcome.propagationStopped).toBe(false)
expect(pane.terminal.focus).not.toHaveBeenCalled()
})
// Guards against collapsing the modifier check to `shiftKey || altKey`:
// off Mac, xterm still forwards an Alt+middle-click, so the TUI pastes.
it('leaves Alt+middle-click on the TUI-owned path off Mac', () => {
const pane = buildTrackedPane('sgr')
const { result } = renderHook(() => useTerminalPaneMobileActions(buildController(pane)))
const outcome = fireMiddleMouseDown(
result.current.handlePrimarySelectionMiddleMouseDown,
pane.container,
{ altKey: true }
)
expect(outcome.propagationStopped).toBe(false)
expect(pane.terminal.focus).not.toHaveBeenCalled()
})
it('stops auxclick propagation too, matching the mousedown handler', () => {
const pane = buildTrackedPane('sgr')
const { result } = renderHook(() => useTerminalPaneMobileActions(buildController(pane)))
const shifted = fireMiddleMouseDown(
result.current.handlePrimarySelectionAuxClick,
pane.container,
{
shiftKey: true
}
)
const plain = fireMiddleMouseDown(
result.current.handlePrimarySelectionAuxClick,
pane.container
)
expect(armPrimarySelectionNativePasteSuppressionMock).toHaveBeenCalledTimes(2)
expect(shifted.propagationStopped).toBe(true)
expect(plain.propagationStopped).toBe(false)
})
it('on Mac follows xterm: Option, not Shift, forces the terminal to own the click', () => {
const userAgent = vi.spyOn(navigator, 'userAgent', 'get')
userAgent.mockReturnValue('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')
try {
const pane = buildTrackedPane('sgr')
const { result } = renderHook(() => useTerminalPaneMobileActions(buildController(pane)))
const shifted = fireMiddleMouseDown(
result.current.handlePrimarySelectionMiddleMouseDown,
pane.container,
{ shiftKey: true }
)
expect(shifted.propagationStopped).toBe(false)
expect(pane.terminal.focus).not.toHaveBeenCalled()
const optioned = fireMiddleMouseDown(
result.current.handlePrimarySelectionMiddleMouseDown,
pane.container,
{ altKey: true }
)
expect(optioned.propagationStopped).toBe(true)
expect(pane.terminal.focus).toHaveBeenCalled()
} finally {
userAgent.mockRestore()
}
})
})
})
@@ -20,6 +20,13 @@ import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd'
import type { TerminalPaneContextController } from './use-terminal-pane-context-actions'
// Why: mirrors xterm's SelectionService.shouldForceSelection — a shifted click
// (Option-click on Mac, via macOptionClickForcesSelection) is never forwarded
// as a mouse report, so the TUI cannot paste and Orca must own it instead.
function terminalForcesSelectionForClick(event: React.MouseEvent): boolean {
return navigator.userAgent.includes('Mac') ? event.altKey : event.shiftKey
}
export function useTerminalPaneMobileActions(controller: TerminalPaneContextController) {
const {
cwd,
@@ -127,10 +134,14 @@ export function useTerminalPaneMobileActions(controller: TerminalPaneContextCont
// (fired on mouseup, not mousedown; see usePrimarySelectionPaste.ts).
// Only the paste-to-PTY below is gated on tracking mode, since a
// tracking TUI still needs the click forwarded as a mouse report and
// must not have propagation stopped.
// must not have propagation stopped — unless the modifier makes xterm
// withhold the report, in which case nobody else will paste.
event.preventDefault()
armPrimarySelectionNativePasteSuppression()
if (targetPane.terminal.modes.mouseTrackingMode !== 'none') {
if (
targetPane.terminal.modes.mouseTrackingMode !== 'none' &&
!terminalForcesSelectionForClick(event)
) {
return
}
const clickedPane = targetPane
@@ -210,7 +221,10 @@ export function useTerminalPaneMobileActions(controller: TerminalPaneContextCont
}
event.preventDefault()
armPrimarySelectionNativePasteSuppression()
if (targetPane.terminal.modes.mouseTrackingMode === 'none') {
if (
targetPane.terminal.modes.mouseTrackingMode === 'none' ||
terminalForcesSelectionForClick(event)
) {
event.stopPropagation()
}
},