feat(terminal): configure URL click and middle-click behavior (#21438)

* feat(terminal): configure URL click behavior

* fix(i18n): include terminal link setting title

* fix(i18n): localize terminal click controls

* fix(settings): update terminal URL click title
This commit is contained in:
Neil
2026-09-18 17:31:06 -07:00
committed by GitHub
parent f2fd18c820
commit cd81725d70
16 changed files with 291 additions and 38 deletions
@@ -4,6 +4,7 @@ import {
isTerminalLinkActionActivation,
isTerminalLinkDirectActivation
} from '@/components/terminal-pane/terminal-link-activation'
import type { TerminalLinkClickBehavior } from '@/components/terminal-pane/terminal-link-click-behavior'
import {
buildHttpLinkActions,
openRoutedHttpLink,
@@ -18,6 +19,7 @@ export type NativeChatWebLinkDeps = {
destinations: HttpLinkActionDestinations
/** Off: a plain click opens the routed destination outright, as it did before actions existed. */
actionsEnabled: boolean
plainClickBehavior?: TerminalLinkClickBehavior
restoreFocus: () => void
request: (request: LinkActionRequest) => void
}
@@ -59,11 +61,15 @@ export function handleNativeChatWebLink(
return false
}
event.preventDefault()
if (!deps.actionsEnabled) {
if (deps.plainClickBehavior === 'none') {
return false
}
event.preventDefault()
open(deps.destinations.primary)
return true
}
event.preventDefault()
const keyboardAnchor = event.detail === 0 ? event.currentTarget?.getBoundingClientRect() : null
deps.request({
anchorX: keyboardAnchor?.left ?? event.clientX,
@@ -13,6 +13,7 @@ import {
resolveNativeChatHttpLinkSourceOwner
} from './native-chat-http-link-source-owner'
import { handleNativeChatWebLink } from './native-chat-web-link-actions'
import { terminalLinkClickBehaviorFor } from '@/components/terminal-pane/terminal-link-click-behavior'
import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click'
export type NativeChatLinkActions = {
@@ -61,6 +62,12 @@ export function useNativeChatLinkActions(
// Read at click time: settings and workspace ownership must not re-render the transcript.
const state = useAppStore.getState()
const sourceOwner = resolveNativeChatHttpLinkSourceOwner(state, context.worktreeId)
const plainClickBehavior =
state.settings?.terminalLinkClickBehavior === undefined
? state.settings?.terminalLinkActionPopoverEnabled === false
? 'open'
: 'actions'
: terminalLinkClickBehaviorFor(state.settings)
const anchor = event.currentTarget
handleNativeChatWebLink(event, route.url, {
worktreeId: context.worktreeId,
@@ -70,7 +77,8 @@ export function useNativeChatLinkActions(
sourceOwner,
canNativeChatOpenOwnedBrowser(state, context.worktreeId, sourceOwner)
),
actionsEnabled: state.settings?.terminalLinkActionPopoverEnabled !== false,
actionsEnabled: plainClickBehavior === 'actions',
plainClickBehavior,
restoreFocus: () =>
(anchor.isConnected ? anchor : rootRef.current)?.focus({ preventScroll: true }),
request: setLinkActionRequest
@@ -2,12 +2,24 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types'
import { translate } from '@/i18n/i18n'
import { BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types'
import { SearchableSetting } from './SearchableSetting'
import { SettingsSwitchRow } from './SettingsFormControls'
import { getTerminalLinkActionsDescription } from './browser-link-routing-copy'
import {
SettingsRow,
SettingsSegmentedControl,
SettingsSubsectionHeader
} from './SettingsFormControls'
import { getTerminalLinkActionSearchKeywords } from './browser-search'
import {
terminalLinkClickBehaviorFor,
type TerminalLinkClickBehavior
} from '../terminal-pane/terminal-link-click-behavior'
type BrowserTerminalLinkActionsSettingProps = {
settings: Pick<GlobalSettings, 'terminalLinkActionPopoverEnabled'>
settings: Pick<
GlobalSettings,
| 'terminalLinkActionPopoverEnabled'
| 'terminalLinkClickBehavior'
| 'terminalUrlMiddleClickBehavior'
>
isMac: boolean
updateSettings: (updates: Partial<GlobalSettings>) => void
}
@@ -19,9 +31,49 @@ export function BrowserTerminalLinkActionsSetting({
}: BrowserTerminalLinkActionsSettingProps): React.JSX.Element {
const title = translate(
'auto.components.settings.BrowserTerminalLinkActionsSetting.title',
'Show link actions'
'Terminal URL clicks'
)
const description = translate(
'auto.components.settings.BrowserTerminalLinkActionsSetting.descriptionV2',
'Control clicks on detected URLs printed in terminal panes and chat transcripts.'
)
const behavior = terminalLinkClickBehaviorFor(settings)
const plainClickLabel = translate(
'auto.components.settings.BrowserTerminalLinkActionsSetting.plainClickLabel',
'Plain click'
)
const plainClickDescription = translate(
'auto.components.settings.BrowserTerminalLinkActionsSetting.plainClickDescription',
'Choose whether a left-click shows actions, opens the URL, or leaves it to the terminal. Cmd/Ctrl-click always opens directly.'
)
const plainClickAriaLabel = translate(
'auto.components.settings.BrowserTerminalLinkActionsSetting.plainClickAriaLabel',
'Plain click URL behavior'
)
const middleClickLabel = translate(
'auto.components.settings.BrowserTerminalLinkActionsSetting.middleClickLabel',
'Middle click'
)
const middleClickDescription = translate(
'auto.components.settings.BrowserTerminalLinkActionsSetting.middleClickDescription',
'Choose what a mouse-wheel click does on a detected terminal URL.'
)
const middleClickAriaLabel = translate(
'auto.components.settings.BrowserTerminalLinkActionsSetting.middleClickAriaLabel',
'Middle click'
)
const actionsLabel = translate(
'auto.components.settings.BrowserTerminalLinkActionsSetting.actionsLabel',
'Actions'
)
const openUrlLabel = translate(
'auto.components.settings.BrowserTerminalLinkActionsSetting.openUrlLabel',
'Open URL'
)
const leaveToTerminalLabel = translate(
'auto.components.settings.BrowserTerminalLinkActionsSetting.leaveToTerminalLabel',
'Leave to terminal'
)
const description = getTerminalLinkActionsDescription({ isMac })
return (
<SearchableSetting
@@ -30,18 +82,48 @@ export function BrowserTerminalLinkActionsSetting({
description={description}
keywords={getTerminalLinkActionSearchKeywords({ isMac })}
>
<div className="ml-4 border-l border-border pl-4">
<SettingsSwitchRow
label={title}
description={description}
checked={settings.terminalLinkActionPopoverEnabled !== false}
onChange={() =>
updateSettings({
terminalLinkActionPopoverEnabled: settings.terminalLinkActionPopoverEnabled === false
})
}
/>
</div>
<section className="space-y-3">
<SettingsSubsectionHeader title={title} description={description} />
<div className="rounded-lg border border-border/60 bg-muted/10 px-4">
<div className="divide-y divide-border/40">
<SettingsRow
label={plainClickLabel}
description={plainClickDescription}
alignTop
control={
<SettingsSegmentedControl<TerminalLinkClickBehavior>
value={behavior}
onChange={(value) => updateSettings({ terminalLinkClickBehavior: value })}
ariaLabel={plainClickAriaLabel}
size="sm"
options={[
{ value: 'actions', label: actionsLabel },
{ value: 'open', label: openUrlLabel },
{ value: 'none', label: leaveToTerminalLabel }
]}
/>
}
/>
<SettingsRow
label={middleClickLabel}
description={middleClickDescription}
control={
<SettingsSegmentedControl<TerminalLinkClickBehavior>
value={settings.terminalUrlMiddleClickBehavior ?? 'open'}
onChange={(value) => updateSettings({ terminalUrlMiddleClickBehavior: value })}
ariaLabel={middleClickAriaLabel}
size="sm"
options={[
{ value: 'actions', label: actionsLabel },
{ value: 'open', label: openUrlLabel },
{ value: 'none', label: leaveToTerminalLabel }
]}
/>
}
/>
</div>
</div>
</section>
</SearchableSetting>
)
}
@@ -61,10 +61,9 @@ describe('browser settings search copy', () => {
expect(linkRoutingEntry?.keywords).not.toContain('cmd')
const terminalActionsEntry = getBrowserPaneSearchEntries({ isMac: false }).find(
(entry) => entry.title === 'Show link actions'
(entry) => entry.title === 'Terminal URL clicks'
)
expect(terminalActionsEntry?.description).toContain('Ctrl-click')
expect(terminalActionsEntry?.description).not.toContain('Cmd/Ctrl')
expect(terminalActionsEntry?.description).toContain('detected URLs')
expect(terminalActionsEntry?.keywords).toEqual(
getTerminalLinkActionSearchKeywords({ isMac: false })
)
@@ -102,7 +101,7 @@ describe('browser link routing modifier copy', () => {
'Default Zoom',
'Link Routing',
'Hold Shift to open in Orca',
'Show link actions',
'Terminal URL clicks',
'Localhost Worktree Labels',
'Session & Cookies',
'Remote server workspaces',
@@ -4,7 +4,6 @@ import { translateSearchKeyword } from './settings-search-keywords'
import { getBrowserUserAgentSearchEntry } from './browser-user-agent-search'
import {
getBrowserLinkRoutingDescription,
getTerminalLinkActionsDescription,
getLinkRoutingModifierDescription,
getLinkRoutingModifierTitle
} from './browser-link-routing-copy'
@@ -59,6 +58,11 @@ export function getTerminalLinkActionSearchKeywords(platform: BrowserShortcutPla
'auto.components.settings.browser.search.terminalLinkActions.disable',
'disable'
),
'link click behavior',
'open directly',
'modifier-click only',
'middle click',
'mouse 3',
platform.isMac ? 'cmd' : 'ctrl'
]
}
@@ -187,9 +191,12 @@ export function getBrowserPaneSearchEntries(
{
title: translate(
'auto.components.settings.BrowserTerminalLinkActionsSetting.title',
'Show link actions'
'Terminal URL clicks'
),
description: translate(
'auto.components.settings.BrowserTerminalLinkActionsSetting.descriptionV2',
'Control clicks on detected URLs printed in terminal panes and chat transcripts.'
),
description: getTerminalLinkActionsDescription(platform),
keywords: getTerminalLinkActionSearchKeywords(platform)
},
{
@@ -1,5 +1,9 @@
import type { TerminalLinkPointerGesture } from './terminal-link-pointer-gesture'
import { isTerminalLinkActionActivation } from './terminal-link-activation'
import {
isTerminalLinkActionActivation,
isTerminalMiddleClickActivation
} from './terminal-link-activation'
import type { TerminalLinkClickBehavior } from './terminal-link-click-behavior'
import {
closeLinkActionRequest,
type LinkAction,
@@ -21,6 +25,8 @@ export type TerminalLinkActionContext = {
claimPtyMouse: () => boolean
request: TerminalLinkActionRequester
focusTerminal: () => void
plainClickBehavior?: TerminalLinkClickBehavior
middleClickBehavior?: TerminalLinkClickBehavior
}
export function closeTerminalLinkActionRequest(
@@ -43,7 +49,10 @@ export function requestTerminalLinkAction(
if (
!event ||
!context ||
!isTerminalLinkActionActivation(event) ||
!(
isTerminalLinkActionActivation(event) ||
(isTerminalMiddleClickActivation(event) && context.middleClickBehavior !== 'none')
) ||
!context.pointerGesture.canRequestAction(event)
) {
return false
@@ -53,6 +62,15 @@ export function requestTerminalLinkAction(
return false
}
event.preventDefault()
const middleClick = isTerminalMiddleClickActivation(event)
if ((middleClick ? context.middleClickBehavior : context.plainClickBehavior) === 'open') {
context.focusTerminal()
details.primary?.run()
return true
}
if (middleClick && context.middleClickBehavior !== 'actions') {
return false
}
context.request({
...details,
paneId: context.paneId,
@@ -30,6 +30,12 @@ function plainEvent(): MouseEvent {
} as unknown as MouseEvent
}
function middleEvent(): MouseEvent {
const event = plainEvent()
Object.defineProperty(event, 'button', { value: 1 })
return event
}
function actionContext(request = vi.fn()): TerminalLinkActionContext {
return {
paneId: 7,
@@ -85,6 +91,52 @@ describe('terminal link action routing', () => {
)
})
it('opens the primary destination directly when plain-click mode is enabled', () => {
const request = vi.fn()
const run = vi.fn()
const context = actionContext(request)
context.plainClickBehavior = 'open'
expect(
requestTerminalLinkAction(plainEvent(), context, {
destination: 'https://example.com',
kind: 'url',
primary: { label: 'Open', run }
})
).toBe(true)
expect(run).toHaveBeenCalledOnce()
expect(request).not.toHaveBeenCalled()
})
it('opens a URL on middle click when configured', () => {
const run = vi.fn()
const context = actionContext()
context.middleClickBehavior = 'open'
expect(
requestTerminalLinkAction(middleEvent(), context, {
destination: 'https://example.com',
kind: 'url',
primary: { label: 'Open', run }
})
).toBe(true)
expect(run).toHaveBeenCalledOnce()
})
it('keeps middle click available when plain clicks stay with the terminal', () => {
const run = vi.fn()
const context = actionContext()
context.plainClickBehavior = 'none'
context.middleClickBehavior = 'open'
expect(
requestTerminalLinkAction(middleEvent(), context, {
destination: 'https://example.com',
kind: 'url',
primary: { label: 'Open', run }
})
).toBe(true)
expect(run).toHaveBeenCalledOnce()
})
it('leaves PTY mouse ownership with an ineligible pointer gesture', () => {
const context = actionContext()
context.pointerGesture.canRequestAction = () => false
@@ -29,6 +29,23 @@ export function isTerminalLinkActionActivation(event: TerminalLinkMouseEvent | u
)
}
export function isTerminalMiddleClickActivation(
event: TerminalLinkMouseEvent | undefined
): boolean {
return Boolean(
event &&
event.button === 1 &&
!event.altKey &&
!event.shiftKey &&
!event.metaKey &&
!event.ctrlKey
)
}
export function isTerminalOwnedLinkGesture(event: TerminalLinkMouseEvent | undefined): boolean {
return isTerminalLinkDirectActivation(event) || isTerminalLinkActionActivation(event)
return (
isTerminalLinkDirectActivation(event) ||
isTerminalLinkActionActivation(event) ||
isTerminalMiddleClickActivation(event)
)
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { terminalLinkClickBehaviorFor } from './terminal-link-click-behavior'
describe('terminalLinkClickBehaviorFor', () => {
it('defaults to actions and preserves legacy profiles', () => {
expect(terminalLinkClickBehaviorFor(undefined)).toBe('actions')
expect(terminalLinkClickBehaviorFor({ terminalLinkActionPopoverEnabled: true })).toBe('actions')
expect(terminalLinkClickBehaviorFor({ terminalLinkActionPopoverEnabled: false })).toBe('none')
})
it('prefers the explicit behavior for new profiles', () => {
expect(
terminalLinkClickBehaviorFor({
terminalLinkActionPopoverEnabled: false,
terminalLinkClickBehavior: 'open'
})
).toBe('open')
expect(terminalLinkClickBehaviorFor({ terminalLinkClickBehavior: 'none' })).toBe('none')
})
})
@@ -0,0 +1,19 @@
import type { GlobalSettings } from '../../../../shared/global-settings-types'
export type TerminalLinkClickBehavior = 'actions' | 'open' | 'none'
/** Resolves the new preference while keeping older profiles behaviorally identical. */
export function terminalLinkClickBehaviorFor(
settings:
| Pick<GlobalSettings, 'terminalLinkClickBehavior' | 'terminalLinkActionPopoverEnabled'>
| null
| undefined
): TerminalLinkClickBehavior {
if (settings?.terminalLinkClickBehavior === 'open') {
return 'open'
}
if (settings?.terminalLinkClickBehavior === 'none') {
return 'none'
}
return settings?.terminalLinkActionPopoverEnabled === false ? 'none' : 'actions'
}
@@ -1,7 +1,8 @@
import type { IDisposable, Terminal } from '@xterm/xterm'
import {
isTerminalLinkActionActivation,
isTerminalLinkDirectActivation
isTerminalLinkDirectActivation,
isTerminalMiddleClickActivation
} from './terminal-link-activation'
import { isXtermMouseReport } from './terminal-pointer-input-sequences'
@@ -80,7 +81,10 @@ export function installTerminalLinkPtyMouseSuppression(
captureCurrentMouseEvent()
return
}
if (!isTerminalLinkDirectActivation(event) || !shouldSuppressMouseEvent(event)) {
if (
(!isTerminalLinkDirectActivation(event) && !isTerminalMiddleClickActivation(event)) ||
!shouldSuppressMouseEvent(event)
) {
return
}
restore()
@@ -23,6 +23,7 @@ import { fitAndFocusPanes, fitPanes } from './pane-helpers'
import { registerRuntimeTerminalTab } from '@/runtime/sync-runtime-graph'
import { normalizeTerminalLayoutSnapshot } from './layout-serialization'
import { applyTerminalAppearance } from './terminal-appearance'
import { terminalLinkClickBehaviorFor } from './terminal-link-click-behavior'
import { createTerminalPanePtyDeps } from './terminal-pane-pty-deps'
import type { DeferredSplitPaneHandoffHandle } from './deferred-split-pane-handoff'
import {
@@ -130,7 +131,9 @@ export function prepareTerminalPaneMount(
canOpenOwnedBrowserForPane(paneId)
)
const getLinkActionContext = (paneId: number): TerminalLinkActionContext | null => {
if (deps.settingsRef.current?.terminalLinkActionPopoverEnabled === false) {
const plainClickBehavior = terminalLinkClickBehaviorFor(deps.settingsRef.current)
const middleClickBehavior = deps.settingsRef.current?.terminalUrlMiddleClickBehavior ?? 'open'
if (plainClickBehavior === 'none' && middleClickBehavior === 'none') {
return null
}
const pane = deps.managerRef.current?.getPanes().find((candidate) => candidate.id === paneId)
@@ -145,7 +148,9 @@ export function prepareTerminalPaneMount(
pointerGesture,
claimPtyMouse: suppression.claimAction,
request: deps.requestTerminalLinkAction,
focusTerminal: () => pane.terminal.focus()
focusTerminal: () => pane.terminal.focus(),
plainClickBehavior,
middleClickBehavior
}
}
const pathExistsCache = new Map<string, boolean>()
@@ -243,7 +248,7 @@ export function prepareTerminalPaneMount(
getHttpLinkSourceOwnerForPane(paneId),
canOpenOwnedBrowserForPane(paneId)
),
showActions: deps.settingsRef.current?.terminalLinkActionPopoverEnabled !== false
showActions: terminalLinkClickBehaviorFor(deps.settingsRef.current) === 'actions'
})
return {
container,
@@ -59,7 +59,7 @@ export type TerminalHttpLinkActionDestinations = HttpLinkActionDestinations
export type TerminalLinkRoutingPreferenceRequester = HttpLinkRoutingPreferenceRequester
function isDesktopHttpLinkFallbackActivation(event: MouseEvent): boolean {
if (event.defaultPrevented || event.button !== 0) {
if (event.defaultPrevented || (event.button !== 0 && event.button !== 1)) {
return false
}
// Why: Shift-only, Alt, and non-primary clicks remain available to the terminal or child TUI.
@@ -111,7 +111,7 @@ export function findHttpLinkAtTerminalMouseEvent(
terminal: Terminal,
event: MouseEvent
): string | null {
if (event.button !== 0 || !isTerminalOwnedLinkGesture(event)) {
if ((event.button !== 0 && event.button !== 1) || !isTerminalOwnedLinkGesture(event)) {
return null
}
const position = getTerminalBufferPositionForMouseEvent(terminal, event)
+12 -2
View File
@@ -11329,8 +11329,18 @@
"descriptionOrca": "Links open in your system browser. When enabled, {{chord}}+click opens one in Orca's built-in browser instead."
},
"BrowserTerminalLinkActionsSetting": {
"title": "Show link actions",
"description": "Show available actions when you click a link in the terminal or a chat transcript. Turn this off to require {{modifier}}-click in the terminal."
"title": "Terminal URL clicks",
"description": "Show available actions when you click a link in the terminal or a chat transcript. Turn this off to require {{modifier}}-click in the terminal.",
"descriptionV2": "Control clicks on detected URLs printed in terminal panes and chat transcripts.",
"plainClickLabel": "Plain click",
"plainClickDescription": "Choose whether a left-click shows actions, opens the URL, or leaves it to the terminal. Cmd/Ctrl-click always opens directly.",
"plainClickAriaLabel": "Plain click URL behavior",
"middleClickLabel": "Middle click",
"middleClickDescription": "Choose what a mouse-wheel click does on a detected terminal URL.",
"middleClickAriaLabel": "Middle click",
"actionsLabel": "Actions",
"openUrlLabel": "Open URL",
"leaveToTerminalLabel": "Leave to terminal"
},
"PluginConsentDialog": {
"workerTrust": "Background worker — runs its own process",
+2
View File
@@ -130,6 +130,8 @@ export function buildDefaultSettings(args: {
openLinksInAppPreferencePrompted: false,
openLinksInAppModifierInverts: false,
terminalLinkActionPopoverEnabled: true,
terminalLinkClickBehavior: 'actions',
terminalUrlMiddleClickBehavior: 'open',
openAgentTabsInChatByDefault: false,
experimentalNativeChat: false,
experimentalStructuredNativeChat: false,
+4
View File
@@ -218,6 +218,10 @@ export type GlobalSettings = {
openLinksInAppModifierInverts?: boolean
/** Show link actions on plain click in the terminal and chat; off restores modifier-click-only terminal links. */
terminalLinkActionPopoverEnabled?: boolean
/** Plain-click behavior for terminal links; optional for profiles saved before this setting existed. */
terminalLinkClickBehavior?: 'actions' | 'open' | 'none'
/** Middle mouse URL behavior; defaults to opening the primary routed destination. */
terminalUrlMiddleClickBehavior?: 'open' | 'actions' | 'none'
/** Opt-in: open new coding-agent tabs in native chat instead of the raw terminal; optional for legacy settings. */
openAgentTabsInChatByDefault?: boolean
/** Experimental native chat surface for Claude/Codex sessions; off by default. */