feat(terminal): add anchored link action popovers (#13414)

This commit is contained in:
Jinwoo Hong
2026-08-09 19:27:01 -07:00
committed by GitHub
parent 6c765d5ad3
commit ce8c3267b7
42 changed files with 1750 additions and 166 deletions
@@ -12,6 +12,7 @@ import { BrowserUseSetup } from './BrowserUsePane'
import { BrowserSearchEngineSetting } from './BrowserSearchEngineSetting'
import { BrowserLinkRoutingSetting } from './BrowserLinkRoutingSetting'
import { BrowserLinkRoutingModifierSetting } from './BrowserLinkRoutingModifierSetting'
import { BrowserTerminalLinkActionsSetting } from './BrowserTerminalLinkActionsSetting'
import { BrowserLocalhostWorktreeLabelsSetting } from './BrowserLocalhostWorktreeLabelsSetting'
import { BrowserSessionCookiesSection } from './BrowserSessionCookiesSection'
import { BrowserNewProfileDialog } from './BrowserNewProfileDialog'
@@ -104,8 +105,11 @@ export function BrowserPane({
const showLinkRoutingModifier = matchesSettingsSearch(searchQuery, [
getBrowserPaneSearchEntries()[4]
])
const showLocalhostLabels = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[5]])
const showCookies = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[6]])
const showTerminalLinkActions = matchesSettingsSearch(searchQuery, [
getBrowserPaneSearchEntries()[5]
])
const showLocalhostLabels = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[6]])
const showCookies = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[7]])
const showBrowserUse = matchesSettingsSearch(searchQuery, getBrowserUsePaneSearchEntries())
const isMac = isMacUserAgent()
const linkRoutingDescription = getBrowserLinkRoutingDescription(
@@ -254,6 +258,14 @@ export function BrowserPane({
/>
) : null}
{showTerminalLinkActions ? (
<BrowserTerminalLinkActionsSetting
settings={settings}
isMac={isMac}
updateSettings={updateSettings}
/>
) : null}
{showLocalhostLabels ? (
<BrowserLocalhostWorktreeLabelsSetting
settings={settings}
@@ -0,0 +1,47 @@
import type { GlobalSettings } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
import { BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types'
import { SearchableSetting } from './SearchableSetting'
import { SettingsSwitchRow } from './SettingsFormControls'
import { getTerminalLinkActionsDescription } from './browser-link-routing-copy'
import { getTerminalLinkActionSearchKeywords } from './browser-search'
type BrowserTerminalLinkActionsSettingProps = {
settings: Pick<GlobalSettings, 'terminalLinkActionPopoverEnabled'>
isMac: boolean
updateSettings: (updates: Partial<GlobalSettings>) => 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 (
<SearchableSetting
id={BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID}
title={title}
description={description}
keywords={getTerminalLinkActionSearchKeywords({ isMac })}
>
<div className="ml-4 border-l border-border pl-4">
<SettingsSwitchRow
label={title}
description={description}
checked={settings.terminalLinkActionPopoverEnabled !== false}
onChange={() =>
updateSettings({
terminalLinkActionPopoverEnabled: settings.terminalLinkActionPopoverEnabled === false
})
}
/>
</div>
</SearchableSetting>
)
}
@@ -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
@@ -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'
])
@@ -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',
@@ -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 }) => <span>{children}</span>
}))
vi.mock('@/components/ui/popover', () => ({
Popover: ({
children,
open,
onOpenChange
}: {
children: ReactNode
open: boolean
onOpenChange: (open: boolean) => void
}) =>
open ? (
<div>
<button data-testid="dismiss-popover" onClick={() => onOpenChange(false)} />
{children}
</div>
) : null,
PopoverAnchor: () => null,
PopoverContent: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
)
}))
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(<TerminalLinkActionPopover request={request} onClose={onClose} />)
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(<TerminalLinkActionPopover request={request} onClose={onClose} />)
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(<TerminalLinkActionPopover request={request} onClose={vi.fn()} />)
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(<TerminalLinkActionPopover request={request} onClose={onClose} />)
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()
})
})
@@ -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 (
<Button
className="h-8 w-full justify-start gap-1.5 px-1.5 text-[13px] font-normal has-[>svg]:px-1.5"
variant="ghost"
onClick={onRun}
>
{action.external === true ? <ExternalLink className="size-3.5" /> : null}
{action.external === false ? <Globe className="size-3.5" /> : null}
<span className="min-w-0 flex-1 text-left">{action.label}</span>
<ShortcutKeyCombo keys={keys} keyCapClassName="min-w-5 px-1 py-0 text-[11px]" />
</Button>
)
}
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 (
<Popover
open={request !== null}
onOpenChange={(open) => !open && onClose(request ?? undefined)}
>
<PopoverAnchor virtualRef={virtualRef} />
{request ? (
<PopoverContent
align="start"
side="top"
sideOffset={6}
collisionPadding={8}
className="w-max min-w-52 max-w-[min(17rem,calc(100vw-1rem))] p-1"
data-terminal-link-action-popover
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
onEscapeKeyDown={() => request.focusTerminal()}
>
<div className="mb-0.5 flex items-center gap-1 overflow-hidden border-b border-border px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
<span
className="line-clamp-2 min-w-0 flex-1 break-all"
data-terminal-link-destination
title={request.destination}
>
{request.destination}
</span>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={settingsLabel}
className="text-muted-foreground"
size="icon-xs"
variant="ghost"
onClick={openTerminalLinkSettings}
>
<Settings />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{settingsLabel}
</TooltipContent>
</Tooltip>
</div>
<ActionRow
action={request.primary}
alternate={false}
onRun={() => runAction(request.primary)}
/>
{request.alternate ? (
<ActionRow
action={request.alternate}
alternate
onRun={() => runAction(request.alternate!)}
/>
) : null}
</PopoverContent>
) : null}
</Popover>
)
}
@@ -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<number>(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<TerminalLinkActionRequest | null>(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}
/>
<TerminalLinkActionPopover
request={terminalLinkActionRequest}
onClose={closeTerminalLinkActions}
/>
{/* Why: repos is a broad store slice; only subscribe while the editor is visible. */}
{quickCommandEditorOpen ? (
<TerminalQuickCommandEditorDialog
@@ -0,0 +1,97 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TerminalLinkActionContext } from './terminal-link-action-request'
const mocks = vi.hoisted(() => ({
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<typeof vi.fn>): 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')
})
})
@@ -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
})
}
}
: {})
})
}
@@ -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<ILink[]> {
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<ILink[]>((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 = {
@@ -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<MouseEvent, 'metaKey' | 'ctrlKey'> | 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(
@@ -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)
}
@@ -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<void>
}
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
}
@@ -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)
})
})
@@ -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> = {}): 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)
})
})
@@ -5,3 +5,30 @@ export function isTerminalLinkActivation(
): boolean {
return isMacPlatform() ? Boolean(event?.metaKey) : Boolean(event?.ctrlKey)
}
type TerminalLinkMouseEvent = Pick<MouseEvent, 'ctrlKey' | 'metaKey'> &
Partial<Pick<MouseEvent, 'altKey' | 'button' | 'shiftKey'>>
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)
}
@@ -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 () => {
@@ -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
}
@@ -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', () => {
@@ -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`
}
@@ -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<ReturnType<typeof installTerminalLinkPointerGesture>>()
function createGesture(): {
gesture: ReturnType<typeof installTerminalLinkPointerGesture>
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)
})
})
@@ -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)
}
}
}
@@ -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)
@@ -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', () => {
@@ -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
@@ -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<MouseEvent, 'metaKey' | 'ctrlKey'> &
Partial<Pick<MouseEvent, 'button' | 'shiftKey' | 'preventDefault' | 'stopPropagation'>>
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
}
@@ -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()
})
})
@@ -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.
@@ -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)
}
@@ -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<GlobalSettings | null | undefined>
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<EffectiveMacOptionAsAlt>
@@ -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<number, IDisposable>())
const terminalHandleLinkDisposablesRef = useRef(new Map<number, IDisposable>())
const linkifierClickPrimingDisposablesRef = useRef(new Map<number, IDisposable>())
const linkPointerGesturesRef = useRef(
new Map<number, ReturnType<typeof installTerminalLinkPointerGesture>>()
)
const fileLinkClickFallbackDisposablesRef = useRef(new Map<number, IDisposable>())
const httpLinkClickFallbackDisposablesRef = useRef(new Map<number, IDisposable>())
// 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<string, boolean>()
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()
}
+25
View File
@@ -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",
@@ -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' }
+4 -2
View File
@@ -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
@@ -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')
})
})
@@ -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) {
@@ -58,7 +58,7 @@ export type PaneManagerOptions = {
onExternalPaneDrop?: PaneExternalDropHandler
terminalOptions?: (paneId: number) => Partial<ITerminalOptions>
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.
@@ -60,6 +60,7 @@ const SETTINGS_NAV_INTENT_SET: ReadonlySet<string> = 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
+4
View File
@@ -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)
})
+1
View File
@@ -265,6 +265,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
localhostWorktreeLabelsEnabled: false,
openLinksInAppPreferencePrompted: false,
openLinksInAppModifierInverts: false,
terminalLinkActionPopoverEnabled: true,
openAgentTabsInChatByDefault: false,
experimentalNativeChat: false,
nativeChatSessionOptions: {},
+1
View File
@@ -246,6 +246,7 @@ export const SETTINGS_CHANGED_WHITELIST = [
'editorAutoSave',
'openLinksInApp',
'openLinksInAppModifierInverts',
'terminalLinkActionPopoverEnabled',
'experimentalMobile',
'experimentalPet',
'experimentalNativeChat',
+2
View File
@@ -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. */