) => void
+}
+
+export function BrowserTerminalLinkActionsSetting({
+ settings,
+ isMac,
+ updateSettings
+}: BrowserTerminalLinkActionsSettingProps): React.JSX.Element {
+ const title = translate(
+ 'auto.components.settings.BrowserTerminalLinkActionsSetting.title',
+ 'Show terminal link actions'
+ )
+ const description = getTerminalLinkActionsDescription({ isMac })
+
+ return (
+
+
+
+ updateSettings({
+ terminalLinkActionPopoverEnabled: settings.terminalLinkActionPopoverEnabled === false
+ })
+ }
+ />
+
+
+ )
+}
diff --git a/src/renderer/src/components/settings/browser-link-routing-copy.ts b/src/renderer/src/components/settings/browser-link-routing-copy.ts
index 5a4d5bac2d6..bdf005749e2 100644
--- a/src/renderer/src/components/settings/browser-link-routing-copy.ts
+++ b/src/renderer/src/components/settings/browser-link-routing-copy.ts
@@ -4,6 +4,14 @@ export function getBrowserLinkRoutingShortcutLabel(platform: { isMac: boolean })
return platform.isMac ? '⇧⌘-click' : 'Shift+Ctrl+click'
}
+export function getTerminalLinkActionsDescription(platform: { isMac: boolean }): string {
+ return translate(
+ 'auto.components.settings.BrowserTerminalLinkActionsSetting.description',
+ 'Show available actions when you click a terminal link. Turn this off to require {{modifier}}-click.',
+ { modifier: platform.isMac ? '⌘' : 'Ctrl' }
+ )
+}
+
// Why: "always" stops being true once inverting is on, so only then does the nested
// row take over the chord sentence — with it off this reads exactly as it always has.
// Each variant is a complete catalog entry: stitched suffixes break in locales whose
diff --git a/src/renderer/src/components/settings/browser-search.test.ts b/src/renderer/src/components/settings/browser-search.test.ts
index 0df9173b28d..b9a36f148d7 100644
--- a/src/renderer/src/components/settings/browser-search.test.ts
+++ b/src/renderer/src/components/settings/browser-search.test.ts
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
import ko from '@/i18n/locales/ko.json'
import { i18n } from '@/i18n/i18n'
-import { getBrowserPaneSearchEntries } from './browser-search'
+import { getBrowserPaneSearchEntries, getTerminalLinkActionSearchKeywords } from './browser-search'
import {
getBrowserLinkRoutingDescription,
getBrowserLinkRoutingShortcutLabel,
@@ -47,6 +47,17 @@ describe('browser settings search copy', () => {
expect(linkRoutingEntry?.description).toBe(getBrowserLinkRoutingDescription({ isMac: false }))
expect(linkRoutingEntry?.keywords).toContain('ctrl')
expect(linkRoutingEntry?.keywords).not.toContain('cmd')
+
+ const terminalActionsEntry = getBrowserPaneSearchEntries({ isMac: false }).find(
+ (entry) => entry.title === 'Show terminal link actions'
+ )
+ expect(terminalActionsEntry?.description).toContain('Ctrl-click')
+ expect(terminalActionsEntry?.description).not.toContain('Cmd/Ctrl')
+ expect(terminalActionsEntry?.keywords).toEqual(
+ getTerminalLinkActionSearchKeywords({ isMac: false })
+ )
+ expect(terminalActionsEntry?.keywords).toContain('browser')
+ expect(terminalActionsEntry?.keywords).toContain('ctrl')
})
// Why: shipping the opt-in must not reword this row for anyone who never enables
@@ -79,6 +90,7 @@ describe('browser link routing modifier copy', () => {
'Default Zoom',
'Link Routing',
'Hold Shift to open in Orca',
+ 'Show terminal link actions',
'Localhost Worktree Labels',
'Session & Cookies'
])
diff --git a/src/renderer/src/components/settings/browser-search.ts b/src/renderer/src/components/settings/browser-search.ts
index 67f5506de42..ad31404e922 100644
--- a/src/renderer/src/components/settings/browser-search.ts
+++ b/src/renderer/src/components/settings/browser-search.ts
@@ -3,11 +3,12 @@ import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from './settings-search-keywords'
import {
getBrowserLinkRoutingDescription,
+ getTerminalLinkActionsDescription,
getLinkRoutingModifierDescription,
getLinkRoutingModifierTitle
} from './browser-link-routing-copy'
-type BrowserShortcutPlatform = {
+export type BrowserShortcutPlatform = {
isMac: boolean
}
@@ -17,6 +18,38 @@ function getDefaultBrowserShortcutPlatform(): BrowserShortcutPlatform {
}
}
+export function getTerminalLinkActionSearchKeywords(platform: BrowserShortcutPlatform): string[] {
+ return [
+ ...translateSearchKeyword('auto.components.settings.browser.search.2d2d995c58', 'browser'),
+ ...translateSearchKeyword('auto.components.settings.browser.search.bea27bac4b', 'links'),
+ ...translateSearchKeyword(
+ 'auto.components.settings.browser.search.terminalLinkActions.terminal',
+ 'terminal'
+ ),
+ ...translateSearchKeyword(
+ 'auto.components.settings.browser.search.terminalLinkActions.click',
+ 'click'
+ ),
+ ...translateSearchKeyword(
+ 'auto.components.settings.browser.search.terminalLinkActions.actions',
+ 'actions'
+ ),
+ ...translateSearchKeyword(
+ 'auto.components.settings.browser.search.terminalLinkActions.popover',
+ 'popover'
+ ),
+ ...translateSearchKeyword(
+ 'auto.components.settings.browser.search.terminalLinkActions.menu',
+ 'menu'
+ ),
+ ...translateSearchKeyword(
+ 'auto.components.settings.browser.search.terminalLinkActions.disable',
+ 'disable'
+ ),
+ platform.isMac ? 'cmd' : 'ctrl'
+ ]
+}
+
export function getBrowserPaneSearchEntries(
platform: BrowserShortcutPlatform = getDefaultBrowserShortcutPlatform()
): SettingsSearchEntry[] {
@@ -138,6 +171,14 @@ export function getBrowserPaneSearchEntries(
platform.isMac ? 'cmd' : 'ctrl'
]
},
+ {
+ title: translate(
+ 'auto.components.settings.BrowserTerminalLinkActionsSetting.title',
+ 'Show terminal link actions'
+ ),
+ description: getTerminalLinkActionsDescription(platform),
+ keywords: getTerminalLinkActionSearchKeywords(platform)
+ },
{
title: translate(
'auto.components.settings.browser.search.19ea5607cf',
diff --git a/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx b/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx
new file mode 100644
index 00000000000..197f5da8269
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx
@@ -0,0 +1,170 @@
+// @vitest-environment happy-dom
+import type { ReactNode } from 'react'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types'
+import type { TerminalLinkActionRequest } from './terminal-link-action-request'
+
+const mocks = vi.hoisted(() => ({
+ openSettingsPage: vi.fn(),
+ openSettingsTarget: vi.fn()
+}))
+
+vi.mock('@/store', () => ({
+ useAppStore: (
+ selector: (state: {
+ openSettingsPage: () => void
+ openSettingsTarget: (target: unknown) => void
+ }) => unknown
+ ) =>
+ selector({
+ openSettingsPage: mocks.openSettingsPage,
+ openSettingsTarget: mocks.openSettingsTarget
+ })
+}))
+
+vi.mock('@/components/ui/tooltip', () => ({
+ Tooltip: ({ children }: { children: ReactNode }) => children,
+ TooltipTrigger: ({ children }: { children: ReactNode }) => children,
+ TooltipContent: ({ children }: { children: ReactNode }) => {children}
+}))
+
+vi.mock('@/components/ui/popover', () => ({
+ Popover: ({
+ children,
+ open,
+ onOpenChange
+ }: {
+ children: ReactNode
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ }) =>
+ open ? (
+
+
+ ) : null,
+ PopoverAnchor: () => null,
+ PopoverContent: ({ children, className }: { children: ReactNode; className?: string }) => (
+ {children}
+ )
+}))
+
+import { TerminalLinkActionPopover } from './TerminalLinkActionPopover'
+
+afterEach(() => {
+ cleanup()
+ vi.clearAllMocks()
+ vi.unstubAllGlobals()
+})
+
+describe('TerminalLinkActionPopover', () => {
+ it('shows the full destination and runs the selected action', () => {
+ vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
+ const onClose = vi.fn()
+ const focusTerminal = vi.fn()
+ const run = vi.fn()
+ const request: TerminalLinkActionRequest = {
+ paneId: 1,
+ anchorX: 100,
+ anchorY: 200,
+ destination: 'https://example.com/full/hidden/destination?query=actual',
+ kind: 'url',
+ primary: { label: 'Open link', run },
+ alternate: { external: true, label: 'System Browser', run: vi.fn() },
+ focusTerminal
+ }
+
+ render()
+
+ const destination = screen.getByText(request.destination)
+ expect(destination.className).toContain('line-clamp-2')
+ expect(destination.getAttribute('title')).toBe(request.destination)
+ expect(screen.getByText('System Browser')).toBeTruthy()
+ expect(screen.getAllByText('Click')).toHaveLength(2)
+ const textOnlyAction = screen.getByText('Open link').closest('button')
+ const externalAction = screen.getByText('System Browser').closest('button')
+ expect(textOnlyAction?.querySelector('svg')).toBeNull()
+ expect(externalAction?.querySelector('svg')).toBeTruthy()
+ expect(externalAction?.className).toContain('has-[>svg]:px-1.5')
+ expect(externalAction?.className).not.toContain('has-[>svg]:px-3')
+ expect(destination.parentElement?.className).toContain('items-center')
+ expect(destination.parentElement?.className).toContain('py-0.5')
+ expect(destination.closest('[class*="w-max"]')).toBeTruthy()
+
+ fireEvent.click(screen.getByText('Open link'))
+ expect(onClose).toHaveBeenCalledOnce()
+ expect(focusTerminal).toHaveBeenCalledOnce()
+ expect(run).toHaveBeenCalledOnce()
+ })
+
+ it('identifies the dismissed request so a newer request can survive', () => {
+ vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
+ const onClose = vi.fn()
+ const request: TerminalLinkActionRequest = {
+ paneId: 1,
+ anchorX: 100,
+ anchorY: 200,
+ destination: 'https://example.com',
+ kind: 'url',
+ primary: { label: 'Open link', run: vi.fn() },
+ focusTerminal: vi.fn()
+ }
+
+ render()
+ fireEvent.click(screen.getByTestId('dismiss-popover'))
+
+ expect(onClose).toHaveBeenCalledWith(request)
+ })
+
+ it('uses distinct icons for system and Orca browser actions', () => {
+ vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
+ const request: TerminalLinkActionRequest = {
+ paneId: 1,
+ anchorX: 100,
+ anchorY: 200,
+ destination: 'https://example.com',
+ kind: 'url',
+ primary: { external: false, label: 'Orca Browser', run: vi.fn() },
+ alternate: { external: true, label: 'System Browser', run: vi.fn() },
+ focusTerminal: vi.fn()
+ }
+
+ render()
+
+ expect(
+ screen.getByText('Orca Browser').closest('button')?.querySelector('.lucide-globe')
+ ).toBeTruthy()
+ expect(
+ screen.getByText('System Browser').closest('button')?.querySelector('.lucide-external-link')
+ ).toBeTruthy()
+ })
+
+ it('opens the terminal link setting from the compact settings button', () => {
+ vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
+ const onClose = vi.fn()
+ const focusTerminal = vi.fn()
+ const request: TerminalLinkActionRequest = {
+ paneId: 1,
+ anchorX: 100,
+ anchorY: 200,
+ destination: 'https://example.com',
+ kind: 'url',
+ primary: { label: 'System Browser', run: vi.fn() },
+ focusTerminal
+ }
+
+ render()
+ fireEvent.click(screen.getByRole('button', { name: 'Terminal link settings' }))
+
+ expect(onClose).toHaveBeenCalledOnce()
+ expect(mocks.openSettingsTarget).toHaveBeenCalledWith({
+ pane: 'browser',
+ repoId: null,
+ sectionId: BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID
+ })
+ expect(mocks.openSettingsPage).toHaveBeenCalledOnce()
+ expect(focusTerminal).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.tsx b/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.tsx
new file mode 100644
index 00000000000..c313edb545a
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.tsx
@@ -0,0 +1,140 @@
+import { useMemo } from 'react'
+import { ExternalLink, Globe, Settings } from 'lucide-react'
+import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
+import { Button } from '@/components/ui/button'
+import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { translate } from '@/i18n/i18n'
+import { BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types'
+import { useAppStore } from '@/store'
+import type { TerminalLinkAction, TerminalLinkActionRequest } from './terminal-link-action-request'
+
+type TerminalLinkActionPopoverProps = {
+ request: TerminalLinkActionRequest | null
+ onClose: (dismissed?: TerminalLinkActionRequest) => void
+}
+
+function ActionRow({
+ action,
+ alternate,
+ onRun
+}: {
+ action: TerminalLinkAction
+ alternate: boolean
+ onRun: () => void
+}): React.JSX.Element {
+ const isMac = navigator.userAgent.includes('Mac')
+ const keys = alternate
+ ? [isMac ? '⇧' : 'Shift', isMac ? '⌘' : 'Ctrl', 'Click']
+ : [isMac ? '⌘' : 'Ctrl', 'Click']
+
+ return (
+
+ )
+}
+
+export function TerminalLinkActionPopover({
+ request,
+ onClose
+}: TerminalLinkActionPopoverProps): React.JSX.Element {
+ const openSettingsPage = useAppStore((state) => state.openSettingsPage)
+ const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
+ const virtualRef = useMemo(
+ () => ({
+ current: {
+ getBoundingClientRect: () => new DOMRect(request?.anchorX ?? 0, request?.anchorY ?? 0, 0, 0)
+ }
+ }),
+ [request?.anchorX, request?.anchorY]
+ )
+
+ const runAction = (action: TerminalLinkAction): void => {
+ onClose()
+ request?.focusTerminal()
+ void action.run()
+ }
+
+ const settingsLabel = translate(
+ 'auto.components.terminal.pane.TerminalLinkActionPopover.terminalLinkSettings',
+ 'Terminal link settings'
+ )
+
+ const openTerminalLinkSettings = (): void => {
+ onClose()
+ openSettingsTarget({
+ pane: 'browser',
+ repoId: null,
+ sectionId: BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID
+ })
+ openSettingsPage()
+ }
+
+ return (
+ !open && onClose(request ?? undefined)}
+ >
+
+ {request ? (
+ event.preventDefault()}
+ onCloseAutoFocus={(event) => event.preventDefault()}
+ onEscapeKeyDown={() => request.focusTerminal()}
+ >
+
+
+ {request.destination}
+
+
+
+
+
+
+ {settingsLabel}
+
+
+
+ runAction(request.primary)}
+ />
+ {request.alternate ? (
+ runAction(request.alternate!)}
+ />
+ ) : null}
+
+ ) : null}
+
+ )
+}
diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
index e4862c5f085..d0e9dee537d 100644
--- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
@@ -93,6 +93,11 @@ import {
import { useSystemPrefersDark } from './use-system-prefers-dark'
import { useTerminalPaneGlobalEffects } from './use-terminal-pane-global-effects'
import { useTerminalPaneLifecycle } from './use-terminal-pane-lifecycle'
+import { TerminalLinkActionPopover } from './TerminalLinkActionPopover'
+import {
+ closeTerminalLinkActionRequest,
+ type TerminalLinkActionRequest
+} from './terminal-link-action-request'
import { useTerminalPaneContextMenu } from './use-terminal-pane-context-menu'
import {
detachTerminalPaneToTab,
@@ -390,6 +395,14 @@ function TerminalPane(
const [paneCount, setPaneCount] = useState(0)
// Why: pane reorders can move panes without changing count or size, so overlay rects need an explicit layout-change render trigger.
const [paneLayoutRevision, setPaneLayoutRevision] = useState(0)
+ const [terminalLinkActionRequest, setTerminalLinkActionRequest] =
+ useState(null)
+ const requestTerminalLinkAction = useCallback((request: TerminalLinkActionRequest) => {
+ setTerminalLinkActionRequest(request)
+ }, [])
+ const closeTerminalLinkActions = useCallback((dismissed?: TerminalLinkActionRequest) => {
+ setTerminalLinkActionRequest((current) => closeTerminalLinkActionRequest(current, dismissed))
+ }, [])
const [searchOpen, setSearchOpen] = useState(false)
const searchOpenRef = useRef(false)
searchOpenRef.current = searchOpen
@@ -1382,6 +1395,7 @@ function TerminalPane(
settings,
settingsRef,
requestOpenLinksInAppPreference,
+ requestTerminalLinkAction,
effectiveMacOptionAsAlt,
effectiveMacOptionAsAltRef: macOptionAsAltRef,
initialLayoutRef,
@@ -1434,6 +1448,10 @@ function TerminalPane(
onExternalPaneDrop: handleExternalPaneDrop
})
+ useEffect(() => {
+ closeTerminalLinkActions()
+ }, [closeTerminalLinkActions, isActive, isRendererVisible, paneLayoutRevision])
+
useEffect(() => {
const manager = managerRef.current
if (!manager || !restoredLayout.root) {
@@ -3112,6 +3130,10 @@ function TerminalPane(
onCopyTerminalId={() => void contextMenu.onCopyTerminalId()}
onCopyPaneId={contextMenu.onCopyPaneId}
/>
+
{/* Why: repos is a broad store slice; only subscribe while the editor is visible. */}
{quickCommandEditorOpen ? (
({
+ canOpenWithSystemDefault: true,
+ openDetectedFilePath: vi.fn(),
+ worktreeRoot: false
+}))
+
+vi.mock('./terminal-file-open-routing', () => ({
+ getTerminalFileContext: () => ({}),
+ mapTerminalFilePath: (filePath: string) => filePath,
+ openDetectedFilePath: mocks.openDetectedFilePath,
+ shouldOpenTerminalFileWithSystemDefault: () => mocks.canOpenWithSystemDefault,
+ terminalLinkWslDistro: () => null
+}))
+
+vi.mock('./terminal-worktree-path-link', () => ({
+ resolveKnownWorktreeRootPathLink: () => (mocks.worktreeRoot ? { id: 'wt-2' } : null)
+}))
+
+import { handleTerminalFileLink } from './terminal-file-link-actions'
+
+const deps = { worktreeId: 'wt-1', worktreePath: '/repo' }
+
+function plainEvent(): MouseEvent {
+ return {
+ button: 0,
+ metaKey: false,
+ ctrlKey: false,
+ shiftKey: false,
+ altKey: false,
+ clientX: 12,
+ clientY: 24,
+ preventDefault: vi.fn()
+ } as unknown as MouseEvent
+}
+
+function context(request: ReturnType): TerminalLinkActionContext {
+ return {
+ paneId: 3,
+ pointerGesture: { canRequestAction: () => true, dispose: vi.fn() },
+ request: request as TerminalLinkActionContext['request'],
+ focusTerminal: vi.fn()
+ }
+}
+
+beforeEach(() => {
+ vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
+ mocks.canOpenWithSystemDefault = true
+ mocks.worktreeRoot = false
+ vi.clearAllMocks()
+})
+
+afterEach(() => vi.unstubAllGlobals())
+
+describe('terminal file link actions', () => {
+ it('offers Orca and system-default actions for a local file', () => {
+ const request = vi.fn()
+ expect(
+ handleTerminalFileLink('/repo/src/main.ts', 12, 4, plainEvent(), deps, context(request))
+ ).toBe(true)
+
+ const actionRequest = request.mock.calls[0][0]
+ expect(actionRequest).toEqual(
+ expect.objectContaining({
+ destination: '/repo/src/main.ts',
+ kind: 'file',
+ primary: expect.objectContaining({ label: 'Open file' }),
+ alternate: expect.objectContaining({ label: 'Open with default app' })
+ })
+ )
+ actionRequest.primary.run()
+ actionRequest.alternate.run()
+ expect(mocks.openDetectedFilePath).toHaveBeenNthCalledWith(1, '/repo/src/main.ts', 12, 4, deps)
+ expect(mocks.openDetectedFilePath).toHaveBeenNthCalledWith(2, '/repo/src/main.ts', 12, 4, {
+ ...deps,
+ openWithSystemDefault: true
+ })
+ })
+
+ it('labels workspace switching and omits an impossible remote alternate', () => {
+ mocks.worktreeRoot = true
+ mocks.canOpenWithSystemDefault = false
+ const request = vi.fn()
+ handleTerminalFileLink('/repo', null, null, plainEvent(), deps, context(request))
+
+ const actionRequest = request.mock.calls[0][0]
+ expect(actionRequest).toEqual(
+ expect.objectContaining({
+ kind: 'workspace',
+ primary: expect.objectContaining({ label: 'Switch workspace' })
+ })
+ )
+ expect(actionRequest).not.toHaveProperty('alternate')
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts b/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts
new file mode 100644
index 00000000000..bd3247ee9d2
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts
@@ -0,0 +1,96 @@
+import {
+ getTerminalFileContext,
+ mapTerminalFilePath,
+ openDetectedFilePath,
+ shouldOpenTerminalFileWithSystemDefault,
+ terminalLinkWslDistro
+} from './terminal-file-open-routing'
+import { isTerminalLinkDirectActivation } from './terminal-link-activation'
+import {
+ requestTerminalLinkAction,
+ type TerminalLinkActionContext
+} from './terminal-link-action-request'
+import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link'
+import { translate } from '@/i18n/i18n'
+
+export type TerminalFileLinkActionDeps = {
+ worktreeId: string
+ worktreePath: string
+ runtimeEnvironmentId?: string | null
+ wslDistro?: string | null
+}
+
+export function handleTerminalFileLink(
+ filePath: string,
+ line: number | null,
+ column: number | null,
+ event: MouseEvent | undefined,
+ deps: TerminalFileLinkActionDeps,
+ actionContext?: TerminalLinkActionContext | null,
+ actionDestination?: string
+): boolean {
+ if (isTerminalLinkDirectActivation(event)) {
+ event?.preventDefault?.()
+ openDetectedFilePath(filePath, line, column, {
+ ...deps,
+ openWithSystemDefault: Boolean(event?.shiftKey)
+ })
+ return true
+ }
+
+ const mappedPath = mapTerminalFilePath(
+ filePath,
+ deps.worktreePath,
+ terminalLinkWslDistro(deps.wslDistro, deps.runtimeEnvironmentId)
+ )
+ const fileContext = getTerminalFileContext(
+ deps.worktreeId,
+ deps.worktreePath,
+ deps.runtimeEnvironmentId
+ )
+ const worktreeRoot = resolveKnownWorktreeRootPathLink(mappedPath)
+ const canOpenWithSystemDefault = shouldOpenTerminalFileWithSystemDefault(fileContext, mappedPath)
+ const isMac = navigator.userAgent.includes('Mac')
+
+ return requestTerminalLinkAction(event, actionContext, {
+ destination: actionDestination ?? mappedPath,
+ kind: worktreeRoot ? 'workspace' : 'file',
+ primary: {
+ label: worktreeRoot
+ ? translate(
+ 'auto.components.terminal.pane.TerminalLinkActionPopover.switchWorkspace',
+ 'Switch workspace'
+ )
+ : translate(
+ 'auto.components.terminal.pane.TerminalLinkActionPopover.openFile',
+ 'Open file'
+ ),
+ run: () => openDetectedFilePath(filePath, line, column, deps)
+ },
+ ...(canOpenWithSystemDefault
+ ? {
+ alternate: {
+ label: worktreeRoot
+ ? isMac
+ ? translate(
+ 'auto.components.terminal.pane.TerminalLinkActionPopover.openInFinder',
+ 'Open in Finder'
+ )
+ : translate(
+ 'auto.components.terminal.pane.TerminalLinkActionPopover.openFolder',
+ 'Open folder'
+ )
+ : translate(
+ 'auto.components.terminal.pane.TerminalLinkActionPopover.openWithDefaultApp',
+ 'Open with default app'
+ ),
+ run: () =>
+ openDetectedFilePath(filePath, line, column, {
+ ...deps,
+ openWithSystemDefault: true
+ })
+ }
+ }
+ : {})
+ })
+}
diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts b/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts
index 2fb6064f814..3c042a12d24 100644
--- a/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts
@@ -11,6 +11,7 @@ import {
findTerminalHandleTarget,
focusRendererTerminalHandle
} from './terminal-handle-links'
+import type { TerminalLinkActionContext } from './terminal-link-action-request'
const mocks = vi.hoisted(() => ({
activateTabAndFocusPane: vi.fn(),
@@ -84,7 +85,8 @@ function setPlatform(userAgent: string): void {
async function collectLinks(
rows: TestBufferLine[],
bufferLineNumber = 1,
- runtimeEnvironmentId: string | null = null
+ runtimeEnvironmentId: string | null = null,
+ linkActionContext?: TerminalLinkActionContext
): Promise {
const terminal = {
buffer: {
@@ -97,7 +99,8 @@ async function collectLinks(
const provider = createTerminalHandleLinkProvider({
getTerminal: () => terminal as never,
getRuntimeEnvironmentId: () => runtimeEnvironmentId,
- linkTooltip: { textContent: '', style: { display: '' } } as unknown as HTMLElement
+ linkTooltip: { textContent: '', style: { display: '' } } as unknown as HTMLElement,
+ getLinkActionContext: () => linkActionContext ?? null
})
return await new Promise((resolve) => {
provider.provideLinks(bufferLineNumber, (links) => resolve(links ?? []))
@@ -303,6 +306,39 @@ describe('createTerminalHandleLinkProvider', () => {
})
})
+ it('defers a plain terminal-handle click to the action popover', async () => {
+ const request = vi.fn()
+ const links = await collectLinks([makeBufferLine('Worker: term_worker')], 1, null, {
+ paneId: 4,
+ pointerGesture: { canRequestAction: () => true, dispose: vi.fn() },
+ request,
+ focusTerminal: vi.fn()
+ })
+
+ links[0].activate(
+ {
+ button: 0,
+ metaKey: false,
+ ctrlKey: false,
+ shiftKey: false,
+ altKey: false,
+ clientX: 20,
+ clientY: 30,
+ preventDefault: vi.fn()
+ } as unknown as MouseEvent,
+ links[0].text
+ )
+
+ expect(request).toHaveBeenCalledWith(
+ expect.objectContaining({
+ destination: 'term_worker',
+ kind: 'terminal',
+ primary: expect.objectContaining({ label: 'Switch terminal' })
+ })
+ )
+ expect(window.api.runtime.call).not.toHaveBeenCalled()
+ })
+
it('uses the owning runtime for terminal links when a renderer match belongs to another runtime', async () => {
markRuntimeEnvironmentCompatible('env-1')
mocks.storeState.tabsByWorktree = {
diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts
index baf2adbde90..b9b949fc83f 100644
--- a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts
@@ -11,6 +11,15 @@ import {
focusRuntimeOrchestrationTask,
ORCHESTRATION_TASK_PREFIX
} from './terminal-orchestration-task-links'
+import {
+ isTerminalLinkActionActivation,
+ isTerminalLinkDirectActivation
+} from './terminal-link-activation'
+import {
+ requestTerminalLinkAction,
+ type TerminalLinkActionContext
+} from './terminal-link-action-request'
+import { translate } from '@/i18n/i18n'
export { extractOrchestrationTaskLinks } from './terminal-orchestration-task-links'
export type { ParsedOrchestrationTaskLink } from './terminal-orchestration-task-links'
@@ -36,6 +45,7 @@ type TerminalHandleLinkProviderDeps = {
getTerminal: () => Terminal | null
getRuntimeEnvironmentId: () => string | null
linkTooltip: HTMLElement
+ getLinkActionContext?: () => TerminalLinkActionContext | null
}
const TERMINAL_HANDLE_PREFIX = 'term_'
@@ -188,15 +198,44 @@ export function createTerminalHandleLinkProvider(
range,
text: parsed.text,
activate: (event) => {
- if (!isTerminalHandleLinkActivation(event)) {
+ const directActivation = isTerminalLinkDirectActivation(event)
+ const actionActivation = isTerminalLinkActionActivation(event)
+ if (!directActivation && !actionActivation) {
return
}
- event?.preventDefault()
- void activateParsedLink(parsed, deps.getRuntimeEnvironmentId())
- terminal.clearSelection()
+ let handled = false
+ if (directActivation) {
+ event?.preventDefault()
+ void activateParsedLink(parsed, deps.getRuntimeEnvironmentId())
+ handled = true
+ } else {
+ handled = requestTerminalLinkAction(event, deps.getLinkActionContext?.(), {
+ destination: parsed.text,
+ kind: parsed.kind,
+ primary: {
+ label:
+ parsed.kind === 'terminal'
+ ? translate(
+ 'auto.components.terminal.pane.TerminalLinkActionPopover.switchTerminal',
+ 'Switch terminal'
+ )
+ : translate(
+ 'auto.components.terminal.pane.TerminalLinkActionPopover.openTaskTerminal',
+ 'Open task terminal'
+ ),
+ run: () => activateParsedLink(parsed, deps.getRuntimeEnvironmentId())
+ }
+ })
+ }
+ if (handled) {
+ terminal.clearSelection()
+ }
},
hover: () => {
- deps.linkTooltip.textContent = `${parsed.text} (${getTerminalHandleFocusHint()})`
+ const showActions = deps.getLinkActionContext
+ ? deps.getLinkActionContext() !== null
+ : true
+ deps.linkTooltip.textContent = `${parsed.text} (${getTerminalHandleFocusHint(showActions)})`
deps.linkTooltip.style.display = ''
},
leave: () => {
@@ -252,17 +291,11 @@ function ptyIdMatchesTerminalHandle(
return ptyEnvironmentId === targetEnvironmentId
}
-function getTerminalHandleFocusHint(): string {
+function getTerminalHandleFocusHint(showActions: boolean): string {
+ const prefix = showActions ? 'Click for actions or ' : ''
return navigator.userAgent.includes('Mac')
- ? '⌘+click to switch terminal'
- : 'Ctrl+click to switch terminal'
-}
-
-function isTerminalHandleLinkActivation(
- event: Pick | undefined
-): boolean {
- const isMac = navigator.userAgent.includes('Mac')
- return isMac ? Boolean(event?.metaKey) : Boolean(event?.ctrlKey)
+ ? `${prefix}⌘+click to switch terminal`
+ : `${prefix}Ctrl+click to switch terminal`
}
async function focusRuntimeTerminalHandle(
diff --git a/src/renderer/src/components/terminal-pane/terminal-http-link-activation.ts b/src/renderer/src/components/terminal-pane/terminal-http-link-activation.ts
index c9e941c874d..893ec638cbf 100644
--- a/src/renderer/src/components/terminal-pane/terminal-http-link-activation.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-http-link-activation.ts
@@ -1,7 +1,5 @@
-import { isTerminalLinkActivation } from './terminal-link-activation'
+import { isTerminalLinkDirectActivation } from './terminal-link-activation'
export function isTerminalHttpLinkActivation(event: MouseEvent | undefined): boolean {
- // Why: xterm deliberately forwards Alt-modified mouse gestures to the PTY,
- // so plain HTTP link handling must leave those gestures to the child TUI.
- return Boolean(event && !event.altKey && isTerminalLinkActivation(event))
+ return isTerminalLinkDirectActivation(event)
}
diff --git a/src/renderer/src/components/terminal-pane/terminal-link-action-request.ts b/src/renderer/src/components/terminal-pane/terminal-link-action-request.ts
new file mode 100644
index 00000000000..653ab34597a
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-link-action-request.ts
@@ -0,0 +1,67 @@
+import type { TerminalLinkPointerGesture } from './terminal-link-pointer-gesture'
+import { isTerminalLinkActionActivation } from './terminal-link-activation'
+
+export type TerminalLinkActionKind = 'url' | 'file' | 'workspace' | 'terminal' | 'task'
+
+export type TerminalLinkAction = {
+ external?: boolean
+ label: string
+ run: () => void | Promise
+}
+
+export type TerminalLinkActionRequest = {
+ paneId: number
+ anchorX: number
+ anchorY: number
+ destination: string
+ kind: TerminalLinkActionKind
+ primary: TerminalLinkAction
+ alternate?: TerminalLinkAction
+ focusTerminal: () => void
+}
+
+export type TerminalLinkActionRequester = (request: TerminalLinkActionRequest) => void
+
+export type TerminalLinkActionContext = {
+ paneId: number
+ pointerGesture: TerminalLinkPointerGesture
+ request: TerminalLinkActionRequester
+ focusTerminal: () => void
+}
+
+export function closeTerminalLinkActionRequest(
+ current: TerminalLinkActionRequest | null,
+ dismissed?: TerminalLinkActionRequest
+): TerminalLinkActionRequest | null {
+ return dismissed && current !== dismissed ? current : null
+}
+
+type LinkActionDetails = Pick<
+ TerminalLinkActionRequest,
+ 'destination' | 'kind' | 'primary' | 'alternate'
+>
+
+export function requestTerminalLinkAction(
+ event: MouseEvent | undefined,
+ context: TerminalLinkActionContext | null | undefined,
+ details: LinkActionDetails
+): boolean {
+ if (
+ !event ||
+ !context ||
+ !isTerminalLinkActionActivation(event) ||
+ !context.pointerGesture.canRequestAction(event)
+ ) {
+ return false
+ }
+
+ event.preventDefault()
+ context.request({
+ ...details,
+ paneId: context.paneId,
+ anchorX: event.clientX,
+ anchorY: event.clientY,
+ focusTerminal: context.focusTerminal
+ })
+ return true
+}
diff --git a/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts
new file mode 100644
index 00000000000..00cdcd3c687
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts
@@ -0,0 +1,162 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing'
+import {
+ closeTerminalLinkActionRequest,
+ type TerminalLinkActionContext,
+ type TerminalLinkActionRequest
+} from './terminal-link-action-request'
+import { handleOscLink } from './terminal-osc-link-routing'
+import { handleTerminalHttpLink } from './terminal-url-link-hit-testing'
+
+const openUrl = vi.fn()
+const createBrowserTab = vi.fn()
+const setActiveWorktree = vi.fn()
+
+function plainEvent(): MouseEvent {
+ return {
+ button: 0,
+ metaKey: false,
+ ctrlKey: false,
+ shiftKey: false,
+ altKey: false,
+ clientX: 40,
+ clientY: 60,
+ preventDefault: vi.fn()
+ } as unknown as MouseEvent
+}
+
+function actionContext(request = vi.fn()): TerminalLinkActionContext {
+ return {
+ paneId: 7,
+ pointerGesture: { canRequestAction: () => true, dispose: vi.fn() },
+ request,
+ focusTerminal: vi.fn()
+ }
+}
+
+beforeEach(() => {
+ vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
+ vi.stubGlobal('window', { api: { shell: { openUrl } } })
+ registerHttpLinkStoreAccessor(() => ({
+ settings: { openLinksInApp: false },
+ setActiveWorktree,
+ createBrowserTab
+ }))
+})
+
+afterEach(() => {
+ vi.clearAllMocks()
+ vi.unstubAllGlobals()
+})
+
+describe('terminal link action routing', () => {
+ it('keeps a replacement request when the previous popover dismisses', () => {
+ const first = { destination: 'https://first.example' } as TerminalLinkActionRequest
+ const second = { destination: 'https://second.example' } as TerminalLinkActionRequest
+
+ expect(closeTerminalLinkActionRequest(second, first)).toBe(second)
+ expect(closeTerminalLinkActionRequest(first, first)).toBeNull()
+ })
+
+ it('offers system browser first and Orca second when system browser is the default', () => {
+ const request = vi.fn()
+ const event = plainEvent()
+
+ expect(
+ handleTerminalHttpLink('https://example.com/path', event, {
+ worktreeId: 'wt-1',
+ linkActionContext: actionContext(request),
+ actionDestinations: { primary: 'system', alternate: 'orca' }
+ })
+ ).toBe(true)
+ expect(openUrl).not.toHaveBeenCalled()
+ expect(request).toHaveBeenCalledWith(
+ expect.objectContaining({
+ paneId: 7,
+ anchorX: 40,
+ anchorY: 60,
+ destination: 'https://example.com/path',
+ kind: 'url'
+ })
+ )
+ expect(request.mock.calls[0][0].primary.label).toBe('System Browser')
+ expect(request.mock.calls[0][0].primary.external).toBe(true)
+ expect(request.mock.calls[0][0].alternate.label).toBe('Orca Browser')
+ expect(request.mock.calls[0][0].alternate.external).toBe(false)
+
+ request.mock.calls[0][0].primary.run()
+ expect(openUrl).toHaveBeenCalledWith('https://example.com/path')
+
+ request.mock.calls[0][0].alternate.run()
+ expect(createBrowserTab).toHaveBeenCalledWith('wt-1', 'https://example.com/path', {
+ activate: true
+ })
+ })
+
+ it('offers Orca first and system browser second when Orca is the default', () => {
+ const request = vi.fn()
+
+ handleTerminalHttpLink('https://example.com/path', plainEvent(), {
+ worktreeId: 'wt-1',
+ linkActionContext: actionContext(request),
+ actionDestinations: { primary: 'orca', alternate: 'system' }
+ })
+
+ expect(request.mock.calls[0][0].primary.label).toBe('Orca Browser')
+ expect(request.mock.calls[0][0].primary.external).toBe(false)
+ expect(request.mock.calls[0][0].alternate.label).toBe('System Browser')
+ expect(request.mock.calls[0][0].alternate.external).toBe(true)
+
+ request.mock.calls[0][0].primary.run()
+ expect(createBrowserTab).toHaveBeenCalledWith('wt-1', 'https://example.com/path', {
+ activate: true
+ })
+
+ request.mock.calls[0][0].alternate.run()
+ expect(openUrl).toHaveBeenCalledWith('https://example.com/path')
+ })
+
+ it('offers only the system browser for a remote link', () => {
+ const request = vi.fn()
+
+ handleTerminalHttpLink('https://example.com/path', plainEvent(), {
+ worktreeId: 'wt-1',
+ sourceOwner: { kind: 'ssh', connectionId: 'ssh-1' },
+ linkActionContext: actionContext(request),
+ actionDestinations: { primary: 'system' }
+ })
+
+ expect(request.mock.calls[0][0].primary.label).toBe('System Browser')
+ expect(request.mock.calls[0][0].primary.external).toBe(true)
+ expect(request.mock.calls[0][0].alternate).toBeUndefined()
+ })
+
+ it('uses Shift+modifier for the alternate local destination', () => {
+ const event = { ...plainEvent(), metaKey: true, shiftKey: true }
+
+ handleTerminalHttpLink('https://example.com/path', event, {
+ worktreeId: 'wt-1',
+ actionDestinations: { primary: 'system', alternate: 'orca' }
+ })
+
+ expect(createBrowserTab).toHaveBeenCalledWith('wt-1', 'https://example.com/path', {
+ activate: true
+ })
+ expect(openUrl).not.toHaveBeenCalled()
+ })
+
+ it('exposes the actual hidden OSC 8 destination', () => {
+ const request = vi.fn()
+ const destination = 'https://example.com'
+
+ expect(
+ handleOscLink(destination, plainEvent(), {
+ worktreeId: 'wt-1',
+ worktreePath: '/repo',
+ startupCwd: '/repo',
+ linkActionContext: actionContext(request)
+ })
+ ).toBe(true)
+ expect(request.mock.calls[0][0].destination).toBe(destination)
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/terminal-link-activation.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-activation.test.ts
new file mode 100644
index 00000000000..ce88c8feeaf
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-link-activation.test.ts
@@ -0,0 +1,53 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+ isTerminalLinkActionActivation,
+ isTerminalLinkDirectActivation,
+ isTerminalOwnedLinkGesture
+} from './terminal-link-activation'
+
+function event(overrides: Partial = {}): MouseEvent {
+ return {
+ button: 0,
+ metaKey: false,
+ ctrlKey: false,
+ shiftKey: false,
+ altKey: false,
+ ...overrides
+ } as MouseEvent
+}
+
+afterEach(() => vi.unstubAllGlobals())
+
+describe('terminal link activation', () => {
+ it('uses plain click for actions and Command-click for direct open on macOS', () => {
+ vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
+
+ expect(isTerminalLinkActionActivation(event())).toBe(true)
+ expect(isTerminalLinkDirectActivation(event({ metaKey: true }))).toBe(true)
+ expect(isTerminalOwnedLinkGesture(event())).toBe(true)
+ })
+
+ it('reserves macOS Control-click for the context menu', () => {
+ vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
+
+ expect(isTerminalLinkActionActivation(event({ ctrlKey: true }))).toBe(false)
+ expect(isTerminalLinkDirectActivation(event({ ctrlKey: true }))).toBe(false)
+ })
+
+ it('uses Ctrl-click for direct open on Windows and Linux', () => {
+ vi.stubGlobal('navigator', { userAgent: 'Windows' })
+
+ expect(isTerminalLinkActionActivation(event())).toBe(true)
+ expect(isTerminalLinkDirectActivation(event({ ctrlKey: true }))).toBe(true)
+ expect(isTerminalLinkDirectActivation(event({ metaKey: true }))).toBe(false)
+ })
+
+ it('leaves Shift-only, Alt, and non-primary gestures unowned', () => {
+ vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
+
+ expect(isTerminalOwnedLinkGesture(event({ shiftKey: true }))).toBe(false)
+ expect(isTerminalOwnedLinkGesture(event({ altKey: true }))).toBe(false)
+ expect(isTerminalOwnedLinkGesture(event({ button: 2 }))).toBe(false)
+ expect(isTerminalLinkDirectActivation(event({ metaKey: true, shiftKey: true }))).toBe(true)
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/terminal-link-activation.ts b/src/renderer/src/components/terminal-pane/terminal-link-activation.ts
index 41def2095c1..aae05daa875 100644
--- a/src/renderer/src/components/terminal-pane/terminal-link-activation.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-link-activation.ts
@@ -5,3 +5,30 @@ export function isTerminalLinkActivation(
): boolean {
return isMacPlatform() ? Boolean(event?.metaKey) : Boolean(event?.ctrlKey)
}
+
+type TerminalLinkMouseEvent = Pick &
+ Partial>
+
+export function isTerminalLinkDirectActivation(event: TerminalLinkMouseEvent | undefined): boolean {
+ return Boolean(
+ event &&
+ (event.button === undefined || event.button === 0) &&
+ !event.altKey &&
+ isTerminalLinkActivation(event)
+ )
+}
+
+export function isTerminalLinkActionActivation(event: TerminalLinkMouseEvent | undefined): boolean {
+ return Boolean(
+ event &&
+ (event.button === undefined || event.button === 0) &&
+ !event.altKey &&
+ !event.shiftKey &&
+ !event.metaKey &&
+ !event.ctrlKey
+ )
+}
+
+export function isTerminalOwnedLinkGesture(event: TerminalLinkMouseEvent | undefined): boolean {
+ return isTerminalLinkDirectActivation(event) || isTerminalLinkActionActivation(event)
+}
diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts
index 3bb650b0e3a..74574dea02a 100644
--- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts
@@ -505,17 +505,36 @@ describe('handleOscLink', () => {
it('advertises the system default open behavior in hover hints', () => {
setPlatform('Macintosh')
- expect(getTerminalFileOpenHint()).toBe('⌘+click to open or ⇧⌘+click for default app')
- expect(getTerminalHtmlFileOpenHint()).toBe('⌘+click to open or ⇧⌘+click for default browser')
- expect(getTerminalUrlOpenHint()).toBe('⌘+click to open or ⇧⌘+click for system browser')
-
- setPlatform('Windows')
- expect(getTerminalFileOpenHint()).toBe('Ctrl+click to open or Shift+Ctrl+click for default app')
+ expect(getTerminalFileOpenHint()).toBe(
+ 'Click for actions, ⌘+click to open, or ⇧⌘+click for default app'
+ )
expect(getTerminalHtmlFileOpenHint()).toBe(
- 'Ctrl+click to open or Shift+Ctrl+click for default browser'
+ 'Click for actions, ⌘+click to open, or ⇧⌘+click for default browser'
)
expect(getTerminalUrlOpenHint()).toBe(
- 'Ctrl+click to open or Shift+Ctrl+click for system browser'
+ 'Click for actions, ⌘+click to open, or ⇧⌘+click for system browser'
+ )
+
+ setPlatform('Windows')
+ expect(getTerminalFileOpenHint()).toBe(
+ 'Click for actions, Ctrl+click to open, or Shift+Ctrl+click for default app'
+ )
+ expect(getTerminalHtmlFileOpenHint()).toBe(
+ 'Click for actions, Ctrl+click to open, or Shift+Ctrl+click for default browser'
+ )
+ expect(getTerminalUrlOpenHint()).toBe(
+ 'Click for actions, Ctrl+click to open, or Shift+Ctrl+click for system browser'
+ )
+ })
+
+ it('omits plain-click actions from hover hints when the popover is disabled', () => {
+ setPlatform('Macintosh')
+ expect(getTerminalFileOpenHint(false)).toBe('⌘+click to open, or ⇧⌘+click for default app')
+ expect(getTerminalHtmlFileOpenHint(false)).toBe(
+ '⌘+click to open, or ⇧⌘+click for default browser'
+ )
+ expect(getTerminalUrlOpenHint({ showActions: false })).toBe(
+ '⌘+click to open, or ⇧⌘+click for system browser'
)
})
@@ -1383,7 +1402,7 @@ describe('createFilePathLinkProvider range bounds', () => {
links[0]!.hover?.({} as MouseEvent, links[0]!.text)
expect(linkTooltip.textContent).toBe(
- '/repo/CLAUDE.md (⌘+click to open or ⇧⌘+click for default app)'
+ '/repo/CLAUDE.md (Click for actions, ⌘+click to open, or ⇧⌘+click for default app)'
)
})
@@ -1426,7 +1445,7 @@ describe('createFilePathLinkProvider range bounds', () => {
links[0]!.hover?.({} as MouseEvent, links[0]!.text)
expect(linkTooltip.textContent).toBe(
- '/repo (⌘+click to switch workspace or ⇧⌘+click to open in Finder)'
+ '/repo (Click for actions, ⌘+click to switch workspace, or ⇧⌘+click to open in Finder)'
)
})
@@ -1448,7 +1467,7 @@ describe('createFilePathLinkProvider range bounds', () => {
expect(window.api.shell.pathExists).not.toHaveBeenCalled()
expect(linkTooltip.textContent).toBe(
- '/repo (⌘+click to switch workspace or ⇧⌘+click to open in Finder)'
+ '/repo (Click for actions, ⌘+click to switch workspace, or ⇧⌘+click to open in Finder)'
)
})
@@ -1481,7 +1500,7 @@ describe('createFilePathLinkProvider range bounds', () => {
links[0]!.hover?.({} as MouseEvent, links[0]!.text)
expect(linkTooltip.textContent).toBe(
- '/repo (⌘+click to switch workspace or ⇧⌘+click to open in Finder)'
+ '/repo (Click for actions, ⌘+click to switch workspace, or ⇧⌘+click to open in Finder)'
)
})
@@ -1499,7 +1518,9 @@ describe('createFilePathLinkProvider range bounds', () => {
expect(links[0]).toBeDefined()
links[0]!.hover?.({} as MouseEvent, links[0]!.text)
- expect(linkTooltip.textContent).toBe('/repo (Ctrl+click to switch workspace)')
+ expect(linkTooltip.textContent).toBe(
+ '/repo (Click for actions or Ctrl+click to switch workspace)'
+ )
})
it('shows the Orca hint for SSH file link hover', async () => {
@@ -1513,7 +1534,9 @@ describe('createFilePathLinkProvider range bounds', () => {
expect(links[0]).toBeDefined()
links[0]!.hover?.({} as MouseEvent, links[0]!.text)
- expect(linkTooltip.textContent).toBe('/repo/CLAUDE.md (⌘+click to open in Orca)')
+ expect(linkTooltip.textContent).toBe(
+ '/repo/CLAUDE.md (Click for actions or ⌘+click to open in Orca)'
+ )
})
it('bounds the terminal path-exists cache while preserving recent probes', async () => {
diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts
index 2f4b9155a4e..b3890bb18b5 100644
--- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts
@@ -15,7 +15,6 @@ import {
getTerminalFileContext,
isHtmlFilePath,
mapTerminalFilePath,
- openDetectedFilePath,
shouldOpenTerminalFileWithSystemDefault,
terminalLinkWslDistro
} from './terminal-file-open-routing'
@@ -38,8 +37,10 @@ import {
getTerminalUrlOpenHint
} from './terminal-link-open-hints'
import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link'
-import { isTerminalLinkActivation } from './terminal-link-activation'
+import { isTerminalLinkDirectActivation } from './terminal-link-activation'
import { getTerminalBufferPositionForMouseEvent } from './terminal-mouse-buffer-position'
+import type { TerminalLinkActionContext } from './terminal-link-action-request'
+import { handleTerminalFileLink } from './terminal-file-link-actions'
export { openDetectedFilePath } from './terminal-file-open-routing'
export { mapTerminalFilePath } from './terminal-file-open-routing'
@@ -59,6 +60,7 @@ export type LinkHandlerDeps = {
terminalHomePath?: string | null
wslDistro?: string | null
getRuntimeEnvironmentIdForPane?: (paneId: number) => string | null
+ getLinkActionContext?: (paneId: number) => TerminalLinkActionContext | null
}
type ProvidedFileLink = {
@@ -186,16 +188,23 @@ export function createFilePathLinkProvider(
range,
text: parsed.displayText,
activate: (event) => {
- if (!isTerminalLinkActivation(event)) {
- return
+ if (
+ handleTerminalFileLink(
+ mappedPath,
+ resolved.line,
+ resolved.column,
+ event,
+ {
+ worktreeId,
+ worktreePath,
+ runtimeEnvironmentId,
+ wslDistro: deps.wslDistro
+ },
+ deps.getLinkActionContext?.(paneId)
+ )
+ ) {
+ pane.terminal.clearSelection?.()
}
- openDetectedFilePath(mappedPath, resolved.line, resolved.column, {
- worktreeId,
- worktreePath,
- runtimeEnvironmentId,
- wslDistro: deps.wslDistro,
- openWithSystemDefault: Boolean(event.shiftKey)
- })
},
hover: () => {
// Why: only local paths can offer the Shift+modifier system
@@ -204,13 +213,18 @@ export function createFilePathLinkProvider(
fileContext,
mappedPath
)
+ const showActions = deps.getLinkActionContext
+ ? deps.getLinkActionContext(paneId) !== null
+ : true
const hint = worktreeRootLink
- ? getTerminalWorktreePathOpenHint(canOpenWithSystemDefault)
+ ? getTerminalWorktreePathOpenHint(canOpenWithSystemDefault, showActions)
: canOpenWithSystemDefault
? isHtmlFilePath(mappedPath)
- ? getTerminalHtmlFileOpenHint()
- : openLinkHint
- : getTerminalOrcaFileOpenHint()
+ ? getTerminalHtmlFileOpenHint(showActions)
+ : showActions
+ ? openLinkHint
+ : getTerminalFileOpenHint(false)
+ : getTerminalOrcaFileOpenHint(showActions)
linkTooltip.textContent = `${mappedPath} (${hint})`
linkTooltip.style.display = ''
},
@@ -262,7 +276,7 @@ export function installFilePathLinkClickFallback(
): IDisposable {
const mouseUpListenerOptions = { capture: true }
const handleMouseUp = (event: MouseEvent): void => {
- if (event.button !== 0 || !isTerminalLinkActivation(event)) {
+ if (!isTerminalLinkDirectActivation(event)) {
return
}
diff --git a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts
index 890a8f3f4c3..801eec81c98 100644
--- a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts
@@ -12,7 +12,9 @@ afterEach(() => {
describe('getTerminalUrlOpenHint', () => {
it('keeps the system-browser wording by default', () => {
stubPlatform(true)
- expect(getTerminalUrlOpenHint()).toBe('⌘+click to open or ⇧⌘+click for system browser')
+ expect(getTerminalUrlOpenHint()).toBe(
+ 'Click for actions, ⌘+click to open, or ⇧⌘+click for system browser'
+ )
})
it('keeps the system-browser wording when inverting is off', () => {
@@ -34,16 +36,27 @@ describe('getTerminalUrlOpenHint', () => {
it('names Orca when inverting and links open externally', () => {
stubPlatform(true)
expect(getTerminalUrlOpenHint({ openLinksInApp: false, modifierInverts: true })).toBe(
- '⌘+click to open or ⇧⌘+click to open in Orca'
+ 'Click for actions, ⌘+click to open, or ⇧⌘+click to open in Orca'
)
})
it('uses the Ctrl chord off macOS', () => {
stubPlatform(false)
expect(getTerminalUrlOpenHint({ openLinksInApp: false, modifierInverts: true })).toBe(
- 'Ctrl+click to open or Shift+Ctrl+click to open in Orca'
+ 'Click for actions, Ctrl+click to open, or Shift+Ctrl+click to open in Orca'
)
})
+
+ it('omits the action-menu gesture when terminal link actions are disabled', () => {
+ stubPlatform(false)
+ expect(
+ getTerminalUrlOpenHint({
+ openLinksInApp: false,
+ modifierInverts: true,
+ showActions: false
+ })
+ ).toBe('Ctrl+click to open, or Shift+Ctrl+click to open in Orca')
+ })
})
describe('terminalUrlOpenHintOptionsFor', () => {
diff --git a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts
index a069ad03f44..e1a5f4ff81f 100644
--- a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts
@@ -4,27 +4,36 @@ export function isMacPlatform(): boolean {
return navigator.userAgent.includes('Mac')
}
-export function getTerminalFileOpenHint(): string {
- return isMacPlatform()
- ? '⌘+click to open or ⇧⌘+click for default app'
- : 'Ctrl+click to open or Shift+Ctrl+click for default app'
+function terminalLinkActionHintPrefix(showActions: boolean): string {
+ return showActions ? 'Click for actions, ' : ''
}
-export function getTerminalOrcaFileOpenHint(): string {
- return isMacPlatform() ? '⌘+click to open in Orca' : 'Ctrl+click to open in Orca'
+export function getTerminalFileOpenHint(showActions = true): string {
+ const prefix = terminalLinkActionHintPrefix(showActions)
+ return isMacPlatform()
+ ? `${prefix}⌘+click to open, or ⇧⌘+click for default app`
+ : `${prefix}Ctrl+click to open, or Shift+Ctrl+click for default app`
}
-// Why: detected local .html/.htm file paths keep the same modifier gate as
-// other file-path links, with Shift+modifier as the system-browser escape hatch.
-export function getTerminalHtmlFileOpenHint(): string {
+export function getTerminalOrcaFileOpenHint(showActions = true): string {
+ const prefix = showActions ? 'Click for actions or ' : ''
return isMacPlatform()
- ? '⌘+click to open or ⇧⌘+click for default browser'
- : 'Ctrl+click to open or Shift+Ctrl+click for default browser'
+ ? `${prefix}⌘+click to open in Orca`
+ : `${prefix}Ctrl+click to open in Orca`
+}
+
+// Why: local HTML paths keep Shift+modifier as the system-browser shortcut.
+export function getTerminalHtmlFileOpenHint(showActions = true): string {
+ const prefix = terminalLinkActionHintPrefix(showActions)
+ return isMacPlatform()
+ ? `${prefix}⌘+click to open, or ⇧⌘+click for default browser`
+ : `${prefix}Ctrl+click to open, or Shift+Ctrl+click for default browser`
}
export type TerminalUrlOpenHintOptions = {
openLinksInApp?: boolean
modifierInverts?: boolean
+ showActions?: boolean
}
// Why: openHttpLink only routes to Orca when the source is local, so a remote pane
@@ -55,14 +64,15 @@ export function terminalUrlOpenHintOptionsFor(
// it means "the other one" — so the hint has to name the actual destination.
export function getTerminalUrlOpenHint(options: TerminalUrlOpenHintOptions = {}): string {
const invertsToOrca = options.modifierInverts === true && options.openLinksInApp !== true
+ const prefix = terminalLinkActionHintPrefix(options.showActions !== false)
if (invertsToOrca) {
return isMacPlatform()
- ? '⌘+click to open or ⇧⌘+click to open in Orca'
- : 'Ctrl+click to open or Shift+Ctrl+click to open in Orca'
+ ? `${prefix}⌘+click to open, or ⇧⌘+click to open in Orca`
+ : `${prefix}Ctrl+click to open, or Shift+Ctrl+click to open in Orca`
}
return isMacPlatform()
- ? '⌘+click to open or ⇧⌘+click for system browser'
- : 'Ctrl+click to open or Shift+Ctrl+click for system browser'
+ ? `${prefix}⌘+click to open, or ⇧⌘+click for system browser`
+ : `${prefix}Ctrl+click to open, or Shift+Ctrl+click for system browser`
}
export function getTerminalUrlSystemBrowserHint(): string {
@@ -75,12 +85,19 @@ export function getTerminalUrlOrcaBrowserHint(): string {
return isMacPlatform() ? '⇧⌘+click to open in Orca' : 'Shift+Ctrl+click to open in Orca'
}
-export function getTerminalWorktreePathOpenHint(canOpenWithSystemDefault: boolean): string {
+export function getTerminalWorktreePathOpenHint(
+ canOpenWithSystemDefault: boolean,
+ showActions = true
+): string {
+ const prefix = terminalLinkActionHintPrefix(showActions)
if (!canOpenWithSystemDefault) {
- return isMacPlatform() ? '⌘+click to switch workspace' : 'Ctrl+click to switch workspace'
+ const directPrefix = showActions ? 'Click for actions or ' : ''
+ return isMacPlatform()
+ ? `${directPrefix}⌘+click to switch workspace`
+ : `${directPrefix}Ctrl+click to switch workspace`
}
return isMacPlatform()
- ? '⌘+click to switch workspace or ⇧⌘+click to open in Finder'
- : 'Ctrl+click to switch workspace or Shift+Ctrl+click to open folder'
+ ? `${prefix}⌘+click to switch workspace, or ⇧⌘+click to open in Finder`
+ : `${prefix}Ctrl+click to switch workspace, or Shift+Ctrl+click to open folder`
}
diff --git a/src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.test.ts
new file mode 100644
index 00000000000..a98486fc2ad
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.test.ts
@@ -0,0 +1,93 @@
+// @vitest-environment happy-dom
+import type { Terminal } from '@xterm/xterm'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { installTerminalLinkPointerGesture } from './terminal-link-pointer-gesture'
+
+const activeGestures = new Set>()
+
+function createGesture(): {
+ gesture: ReturnType
+ element: HTMLDivElement
+ setSelection: (selected: boolean) => void
+} {
+ let selected = false
+ const element = document.createElement('div')
+ document.body.appendChild(element)
+ const terminal = {
+ element,
+ hasSelection: () => selected
+ } as unknown as Terminal
+ const gesture = installTerminalLinkPointerGesture(terminal)
+ activeGestures.add(gesture)
+ return {
+ gesture,
+ element,
+ setSelection: (next) => {
+ selected = next
+ }
+ }
+}
+
+function mouse(type: string, x: number, y: number): MouseEvent {
+ return new MouseEvent(type, { bubbles: true, button: 0, clientX: x, clientY: y })
+}
+
+afterEach(() => {
+ for (const gesture of activeGestures) {
+ gesture.dispose()
+ }
+ activeGestures.clear()
+ document.body.replaceChildren()
+ vi.unstubAllGlobals()
+})
+
+describe('terminal link pointer gesture', () => {
+ it('allows a stationary plain click', () => {
+ vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
+ const { element, gesture } = createGesture()
+ const down = mouse('mousedown', 20, 30)
+ element.dispatchEvent(down)
+
+ expect(gesture.canRequestAction(down)).toBe(true)
+ })
+
+ it('rejects a drag and a click that began with a selection', () => {
+ vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
+ const dragged = createGesture()
+ const dragDown = mouse('mousedown', 10, 10)
+ dragged.element.dispatchEvent(dragDown)
+ document.dispatchEvent(mouse('mousemove', 20, 10))
+ expect(dragged.gesture.canRequestAction(dragDown)).toBe(false)
+
+ const selected = createGesture()
+ selected.setSelection(true)
+ const selectionDown = mouse('mousedown', 10, 10)
+ selected.element.dispatchEvent(selectionDown)
+ selected.setSelection(false)
+ expect(selected.gesture.canRequestAction(selectionDown)).toBe(false)
+ })
+
+ it('rejects a selection created during the gesture and clears on blur', () => {
+ vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
+ const { element, gesture, setSelection } = createGesture()
+ const down = mouse('mousedown', 10, 10)
+ element.dispatchEvent(down)
+ setSelection(true)
+ expect(gesture.canRequestAction(down)).toBe(false)
+
+ setSelection(false)
+ window.dispatchEvent(new Event('blur'))
+ expect(gesture.canRequestAction(down)).toBe(false)
+ })
+
+ it('removes its listeners on dispose', () => {
+ vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
+ const { element, gesture } = createGesture()
+ gesture.dispose()
+ const down = mouse('mousedown', 10, 10)
+
+ element.dispatchEvent(down)
+
+ expect(gesture.canRequestAction(down)).toBe(false)
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.ts b/src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.ts
new file mode 100644
index 00000000000..ff3ac6cf945
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.ts
@@ -0,0 +1,68 @@
+import type { IDisposable, Terminal } from '@xterm/xterm'
+import { isTerminalOwnedLinkGesture } from './terminal-link-activation'
+
+const DRAG_THRESHOLD_PX = 4
+const CAPTURE_LISTENER_OPTIONS = { capture: true } as const
+
+export type TerminalLinkPointerGesture = IDisposable & {
+ canRequestAction: (event: MouseEvent) => boolean
+}
+
+type PendingGesture = {
+ clientX: number
+ clientY: number
+ hadSelection: boolean
+ moved: boolean
+}
+
+export function installTerminalLinkPointerGesture(terminal: Terminal): TerminalLinkPointerGesture {
+ const terminalElement = terminal.element
+ const ownerDocument = terminalElement?.ownerDocument
+ const ownerWindow = ownerDocument?.defaultView
+ let pending: PendingGesture | null = null
+
+ const clear = (): void => {
+ pending = null
+ }
+ const handleMouseDown = (event: MouseEvent): void => {
+ if (!isTerminalOwnedLinkGesture(event)) {
+ clear()
+ return
+ }
+ pending = {
+ clientX: event.clientX,
+ clientY: event.clientY,
+ hadSelection: terminal.hasSelection(),
+ moved: false
+ }
+ }
+ const handleMouseMove = (event: MouseEvent): void => {
+ if (
+ pending &&
+ Math.hypot(event.clientX - pending.clientX, event.clientY - pending.clientY) >
+ DRAG_THRESHOLD_PX
+ ) {
+ pending.moved = true
+ }
+ }
+ const handleMouseUp = (): void => {
+ queueMicrotask(clear)
+ }
+
+ terminalElement?.addEventListener('mousedown', handleMouseDown, CAPTURE_LISTENER_OPTIONS)
+ ownerDocument?.addEventListener('mousemove', handleMouseMove)
+ ownerDocument?.addEventListener('mouseup', handleMouseUp)
+ ownerWindow?.addEventListener('blur', clear)
+
+ return {
+ canRequestAction: () =>
+ Boolean(pending && !pending.moved && !pending.hadSelection && !terminal.hasSelection()),
+ dispose: () => {
+ clear()
+ terminalElement?.removeEventListener('mousedown', handleMouseDown, CAPTURE_LISTENER_OPTIONS)
+ ownerDocument?.removeEventListener('mousemove', handleMouseMove)
+ ownerDocument?.removeEventListener('mouseup', handleMouseUp)
+ ownerWindow?.removeEventListener('blur', clear)
+ }
+ }
+}
diff --git a/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts b/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts
index e595974bc18..a325056759b 100644
--- a/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts
@@ -1,5 +1,5 @@
import type { IDisposable, Terminal } from '@xterm/xterm'
-import { isTerminalHttpLinkActivation } from './terminal-http-link-activation'
+import { isTerminalLinkDirectActivation } from './terminal-link-activation'
const CAPTURE_LISTENER_OPTIONS = { capture: true } as const
@@ -31,17 +31,12 @@ export function installTerminalLinkPtyMouseSuppression(
queueMicrotask(restore)
}
const handleMouseDown = (event: MouseEvent): void => {
- if (
- event.button !== 0 ||
- !isTerminalHttpLinkActivation(event) ||
- !shouldSuppressMouseEvent(event)
- ) {
+ if (!isTerminalLinkDirectActivation(event) || !shouldSuppressMouseEvent(event)) {
return
}
restore()
previousMouseEventsRequireAlt = Boolean(terminal.options.mouseEventsRequireAlt)
- // Why: xterm otherwise forwards the same Cmd/Ctrl link gesture to a mouse-aware
- // TUI, letting the terminal and the child process both open the URL.
+ // Why: an Orca-owned link gesture must not also reach a mouse-aware child TUI.
terminal.options.mouseEventsRequireAlt = true
ownerDocument?.addEventListener('mouseup', queueRestore)
ownerWindow?.addEventListener('blur', restore)
diff --git a/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.test.ts b/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.test.ts
index aa58f773680..4416e9ae47e 100644
--- a/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.test.ts
@@ -124,7 +124,7 @@ describe('installTerminalLinkifierClickPriming', () => {
expect(linkifier._activeLine).toBe(5)
})
- it('ignores plain clicks and degrades safely when xterm internals are unavailable', () => {
+ it('primes plain action clicks and degrades safely when xterm internals are unavailable', () => {
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
const handleMouseMove = vi.fn()
const present = createTerminal({ _handleMouseMove: handleMouseMove })
@@ -136,7 +136,7 @@ describe('installTerminalLinkifierClickPriming', () => {
expect(() =>
getMouseDownHandler(absent.registrations)(modifierMouseDown({ metaKey: true }))
).not.toThrow()
- expect(handleMouseMove).not.toHaveBeenCalled()
+ expect(handleMouseMove).toHaveBeenCalledOnce()
})
it('removes its capture listener on dispose', () => {
diff --git a/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.ts b/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.ts
index 7965fa488d4..ece9806e827 100644
--- a/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.ts
@@ -1,5 +1,5 @@
import type { IDisposable, Terminal } from '@xterm/xterm'
-import { isTerminalLinkActivation } from './terminal-link-activation'
+import { isTerminalOwnedLinkGesture } from './terminal-link-activation'
const CAPTURE_LISTENER_OPTIONS = { capture: true } as const
@@ -39,7 +39,7 @@ function primeTerminalLinkifier(terminal: Terminal, event: MouseEvent): void {
export function installTerminalLinkifierClickPriming(terminal: Terminal): IDisposable {
const terminalElement = terminal.element
const handleMouseDown = (event: MouseEvent): void => {
- if (event.button !== 0 || !isTerminalLinkActivation(event)) {
+ if (!isTerminalOwnedLinkGesture(event)) {
return
}
// Why: xterm snapshots its current link on mousedown but otherwise resolves
diff --git a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts
index 1f51187fd21..b041cb65f0d 100644
--- a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts
@@ -2,16 +2,32 @@ import { resolveTerminalFileLinkText } from '@/lib/terminal-links'
import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path'
import type { LinkHandlerDeps } from './terminal-link-handlers'
import { resolveTerminalFileUrlTarget } from '../../../../shared/terminal-file-url-target'
-import { openDetectedFilePath } from './terminal-file-open-routing'
-import { isTerminalLinkActivation } from './terminal-link-activation'
import {
- openTerminalHttpLink,
+ isTerminalLinkActionActivation,
+ isTerminalLinkDirectActivation
+} from './terminal-link-activation'
+import {
+ handleTerminalHttpLink,
+ type TerminalHttpLinkActionDestinations,
type TerminalLinkRoutingPreferenceRequester
} from './terminal-url-link-hit-testing'
import type { HttpLinkSourceOwner } from '@/lib/http-link-routing'
+import type { TerminalLinkActionContext } from './terminal-link-action-request'
+import { handleTerminalFileLink } from './terminal-file-link-actions'
type TerminalLinkEvent = Pick &
- Partial>
+ Partial<
+ Pick<
+ MouseEvent,
+ | 'altKey'
+ | 'button'
+ | 'clientX'
+ | 'clientY'
+ | 'shiftKey'
+ | 'preventDefault'
+ | 'stopPropagation'
+ >
+ >
function isDesktopOscLinkActivation(event: TerminalLinkEvent | undefined): boolean {
if (!event) {
@@ -22,7 +38,7 @@ function isDesktopOscLinkActivation(event: TerminalLinkEvent | undefined): boole
}
// Why: desktop xterm links must not open while the user is just placing the
// cursor or selecting text. Mobile URL taps use a separate WebView path.
- return isTerminalLinkActivation(event)
+ return isTerminalLinkDirectActivation(event) || isTerminalLinkActionActivation(event)
}
export function handleOscLink(
@@ -37,20 +53,20 @@ export function handleOscLink(
> & {
sourceOwner?: HttpLinkSourceOwner
requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester
+ linkActionContext?: TerminalLinkActionContext | null
+ actionDestinations?: TerminalHttpLinkActionDestinations
}
): boolean {
if (!isDesktopOscLinkActivation(event)) {
return false
}
- // Why: xterm renders OSC 8 links as clickable anchors. Orca must suppress
- // default anchor navigation so link-routing settings can choose the target.
- // Note: we intentionally do NOT stopPropagation here — xterm's
- // SelectionService listens for mouseup on ownerDocument to clear the
- // pending drag-select state initiated by the mousedown of the same click.
- // Stopping propagation leaves SelectionService's mousemove/mouseup handlers
- // attached, so returning focus to the terminal and moving the mouse (even
- // without holding a button) extends a selection until the next click/Esc.
- event?.preventDefault?.()
+ const finish = (handled: boolean): boolean => {
+ if (handled) {
+ // Why: prevent anchor navigation without blocking xterm's document-level selection cleanup.
+ event?.preventDefault?.()
+ }
+ return handled
+ }
const openDetectedPathLink = (): boolean => {
const resolved = resolveTerminalFileLinkText(
@@ -61,11 +77,17 @@ export function handleOscLink(
if (!resolved) {
return false
}
- openDetectedFilePath(resolved.absolutePath, resolved.line, resolved.column, {
- ...deps,
- openWithSystemDefault: Boolean(event?.shiftKey)
- })
- return true
+ return finish(
+ handleTerminalFileLink(
+ resolved.absolutePath,
+ resolved.line,
+ resolved.column,
+ event as MouseEvent,
+ deps,
+ deps.linkActionContext,
+ rawText
+ )
+ )
}
if (
@@ -86,17 +108,20 @@ export function handleOscLink(
}
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
- openTerminalHttpLink(parsed.toString(), {
- worktreeId: deps.worktreeId,
- sourceOwner:
- deps.sourceOwner ??
- (deps.runtimeEnvironmentId
- ? { kind: 'runtime', runtimeEnvironmentId: deps.runtimeEnvironmentId }
- : { kind: 'local' }),
- modifierHeld: Boolean(event?.shiftKey),
- requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference
- })
- return true
+ return finish(
+ handleTerminalHttpLink(parsed.toString(), event as MouseEvent, {
+ worktreeId: deps.worktreeId,
+ sourceOwner:
+ deps.sourceOwner ??
+ (deps.runtimeEnvironmentId
+ ? { kind: 'runtime', runtimeEnvironmentId: deps.runtimeEnvironmentId }
+ : { kind: 'local' }),
+ requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference,
+ linkActionContext: deps.linkActionContext,
+ actionDestinations: deps.actionDestinations,
+ actionDestination: rawText
+ })
+ )
}
if (parsed.protocol === 'file:') {
@@ -113,11 +138,17 @@ export function handleOscLink(
if (!resolved) {
return false
}
- openDetectedFilePath(resolved.filePath, resolved.line, resolved.column, {
- ...deps,
- openWithSystemDefault: Boolean(event?.shiftKey)
- })
- return true
+ return finish(
+ handleTerminalFileLink(
+ resolved.filePath,
+ resolved.line,
+ resolved.column,
+ event as MouseEvent,
+ deps,
+ deps.linkActionContext,
+ rawText
+ )
+ )
}
return false
}
diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts
index 4c5184cc933..0461474dedd 100644
--- a/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts
@@ -134,14 +134,17 @@ function makeTerminal(options?: {
}
}
-function mouseEventForRow(row: number, options: { altKey?: boolean } = {}): MouseEvent {
+function mouseEventForRow(
+ row: number,
+ options: { altKey?: boolean; plain?: boolean } = {}
+): MouseEvent {
let defaultPrevented = false
return {
button: 0,
- metaKey: true,
+ metaKey: !options.plain,
ctrlKey: false,
altKey: options.altKey ?? false,
- shiftKey: true,
+ shiftKey: !options.plain,
get defaultPrevented() {
return defaultPrevented
},
@@ -482,4 +485,51 @@ describe('hard-wrapped terminal HTTP clicks', () => {
expect(terminal.options.mouseEventsRequireAlt).toBe(false)
disposable.dispose()
})
+
+ it('owns a plain HTTP link click without opening until an action is chosen', () => {
+ const request = vi.fn()
+ const { terminal, registrations } = makeTerminal()
+ const disposable = installHttpLinkClickFallback(terminal, {
+ worktreeId: 'wt-1',
+ getLinkActionContext: () => ({
+ paneId: 1,
+ pointerGesture: { canRequestAction: () => true, dispose: vi.fn() },
+ request,
+ focusTerminal: vi.fn()
+ })
+ })
+ const mouseDown = registrations.find(([name]) => name === 'mousedown')?.[1]
+ const mouseUp = registrations.find(
+ ([name, _listener, options]) => name === 'mouseup' && options === undefined
+ )?.[1]
+ const event = mouseEventForRow(0, { plain: true })
+
+ mouseDown!(event)
+ expect(terminal.options.mouseEventsRequireAlt).toBe(false)
+ mouseUp!(event)
+
+ expect(request).toHaveBeenCalledWith(expect.objectContaining({ kind: 'url' }))
+ expect(openUrlMock).not.toHaveBeenCalled()
+ disposable.dispose()
+ })
+
+ it('does not suppress a plain HTTP link when action popovers are disabled', () => {
+ const { terminal, registrations } = makeTerminal()
+ const disposable = installHttpLinkClickFallback(terminal, {
+ worktreeId: 'wt-1',
+ getLinkActionContext: () => null
+ })
+ const mouseDown = registrations.find(([name]) => name === 'mousedown')?.[1]
+ const mouseUp = registrations.find(
+ ([name, _listener, options]) => name === 'mouseup' && options === undefined
+ )?.[1]
+ const event = mouseEventForRow(0, { plain: true })
+
+ mouseDown!(event)
+ mouseUp!(event)
+
+ expect(terminal.options.mouseEventsRequireAlt).toBe(false)
+ expect(openUrlMock).not.toHaveBeenCalled()
+ disposable.dispose()
+ })
})
diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts
index d173157ea65..c28ae97824c 100644
--- a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts
@@ -9,6 +9,12 @@ import { getTerminalBufferPositionForMouseEvent } from './terminal-mouse-buffer-
import { extractTerminalHttpLinks } from './terminal-http-url-extraction'
import { buildWrappedLogicalLine, rangeForParsedFileLink } from './wrapped-terminal-link-ranges'
import { isTerminalLinkifierHoverActive } from '@/lib/pane-manager/terminal-linkifier-hover-reset'
+import { translate } from '@/i18n/i18n'
+import { isTerminalOwnedLinkGesture } from './terminal-link-activation'
+import {
+ requestTerminalLinkAction,
+ type TerminalLinkActionContext
+} from './terminal-link-action-request'
export { extractTerminalHttpLinks } from './terminal-http-url-extraction'
export { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits'
@@ -18,6 +24,10 @@ type UrlLinkHitTestDeps = {
sourceOwner?: HttpLinkSourceOwner
modifierHeld?: boolean
requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester
+ linkActionContext?: TerminalLinkActionContext | null
+ actionDestinations?: TerminalHttpLinkActionDestinations
+ actionDestination?: string
+ forceDestination?: TerminalHttpLinkDestination
}
type UrlLinkClickFallbackDeps = {
@@ -25,6 +35,15 @@ type UrlLinkClickFallbackDeps = {
/** Resolved per click: the pane's PTY (and its runtime binding) may not exist at install time. */
getSourceOwner?: () => HttpLinkSourceOwner
requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester
+ getLinkActionContext?: () => TerminalLinkActionContext | null
+ getActionDestinations?: () => TerminalHttpLinkActionDestinations
+}
+
+export type TerminalHttpLinkDestination = 'orca' | 'system'
+
+export type TerminalHttpLinkActionDestinations = {
+ primary: TerminalHttpLinkDestination
+ alternate?: TerminalHttpLinkDestination
}
export type TerminalLinkRoutingPreferenceRequester = (
@@ -35,10 +54,73 @@ function isDesktopHttpLinkFallbackActivation(event: MouseEvent): boolean {
if (event.defaultPrevented || event.button !== 0) {
return false
}
- // Why: desktop terminal links require an intentional Cmd/Ctrl gesture so
- // plain clicks remain available for cursor placement and selection. Mobile
- // tap routing is handled separately under mobile/src/terminal.
- return isTerminalHttpLinkActivation(event)
+ // Why: Shift-only, Alt, and non-primary clicks remain available to the terminal or child TUI.
+ return isTerminalOwnedLinkGesture(event)
+}
+
+export function handleTerminalHttpLink(
+ url: string,
+ event: MouseEvent | undefined,
+ deps: UrlLinkHitTestDeps
+): boolean {
+ if (isTerminalHttpLinkActivation(event)) {
+ const forceDestination = event?.shiftKey
+ ? deps.actionDestinations?.alternate
+ : deps.actionDestinations?.primary
+ openTerminalHttpLink(url, {
+ ...deps,
+ modifierHeld: forceDestination ? false : Boolean(event?.shiftKey),
+ forceDestination
+ })
+ return true
+ }
+
+ const actionDestinations = deps.actionDestinations
+ const primaryDestination = actionDestinations?.primary
+ const labelForDestination = (destination: TerminalHttpLinkDestination): string =>
+ destination === 'orca'
+ ? translate(
+ 'auto.components.terminal.pane.TerminalLinkActionPopover.orcaBrowser',
+ 'Orca Browser'
+ )
+ : translate(
+ 'auto.components.terminal.pane.TerminalLinkActionPopover.systemBrowser',
+ 'System Browser'
+ )
+
+ return requestTerminalLinkAction(event, deps.linkActionContext, {
+ destination: deps.actionDestination ?? url,
+ kind: 'url',
+ primary: {
+ external: primaryDestination === 'system',
+ label: primaryDestination
+ ? labelForDestination(primaryDestination)
+ : translate(
+ 'auto.components.terminal.pane.TerminalLinkActionPopover.openLink',
+ 'Open link'
+ ),
+ run: () =>
+ openTerminalHttpLink(url, {
+ ...deps,
+ modifierHeld: false,
+ forceDestination: primaryDestination
+ })
+ },
+ ...(actionDestinations?.alternate
+ ? {
+ alternate: {
+ external: actionDestinations.alternate === 'system',
+ label: labelForDestination(actionDestinations.alternate),
+ run: () =>
+ openTerminalHttpLink(url, {
+ ...deps,
+ modifierHeld: false,
+ forceDestination: actionDestinations.alternate
+ })
+ }
+ }
+ : {})
+ })
}
export function openHttpLinkAtTerminalMouseEvent(
@@ -56,6 +138,19 @@ export function openHttpLinkAtTerminalMouseEvent(
return openHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols, deps)
}
+export function findHttpLinkAtTerminalMouseEvent(
+ terminal: Terminal,
+ event: MouseEvent
+): string | null {
+ if (event.button !== 0 || !isTerminalOwnedLinkGesture(event)) {
+ return null
+ }
+ const position = getTerminalBufferPositionForMouseEvent(terminal, event)
+ return position
+ ? findHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols)
+ : null
+}
+
export function installHttpLinkClickFallback(
terminal: Terminal,
deps: UrlLinkClickFallbackDeps
@@ -74,16 +169,19 @@ export function installHttpLinkClickFallback(
return
}
- // Why: xterm's WebLinksAddon only activates after hover state exists. This
- // direct mouseup fallback preserves modifier-clicks when the hover link was
- // never established, while defaultPrevented avoids duplicate opens.
- const opened = openHttpLinkAtTerminalMouseEvent(terminal, event, {
- worktreeId: deps.worktreeId,
- sourceOwner: deps.getSourceOwner?.() ?? { kind: 'local' },
- modifierHeld: event.shiftKey,
- requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference
- })
- if (opened) {
+ // Why: xterm's WebLinksAddon misses first clicks before hover state exists.
+ const url = findHttpLinkAtTerminalMouseEvent(terminal, event)
+ const handled = Boolean(
+ url &&
+ handleTerminalHttpLink(url, event, {
+ worktreeId: deps.worktreeId,
+ sourceOwner: deps.getSourceOwner?.() ?? { kind: 'local' },
+ requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference,
+ linkActionContext: deps.getLinkActionContext?.(),
+ actionDestinations: deps.getActionDestinations?.()
+ })
+ )
+ if (handled) {
event.preventDefault()
terminal.clearSelection()
}
@@ -161,6 +259,15 @@ export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): voi
// Why: Orca browser tabs are local-only, so a link clicked in a runtime-hosted
// pane must be classified by its pane's host, not the global active runtime.
const sourceOwner = deps.sourceOwner ?? { kind: 'local' }
+ if (deps.forceDestination) {
+ openHttpLink(url, {
+ worktreeId: deps.worktreeId,
+ forceInApp: deps.forceDestination === 'orca',
+ forceSystemBrowser: deps.forceDestination === 'system',
+ sourceOwner
+ })
+ return
+ }
if (deps.modifierHeld) {
// Why: the modifier states a destination outright, so it also skips the
// one-time routing prompt; openHttpLink resolves which destination it means.
diff --git a/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts b/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts
index f6d776eb289..441ff06ce86 100644
--- a/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts
@@ -1,12 +1,15 @@
import type { Terminal } from '@xterm/xterm'
import type { LinkHandlerDeps } from './terminal-link-handlers'
-import { isTerminalHttpLinkActivation } from './terminal-http-link-activation'
+import { isTerminalOwnedLinkGesture } from './terminal-link-activation'
import { handleOscLink } from './terminal-osc-link-routing'
import {
- openHttpLinkAtTerminalMouseEvent,
+ findHttpLinkAtTerminalMouseEvent,
+ handleTerminalHttpLink,
+ type TerminalHttpLinkActionDestinations,
type TerminalLinkRoutingPreferenceRequester
} from './terminal-url-link-hit-testing'
import type { HttpLinkSourceOwner } from '@/lib/http-link-routing'
+import type { TerminalLinkActionContext } from './terminal-link-action-request'
type TerminalWebLinkClickDeps = Pick<
LinkHandlerDeps,
@@ -15,6 +18,8 @@ type TerminalWebLinkClickDeps = Pick<
terminal: Terminal | null
sourceOwner?: HttpLinkSourceOwner
requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester
+ linkActionContext?: TerminalLinkActionContext | null
+ actionDestinations?: TerminalHttpLinkActionDestinations
}
export function handleTerminalWebLinkClick(
@@ -22,28 +27,27 @@ export function handleTerminalWebLinkClick(
event: MouseEvent | undefined,
deps: TerminalWebLinkClickDeps
): boolean {
- if (!event || !isTerminalHttpLinkActivation(event)) {
+ if (!event || !isTerminalOwnedLinkGesture(event)) {
return false
}
let handled: boolean
- if (
- deps.terminal &&
- openHttpLinkAtTerminalMouseEvent(deps.terminal, event, {
+ const completeUrl = deps.terminal ? findHttpLinkAtTerminalMouseEvent(deps.terminal, event) : null
+ if (completeUrl) {
+ handled = handleTerminalHttpLink(completeUrl, event, {
worktreeId: deps.worktreeId,
sourceOwner:
deps.sourceOwner ??
(deps.runtimeEnvironmentId
? { kind: 'runtime', runtimeEnvironmentId: deps.runtimeEnvironmentId }
: { kind: 'local' }),
- modifierHeld: Boolean(event.shiftKey),
- requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference
+ requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference,
+ linkActionContext: deps.linkActionContext,
+ actionDestinations: deps.actionDestinations
})
- ) {
// Why: WebLinksAddon only knows the physical row; Orca's logical hit-test
// preserves the complete URL rendered across hard-wrapped TUI rows.
event.preventDefault()
- handled = true
} else {
handled = handleOscLink(url, event, deps)
}
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
index 631287b7217..1b666e20a80 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
@@ -39,9 +39,15 @@ import { handleOscLink } from './terminal-osc-link-routing'
import { handleTerminalWebLinkClick } from './terminal-web-link-click'
import {
installHttpLinkClickFallback,
+ type TerminalHttpLinkActionDestinations,
type TerminalLinkRoutingPreferenceRequester
} from './terminal-url-link-hit-testing'
import { installTerminalLinkifierClickPriming } from './terminal-linkifier-click-priming'
+import { installTerminalLinkPointerGesture } from './terminal-link-pointer-gesture'
+import type {
+ TerminalLinkActionContext,
+ TerminalLinkActionRequester
+} from './terminal-link-action-request'
import {
resolveLocalhostHttpLinkDisplayUrl,
type HttpLinkSourceOwner
@@ -255,6 +261,7 @@ type UseTerminalPaneLifecycleDeps = {
settings: GlobalSettings | null | undefined
settingsRef: React.RefObject
requestOpenLinksInAppPreference: TerminalLinkRoutingPreferenceRequester
+ requestTerminalLinkAction: TerminalLinkActionRequester
/** Resolved Option-as-Alt: `'auto'` already mapped to `'true'|'false'` via the layout probe, which lives outside the settings store. */
effectiveMacOptionAsAlt: EffectiveMacOptionAsAlt
effectiveMacOptionAsAltRef: React.RefObject
@@ -594,6 +601,7 @@ export function useTerminalPaneLifecycle({
settings,
settingsRef,
requestOpenLinksInAppPreference,
+ requestTerminalLinkAction,
effectiveMacOptionAsAlt,
effectiveMacOptionAsAltRef,
initialLayoutRef,
@@ -656,6 +664,9 @@ export function useTerminalPaneLifecycle({
const linkProviderDisposablesRef = useRef(new Map())
const terminalHandleLinkDisposablesRef = useRef(new Map())
const linkifierClickPrimingDisposablesRef = useRef(new Map())
+ const linkPointerGesturesRef = useRef(
+ new Map>()
+ )
const fileLinkClickFallbackDisposablesRef = useRef(new Map())
const httpLinkClickFallbackDisposablesRef = useRef(new Map())
// Why: read settingsRef at fire time so toggling "copy on select" applies without recreating panes.
@@ -698,6 +709,7 @@ export function useTerminalPaneLifecycle({
const linkDisposables = linkProviderDisposablesRef.current
const terminalHandleLinkDisposables = terminalHandleLinkDisposablesRef.current
const linkifierClickPrimingDisposables = linkifierClickPrimingDisposablesRef.current
+ const linkPointerGestures = linkPointerGesturesRef.current
const fileLinkClickFallbackDisposables = fileLinkClickFallbackDisposablesRef.current
const httpLinkClickFallbackDisposables = httpLinkClickFallbackDisposablesRef.current
const selectionDisposables = selectionDisposablesRef.current
@@ -728,6 +740,32 @@ export function useTerminalPaneLifecycle({
resolvePaneLinkCwd(paneCwdRef.current, paneId, startupCwd)
const getHttpLinkSourceOwnerForPane = (paneId: number) =>
resolveTerminalHttpLinkSourceOwner(paneTransportsRef.current.get(paneId))
+ const getHttpLinkActionDestinations = (paneId: number): TerminalHttpLinkActionDestinations => {
+ const sourceOwner = getHttpLinkSourceOwnerForPane(paneId)
+ if (sourceOwner.kind !== 'local') {
+ return { primary: 'system' }
+ }
+ const options = terminalUrlOpenHintOptionsFor(settingsRef.current, sourceOwner)
+ return options.openLinksInApp
+ ? { primary: 'orca', alternate: 'system' }
+ : { primary: 'system', alternate: 'orca' }
+ }
+ const getLinkActionContext = (paneId: number): TerminalLinkActionContext | null => {
+ if (settingsRef.current?.terminalLinkActionPopoverEnabled === false) {
+ return null
+ }
+ const pane = managerRef.current?.getPanes().find((candidate) => candidate.id === paneId)
+ const pointerGesture = linkPointerGestures.get(paneId)
+ if (!pane || !pointerGesture) {
+ return null
+ }
+ return {
+ paneId,
+ pointerGesture,
+ request: requestTerminalLinkAction,
+ focusTerminal: () => pane.terminal.focus()
+ }
+ }
// Why: lifecycle-scoped cache for cross-SSH/runtime existence probes; may hold temporarily stale entries.
const pathExistsCache = new Map()
const linkDeps: LinkHandlerDeps = {
@@ -743,7 +781,8 @@ export function useTerminalPaneLifecycle({
getRuntimeEnvironmentIdForPane: (paneId) => {
const sourceOwner = getHttpLinkSourceOwnerForPane(paneId)
return sourceOwner.kind === 'runtime' ? sourceOwner.runtimeEnvironmentId : null
- }
+ },
+ getLinkActionContext
}
let resizeRaf: number | null = null
const queueResizeAll = (focusActive: boolean): void => {
@@ -847,9 +886,13 @@ export function useTerminalPaneLifecycle({
const fileOpenLinkHint = getTerminalFileOpenHint()
// Why: read settingsRef at fire time so toggling link routing applies without recreating panes.
const getUrlOpenLinkHint = (paneId: number): string =>
- getTerminalUrlOpenHint(
- terminalUrlOpenHintOptionsFor(settingsRef.current, getHttpLinkSourceOwnerForPane(paneId))
- )
+ getTerminalUrlOpenHint({
+ ...terminalUrlOpenHintOptionsFor(
+ settingsRef.current,
+ getHttpLinkSourceOwnerForPane(paneId)
+ ),
+ showActions: settingsRef.current?.terminalLinkActionPopoverEnabled !== false
+ })
const osc7UncHost = extractUncHost(startupCwd)
let releaseWebviewDragPassthrough: (() => void) | null = null
@@ -1046,6 +1089,8 @@ export function useTerminalPaneLifecycle({
return !shouldBypass
})
+ const linkPointerGesture = installTerminalLinkPointerGesture(pane.terminal)
+ linkPointerGestures.set(pane.id, linkPointerGesture)
const linkProviderDisposable = pane.terminal.registerLinkProvider(
createFilePathLinkProvider(pane.id, linkDeps, pane.linkTooltip, fileOpenLinkHint)
)
@@ -1057,7 +1102,8 @@ export function useTerminalPaneLifecycle({
?.terminal ?? null,
getRuntimeEnvironmentId: () =>
linkDeps.getRuntimeEnvironmentIdForPane?.(pane.id) ?? null,
- linkTooltip: pane.linkTooltip
+ linkTooltip: pane.linkTooltip,
+ getLinkActionContext: () => getLinkActionContext(pane.id)
})
)
terminalHandleLinkDisposablesRef.current.set(pane.id, terminalHandleLinkDisposable)
@@ -1072,7 +1118,9 @@ export function useTerminalPaneLifecycle({
const httpLinkClickFallbackDisposable = installHttpLinkClickFallback(pane.terminal, {
...linkDeps,
getSourceOwner: () => getHttpLinkSourceOwnerForPane(pane.id),
- requestOpenLinksInAppPreference
+ requestOpenLinksInAppPreference,
+ getLinkActionContext: () => getLinkActionContext(pane.id),
+ getActionDestinations: () => getHttpLinkActionDestinations(pane.id)
})
httpLinkClickFallbackDisposables.set(pane.id, httpLinkClickFallbackDisposable)
seedStartupSessionRestoredBanner(ptyDeps.startup, pane.id, onShowSessionRestoredBanner)
@@ -1142,7 +1190,9 @@ export function useTerminalPaneLifecycle({
startupCwd: getPaneLinkCwd(pane.id),
runtimeEnvironmentId: linkDeps.getRuntimeEnvironmentIdForPane?.(pane.id) ?? null,
sourceOwner: getHttpLinkSourceOwnerForPane(pane.id),
- requestOpenLinksInAppPreference
+ requestOpenLinksInAppPreference,
+ linkActionContext: getLinkActionContext(pane.id),
+ actionDestinations: getHttpLinkActionDestinations(pane.id)
})
// Why: link activation can steal focus before the click's mouseup reaches xterm, stranding its drag-select
// listener (runaway selection until next click/Esc); clearSelection detaches it (SelectionService._removeMouseDownListeners).
@@ -1216,6 +1266,11 @@ export function useTerminalPaneLifecycle({
linkifierClickPrimingDisposable.dispose()
linkifierClickPrimingDisposablesRef.current.delete(paneId)
}
+ const linkPointerGesture = linkPointerGestures.get(paneId)
+ if (linkPointerGesture) {
+ linkPointerGesture.dispose()
+ linkPointerGestures.delete(paneId)
+ }
const fileLinkClickFallbackDisposable =
fileLinkClickFallbackDisposablesRef.current.get(paneId)
if (fileLinkClickFallbackDisposable) {
@@ -1459,8 +1514,10 @@ export function useTerminalPaneLifecycle({
},
terminalTuiScrollSensitivity: () =>
normalizeTerminalTuiMouseWheelMultiplier(settingsRef.current?.terminalTuiScrollSensitivity),
- onLinkClick: (event, url) => {
- const activePane = managerRef.current?.getActivePane()
+ onLinkClick: (paneId, event, url) => {
+ const activePane = managerRef.current
+ ?.getPanes()
+ .find((candidate) => candidate.id === paneId)
handleTerminalWebLinkClick(url, event, {
...linkDeps,
terminal: activePane?.terminal ?? null,
@@ -1471,7 +1528,9 @@ export function useTerminalPaneLifecycle({
sourceOwner: activePane
? getHttpLinkSourceOwnerForPane(activePane.id)
: { kind: 'local' },
- requestOpenLinksInAppPreference
+ requestOpenLinksInAppPreference,
+ linkActionContext: getLinkActionContext(paneId),
+ actionDestinations: getHttpLinkActionDestinations(paneId)
})
},
linkOpenHint: getUrlOpenLinkHint,
@@ -1713,6 +1772,10 @@ export function useTerminalPaneLifecycle({
disposable.dispose()
}
linkifierClickPrimingDisposables.clear()
+ for (const gesture of linkPointerGestures.values()) {
+ gesture.dispose()
+ }
+ linkPointerGestures.clear()
for (const disposable of fileLinkClickFallbackDisposables.values()) {
disposable.dispose()
}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 3e0980342eb..3184344d835 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -2839,6 +2839,19 @@
"cc5a2dc706": "Edit pane title: {{value0}}",
"7dbbfcbecc": "Pane title"
},
+ "TerminalLinkActionPopover": {
+ "openLink": "Open link",
+ "openFile": "Open file",
+ "switchWorkspace": "Switch workspace",
+ "openInFinder": "Open in Finder",
+ "openFolder": "Open folder",
+ "openWithDefaultApp": "Open with default app",
+ "switchTerminal": "Switch terminal",
+ "openTaskTerminal": "Open task terminal",
+ "systemBrowser": "System Browser",
+ "orcaBrowser": "Orca Browser",
+ "terminalLinkSettings": "Terminal link settings"
+ },
"TerminalSessionStateSaveFailureDialog": {
"6bee0c8f17": "Open Disk Space Analyzer",
"ae20d0ffc2": "Dismiss",
@@ -8110,6 +8123,14 @@
"invert": "invert",
"opposite": "opposite"
},
+ "terminalLinkActions": {
+ "terminal": "terminal",
+ "click": "click",
+ "actions": "actions",
+ "popover": "popover",
+ "menu": "menu",
+ "disable": "disable"
+ },
"72c58f7792": "webview",
"82ba1c80ea": "localhost",
"bea27bac4b": "links",
@@ -10023,6 +10044,10 @@
"descriptionSystem": "Links open in Orca, so {{chord}}+click sends one to your system browser instead.",
"descriptionOrca": "Links open in your system browser. When enabled, {{chord}}+click opens one in Orca's built-in browser instead."
},
+ "BrowserTerminalLinkActionsSetting": {
+ "title": "Show terminal link actions",
+ "description": "Show available actions when you click a terminal link. Turn this off to require {{modifier}}-click."
+ },
"PluginConsentDialog": {
"workerTrust": "Background worker — runs its own process",
"instructionalTrust": "Instructional content — runs later under user or agent authority",
diff --git a/src/renderer/src/lib/http-link-routing.test.ts b/src/renderer/src/lib/http-link-routing.test.ts
index d0c96799978..64991900a25 100644
--- a/src/renderer/src/lib/http-link-routing.test.ts
+++ b/src/renderer/src/lib/http-link-routing.test.ts
@@ -103,6 +103,34 @@ describe('openHttpLink', () => {
expect(createBrowserTabMock).not.toHaveBeenCalled()
})
+ it('forceInApp opens a local link in Orca when the setting is off', () => {
+ storeState.settings = { openLinksInApp: false }
+
+ openHttpLink('https://example.com/', {
+ worktreeId: 'wt-1',
+ forceInApp: true,
+ sourceOwner: { kind: 'local' }
+ })
+
+ expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', {
+ activate: true
+ })
+ expect(openUrlMock).not.toHaveBeenCalled()
+ })
+
+ it('does not force a remote link into the Orca browser', () => {
+ storeState.settings = { openLinksInApp: false }
+
+ openHttpLink('https://example.com/', {
+ worktreeId: 'wt-1',
+ forceInApp: true,
+ sourceOwner: { kind: 'ssh', connectionId: 'ssh-1' }
+ })
+
+ expect(openUrlMock).toHaveBeenCalledWith('https://example.com/')
+ expect(createBrowserTabMock).not.toHaveBeenCalled()
+ })
+
it('routes to the system browser when a remote runtime environment is active', () => {
storeState.settings = { openLinksInApp: true, activeRuntimeEnvironmentId: 'env-1' }
diff --git a/src/renderer/src/lib/http-link-routing.ts b/src/renderer/src/lib/http-link-routing.ts
index 86e4f91fcd5..60f53457fc7 100644
--- a/src/renderer/src/lib/http-link-routing.ts
+++ b/src/renderer/src/lib/http-link-routing.ts
@@ -10,6 +10,8 @@ export type OpenHttpLinkOptions = {
worktreeId?: string | null
/** Unconditional: always use the system browser regardless of settings. */
forceSystemBrowser?: boolean
+ /** Unconditional for local sources: open inside Orca regardless of settings. */
+ forceInApp?: boolean
/** The Shift escape-hatch modifier was held; resolveModifierRouting decides what it means. */
modifierHeld?: boolean
sourceOwner?: HttpLinkSourceOwner
@@ -91,7 +93,7 @@ export function resolveModifierRouting(
}
export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void {
- const { worktreeId, forceSystemBrowser, modifierHeld, sourceOwner } = opts
+ const { worktreeId, forceSystemBrowser, forceInApp, modifierHeld, sourceOwner } = opts
if (sourceOwner?.kind === 'unknown') {
return
}
@@ -109,7 +111,7 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void
!forceSystemBrowser &&
!modifier.wantsSystemBrowser &&
Boolean(worktreeId) &&
- (openLinksInApp || modifier.wantsOrca)
+ (forceInApp || openLinksInApp || modifier.wantsOrca)
if (routeToOrca && worktreeId && state) {
// Why: http clicks from inside a worktree should not push a worktree-switch
diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts
index 64cbdd6ada1..fd0ff5e88ec 100644
--- a/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts
+++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts
@@ -4,6 +4,7 @@ import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
import { createPaneDOM } from './pane-dom-creation'
const webLinksAddonMock = vi.hoisted(() => ({
+ handler: null as ((event: MouseEvent, uri: string) => void) | null,
options: null as { hover?: (event: MouseEvent, uri: string) => void; leave?: () => void } | null
}))
@@ -32,7 +33,8 @@ vi.mock('@xterm/addon-unicode11', () => ({
}))
vi.mock('@xterm/addon-web-links', () => ({
- WebLinksAddon: vi.fn().mockImplementation(function WebLinksAddon(_handler, options) {
+ WebLinksAddon: vi.fn().mockImplementation(function WebLinksAddon(handler, options) {
+ webLinksAddonMock.handler = handler
webLinksAddonMock.options = options
return {}
})
@@ -135,4 +137,23 @@ describe('createPaneDOM link tooltips', () => {
expect(linkOpenHint).toHaveBeenCalledWith(7)
expect(formatLinkTooltip).toHaveBeenCalledWith(7, 'http://localhost:5180/', 'open hint')
})
+
+ it('identifies the clicked pane to link routing', () => {
+ const leafId = '11111111-1111-4111-8111-111111111111' as TerminalLeafId
+ const onLinkClick = vi.fn()
+ createPaneDOM(
+ 7,
+ leafId,
+ { linkOpenHint: () => 'open hint', onLinkClick },
+ { active: null } as never,
+ {} as never,
+ vi.fn(),
+ vi.fn()
+ )
+ const event = {} as MouseEvent
+
+ webLinksAddonMock.handler?.(event, 'https://example.com')
+
+ expect(onLinkClick).toHaveBeenCalledWith(7, event, 'https://example.com')
+ })
})
diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts
index 0163587662e..f383dfa3c53 100644
--- a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts
+++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts
@@ -70,7 +70,7 @@ export function createPaneDOM(
const paneDragCleanup = attachPaneDrag(dragHandle, id, dragState, dragCallbacks)
const webLinksAddon = new WebLinksAddon(
- options.onLinkClick ? (event, uri) => options.onLinkClick!(event, uri) : undefined,
+ options.onLinkClick ? (event, uri) => options.onLinkClick!(id, event, uri) : undefined,
{
hover: (_event, uri) => {
if (uri) {
diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts
index 21956fad0ec..113f10af80d 100644
--- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts
+++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts
@@ -58,7 +58,7 @@ export type PaneManagerOptions = {
onExternalPaneDrop?: PaneExternalDropHandler
terminalOptions?: (paneId: number) => Partial
terminalTuiScrollSensitivity?: () => number | undefined
- onLinkClick?: (event: MouseEvent | undefined, url: string) => void
+ onLinkClick?: (paneId: number, event: MouseEvent | undefined, url: string) => void
/** Resolved per hover so link-routing setting changes apply without recreating panes. */
// Why: required so dropping the wiring is a compile error — an optional hint with a
// default would silently serve stale copy that no test can distinguish.
diff --git a/src/renderer/src/lib/settings-navigation-types.ts b/src/renderer/src/lib/settings-navigation-types.ts
index 1079a47f0f7..b901ffb0cce 100644
--- a/src/renderer/src/lib/settings-navigation-types.ts
+++ b/src/renderer/src/lib/settings-navigation-types.ts
@@ -60,6 +60,7 @@ const SETTINGS_NAV_INTENT_SET: ReadonlySet = new Set(SETTINGS_NAV_INTENT
export type SettingsNavTarget = (typeof SETTINGS_NAV_TARGETS)[number]
export const FULL_DISK_ACCESS_SETTINGS_TARGET_ID = 'developer-permissions-full-disk-access'
+export const BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID = 'browser-terminal-link-actions'
export type SettingsNavigationTarget = {
pane: SettingsNavTarget
diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts
index f3a6ff1b89e..ea41c8a6af9 100644
--- a/src/shared/constants.test.ts
+++ b/src/shared/constants.test.ts
@@ -69,6 +69,10 @@ describe('getDefaultSettings', () => {
expect(getDefaultSettings('/tmp').showMenuBarIcon).toBe(true)
})
+ it('shows terminal link actions by default', () => {
+ expect(getDefaultSettings('/tmp').terminalLinkActionPopoverEnabled).toBe(true)
+ })
+
it('confirms before closing pinned tabs by default', () => {
expect(getDefaultSettings('/tmp').confirmClosePinnedTab).toBe(true)
})
diff --git a/src/shared/constants.ts b/src/shared/constants.ts
index fcd72044911..d9a17c103f5 100644
--- a/src/shared/constants.ts
+++ b/src/shared/constants.ts
@@ -265,6 +265,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
localhostWorktreeLabelsEnabled: false,
openLinksInAppPreferencePrompted: false,
openLinksInAppModifierInverts: false,
+ terminalLinkActionPopoverEnabled: true,
openAgentTabsInChatByDefault: false,
experimentalNativeChat: false,
nativeChatSessionOptions: {},
diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts
index 6115584fd04..bbffedaefe0 100644
--- a/src/shared/telemetry-events.ts
+++ b/src/shared/telemetry-events.ts
@@ -246,6 +246,7 @@ export const SETTINGS_CHANGED_WHITELIST = [
'editorAutoSave',
'openLinksInApp',
'openLinksInAppModifierInverts',
+ 'terminalLinkActionPopoverEnabled',
'experimentalMobile',
'experimentalPet',
'experimentalNativeChat',
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 63019fcd03d..c6dfff01d3c 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -2878,6 +2878,8 @@ export type GlobalSettings = {
openLinksInAppPreferencePrompted: boolean
/** Opt-in: Shift+modifier click inverts openLinksInApp instead of always forcing the system browser. Off keeps the historical one-way escape hatch. */
openLinksInAppModifierInverts?: boolean
+ /** Show terminal link actions on plain click; off restores modifier-click-only terminal links. */
+ terminalLinkActionPopoverEnabled?: boolean
/** 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. */