From 41f34f6ff254fdc99ae6647b3c22ded15e1bf10a Mon Sep 17 00:00:00 2001 From: "buf0-bot[bot]" <252831055+buf0-bot[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:33:34 -0700 Subject: [PATCH] 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 --- ...de-middle-click-double-paste.repro.test.ts | 107 +++++++++++++++++- .../use-terminal-pane-mobile-actions.ts | 20 +++- 2 files changed, 122 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/issue-21762-tracking-mode-middle-click-double-paste.repro.test.ts b/src/renderer/src/components/terminal-pane/issue-21762-tracking-mode-middle-click-double-paste.repro.test.ts index 19dab3e0eda..d6f327a24df 100644 --- a/src/renderer/src/components/terminal-pane/issue-21762-tracking-mode-middle-click-double-paste.repro.test.ts +++ b/src/renderer/src/components/terminal-pane/issue-21762-tracking-mode-middle-click-double-paste.repro.test.ts @@ -88,14 +88,17 @@ function buildController(pane: ManagedPane): TerminalPaneContextController { function fireMiddleMouseDown( handler: (event: React.MouseEvent) => 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() + } + }) + }) }) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-mobile-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-mobile-actions.ts index ba47eaa7d5c..5ce2bfc438e 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-mobile-actions.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-mobile-actions.ts @@ -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() } },