Add default browser action for terminal links (#6129)

* Add terminal link browser context action

* Polish terminal link default-browser action

- Route Open in Default Browser through the shared openHttpLink funnel
  (forceSystemBrowser) so loopback dev-server links get the same
  worktree-label rewrite as shift+click's system-browser path.
- Translate the new menu label in es/ja/ko/zh (was en-only).
- Extract TerminalQuickCommandMenuItem to keep TerminalContextMenu under
  the max-lines lint after adding the link item.
- Hit-test the link only when the menu actually opens (skip on Windows
  right-click copy/paste path).
- Add scheme-safety unit tests (file:/javascript:/smb:/vscode: rejected)
  and a menu-item render/callback test; fix TerminalContextMenu.test
  props for the new required fields.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Ricardo Sawir
2026-07-03 14:09:52 -07:00
committed by GitHub
co-authored by Orca Neil
parent 42de074152
commit 3614cbc40c
12 changed files with 278 additions and 30 deletions
@@ -57,6 +57,8 @@ function renderMenu(overrides: Record<string, unknown> = {}): void {
canClosePane: true,
canExpandPane: true,
menuPaneIsExpanded: false,
linkUrl: null,
onOpenLinkInDefaultBrowser: vi.fn(),
onCopy: vi.fn(),
onPaste: vi.fn(),
onSplitRight: vi.fn(),
@@ -114,6 +116,24 @@ describe('TerminalContextMenu', () => {
expect(onForkAgentSession).not.toHaveBeenCalled()
})
it('shows "Open in Default Browser" only when a link is under the cursor', () => {
renderMenu({ linkUrl: null })
expect(
items.list.some((item) => childrenText(item.children) === 'Open in Default Browser')
).toBe(false)
items.list = []
const onOpenLinkInDefaultBrowser = vi.fn()
renderMenu({ linkUrl: 'https://example.com/docs', onOpenLinkInDefaultBrowser })
const openLinkItem = items.list.find(
(item) => childrenText(item.children) === 'Open in Default Browser'
)
expect(openLinkItem).toBeDefined()
openLinkItem?.onSelect?.()
expect(onOpenLinkInDefaultBrowser).toHaveBeenCalledTimes(1)
})
it('shows one shortcut per terminal menu action on Windows', () => {
vi.stubGlobal('navigator', {
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
@@ -4,6 +4,7 @@ import {
ClipboardCopy,
Copy,
Eraser,
ExternalLink,
GitFork,
Maximize2,
MessageSquare,
@@ -31,12 +32,11 @@ import {
} from '@/components/ui/dropdown-menu'
import { shouldIgnoreTerminalMenuPointerDownOutside } from './terminal-context-menu-dismiss'
import type { TerminalQuickCommand } from '../../../../shared/types'
import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands'
import { formatPrimaryShortcutLabel } from '@/hooks/useShortcutLabel'
import { AgentIcon } from '@/lib/agent-catalog'
import type { KeybindingOverrides } from '../../../../shared/keybindings'
import { translate } from '@/i18n/i18n'
import { isMacPlatform, nativeChatToggleShortcutLabel } from '../native-chat/native-chat-shortcut'
import { TerminalQuickCommandMenuItem } from './TerminalQuickCommandMenuItem'
type TerminalContextMenuProps = {
open: boolean
@@ -46,6 +46,8 @@ type TerminalContextMenuProps = {
canClosePane: boolean
canExpandPane: boolean
menuPaneIsExpanded: boolean
linkUrl: string | null
onOpenLinkInDefaultBrowser: () => void
onCopy: () => void
onPaste: () => void
onSplitRight: () => void
@@ -81,6 +83,8 @@ export default function TerminalContextMenu({
canClosePane,
canExpandPane,
menuPaneIsExpanded,
linkUrl,
onOpenLinkInDefaultBrowser,
onCopy,
onPaste,
onSplitRight,
@@ -128,27 +132,6 @@ export default function TerminalContextMenu({
const showEqualizeShortcut = shortcuts.equalize !== 'Unassigned'
const showSetTitleShortcut = shortcuts.setTitle !== 'Unassigned'
const showClearPaneTitleShortcut = shortcuts.clearPaneTitle !== 'Unassigned'
const renderQuickCommandItem = (command: TerminalQuickCommand): React.JSX.Element => (
<DropdownMenuItem key={command.id} onSelect={() => onQuickCommand(command)}>
{isTerminalAgentQuickCommand(command) ? (
<span className="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground">
<AgentIcon agent={command.agent} size={14} />
</span>
) : (
<Play
className="size-3.5 shrink-0 text-muted-foreground"
fill="currentColor"
strokeWidth={0}
/>
)}
<span className="min-w-0 flex-1 truncate">{command.label}</span>
{!isTerminalAgentQuickCommand(command) && !command.appendEnter ? (
<DropdownMenuShortcut className="shrink-0">
{translate('auto.components.terminal.pane.TerminalContextMenu.c2f0b72b8d', 'Insert')}
</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
)
return (
<DropdownMenu
@@ -194,6 +177,18 @@ export default function TerminalContextMenu({
}
}}
>
{linkUrl ? (
<>
<DropdownMenuItem onSelect={onOpenLinkInDefaultBrowser}>
<ExternalLink />
{translate(
'auto.components.terminal.pane.TerminalContextMenu.openLinkDefaultBrowser',
'Open in Default Browser'
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
) : null}
<DropdownMenuItem onSelect={onCopy}>
<Copy />
{translate('auto.components.terminal.pane.TerminalContextMenu.f3eeb1de13', 'Copy')}
@@ -220,7 +215,13 @@ export default function TerminalContextMenu({
<DropdownMenuLabel className="truncate">
{quickCommandRepoLabel}
</DropdownMenuLabel>
{repoQuickCommands.map(renderQuickCommandItem)}
{repoQuickCommands.map((command) => (
<TerminalQuickCommandMenuItem
key={command.id}
command={command}
onSelect={onQuickCommand}
/>
))}
</>
) : null}
{globalQuickCommands.length > 0 ? (
@@ -234,7 +235,13 @@ export default function TerminalContextMenu({
)}
</DropdownMenuLabel>
) : null}
{globalQuickCommands.map(renderQuickCommandItem)}
{globalQuickCommands.map((command) => (
<TerminalQuickCommandMenuItem
key={command.id}
command={command}
onSelect={onQuickCommand}
/>
))}
</>
) : null}
</>
@@ -2971,6 +2971,8 @@ export default function TerminalPane({
menuPaneIsExpanded={
contextMenu.menuPaneId !== null && contextMenu.menuPaneId === expandedPaneId
}
linkUrl={contextMenu.menuLinkUrl}
onOpenLinkInDefaultBrowser={contextMenu.onOpenLinkInDefaultBrowser}
onCopy={() => void contextMenu.onCopy()}
onPaste={() => void contextMenu.onPaste()}
onSplitRight={contextMenu.onSplitRight}
@@ -0,0 +1,38 @@
import { Play } from 'lucide-react'
import { DropdownMenuItem, DropdownMenuShortcut } from '@/components/ui/dropdown-menu'
import { AgentIcon } from '@/lib/agent-catalog'
import { translate } from '@/i18n/i18n'
import type { TerminalQuickCommand } from '../../../../shared/types'
import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands'
type TerminalQuickCommandMenuItemProps = {
command: TerminalQuickCommand
onSelect: (command: TerminalQuickCommand) => void
}
export function TerminalQuickCommandMenuItem({
command,
onSelect
}: TerminalQuickCommandMenuItemProps): React.JSX.Element {
return (
<DropdownMenuItem onSelect={() => onSelect(command)}>
{isTerminalAgentQuickCommand(command) ? (
<span className="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground">
<AgentIcon agent={command.agent} size={14} />
</span>
) : (
<Play
className="size-3.5 shrink-0 text-muted-foreground"
fill="currentColor"
strokeWidth={0}
/>
)}
<span className="min-w-0 flex-1 truncate">{command.label}</span>
{!isTerminalAgentQuickCommand(command) && !command.appendEnter ? (
<DropdownMenuShortcut className="shrink-0">
{translate('auto.components.terminal.pane.TerminalContextMenu.c2f0b72b8d', 'Insert')}
</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
)
}
@@ -1,6 +1,8 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
extractTerminalHttpLinks,
getTerminalHttpLinkAtBufferPosition,
getTerminalHttpLinkForMouseEvent,
TERMINAL_HTTP_URL_MAX_LENGTH
} from './terminal-url-link-hit-testing'
@@ -47,3 +49,127 @@ describe('extractTerminalHttpLinks', () => {
expect(matchAllSpy).not.toHaveBeenCalled()
})
})
describe('terminal HTTP link hit testing', () => {
type TestBufferLine = {
isWrapped: boolean
length: number
translateToString: (
trimRight?: boolean,
startColumn?: number,
endColumn?: number,
outColumns?: number[]
) => string
}
function defaultColumnsForText(text: string): number[] {
return Array.from({ length: text.length + 1 }, (_value, index) => index)
}
function makeBufferLine(
text: string,
options: { isWrapped?: boolean; columns?: number[] } = {}
): TestBufferLine {
const columns = options.columns ?? defaultColumnsForText(text)
return {
isWrapped: options.isWrapped ?? false,
length: text.length,
translateToString: (
_trimRight?: boolean,
startColumn = 0,
endColumn = text.length,
outColumns?: number[]
) => {
if (outColumns) {
outColumns.length = 0
for (let index = startColumn; index <= endColumn; index++) {
outColumns.push(columns[index] ?? index)
}
}
return text.slice(startColumn, endColumn)
}
}
}
function makeBuffer(
rows: TestBufferLine[]
): Parameters<typeof getTerminalHttpLinkAtBufferPosition>[0] {
return { getLine: (y: number) => rows[y] } as Parameters<
typeof getTerminalHttpLinkAtBufferPosition
>[0]
}
it('returns the URL at a buffer position without opening it', () => {
const line = 'Open https://example.com/docs?q=orca now'
expect(
getTerminalHttpLinkAtBufferPosition(
makeBuffer([makeBufferLine(line)]),
{ x: line.indexOf('example') + 1, y: 1 },
80
)
).toBe('https://example.com/docs?q=orca')
})
it('returns null when the buffer position is outside the URL', () => {
const line = 'Open https://example.com/docs now'
expect(
getTerminalHttpLinkAtBufferPosition(makeBuffer([makeBufferLine(line)]), { x: 2, y: 1 }, 80)
).toBeNull()
})
// Why: the context-menu "Open in Default Browser" item hands this URL to
// shell.openExternal, so a dangerous scheme under the cursor must never be
// surfaced as an openable link.
it.each(['file:///etc/passwd', 'javascript:alert(1)', 'smb://host/share', 'vscode://x'])(
'returns null for the non-http scheme %s',
(dangerousUrl) => {
const line = `Open ${dangerousUrl} now`
expect(
getTerminalHttpLinkAtBufferPosition(
makeBuffer([makeBufferLine(line)]),
{ x: line.indexOf(dangerousUrl) + 1, y: 1 },
80
)
).toBeNull()
}
)
it('returns wrapped URLs from mouse coordinates', () => {
const rows = [
makeBufferLine('Visit https://exa'),
makeBufferLine('mple.com/docs', { isWrapped: true })
]
const screen = {
getBoundingClientRect: () => ({
left: 10,
top: 20,
width: 200,
height: 40
})
}
const terminal = {
cols: 20,
rows: 2,
element: { querySelector: () => screen },
buffer: {
active: {
viewportY: 0,
getLine: (y: number) => rows[y]
}
}
}
expect(
getTerminalHttpLinkForMouseEvent(
terminal as unknown as Parameters<typeof getTerminalHttpLinkForMouseEvent>[0],
{
clientX: 30,
clientY: 45
}
)
).toBe('https://example.com/docs')
})
})
@@ -191,7 +191,7 @@ function getTerminalScreenElement(terminal: Terminal): HTMLElement | null {
function getBufferPositionForTerminalMouseEvent(
terminal: Terminal,
event: MouseEvent
event: Pick<MouseEvent, 'clientX' | 'clientY'>
): { x: number; y: number } | null {
const screenElement = getTerminalScreenElement(terminal)
if (!screenElement || terminal.cols <= 0 || terminal.rows <= 0) {
@@ -260,9 +260,34 @@ export function openHttpLinkAtBufferPosition(
terminalColumns: number,
deps: UrlLinkHitTestDeps
): boolean {
const url = getTerminalHttpLinkAtBufferPosition(buffer, position, terminalColumns)
if (!url) {
return false
}
openTerminalHttpLink(url, deps)
return true
}
export function getTerminalHttpLinkForMouseEvent(
terminal: Terminal,
event: Pick<MouseEvent, 'clientX' | 'clientY'>
): string | null {
const position = getBufferPositionForTerminalMouseEvent(terminal, event)
if (!position) {
return null
}
return getTerminalHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols)
}
export function getTerminalHttpLinkAtBufferPosition(
buffer: { getLine(y: number): IBufferLine | undefined },
position: { x: number; y: number },
terminalColumns: number
): string | null {
const logicalLines = buildCandidateLogicalLinesForBufferPosition(buffer, position.y)
if (logicalLines.length === 0) {
return false
return null
}
for (const logicalLine of logicalLines) {
@@ -271,12 +296,11 @@ export function openHttpLinkAtBufferPosition(
if (!range || !rangeContainsBufferPosition(range, position, terminalColumns)) {
continue
}
openTerminalHttpLink(parsed.url, deps)
return true
return parsed.url
}
}
return false
return null
}
export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): void {
@@ -41,6 +41,8 @@ import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
import { copyTerminalHandleForPane } from './terminal-handle-copy'
import { getTerminalHttpLinkForMouseEvent } from './terminal-url-link-hit-testing'
import { openHttpLink } from '@/lib/http-link-routing'
const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
@@ -81,6 +83,8 @@ type TerminalMenuState = {
menuOpenedAtRef: React.RefObject<number>
paneCount: number
menuPaneId: number | null
menuLinkUrl: string | null
onOpenLinkInDefaultBrowser: () => void
onContextMenuCapture: (event: React.MouseEvent<HTMLDivElement>) => void
onPaneTitleContextMenu: (event: React.MouseEvent<HTMLElement>, paneId: number) => void
onCopy: () => Promise<void>
@@ -124,6 +128,7 @@ export function useTerminalPaneContextMenu({
const menuOpenedAtRef = useRef(0)
const [open, setOpen] = useState(false)
const [point, setPoint] = useState({ x: 0, y: 0 })
const [menuLinkUrl, setMenuLinkUrl] = useState<string | null>(null)
useEffect(() => {
const closeMenu = (): void => {
@@ -182,6 +187,17 @@ export function useTerminalPaneContextMenu({
pane.terminal.focus()
}
const onOpenLinkInDefaultBrowser = (): void => {
if (!menuLinkUrl) {
return
}
// Why: route through the shared funnel (not shell.openUrl directly) so this
// matches shift+click's system-browser path, including the loopback
// worktree-label rewrite for local dev-server links.
openHttpLink(menuLinkUrl, { worktreeId, forceSystemBrowser: true })
resolveMenuPane()?.terminal.focus()
}
const getShortcutPlatform = (): NodeJS.Platform => {
if (navigator.userAgent.includes('Mac')) {
return 'darwin'
@@ -472,6 +488,7 @@ export function useTerminalPaneContextMenu({
const manager = managerRef.current
if (!manager) {
contextPaneIdRef.current = null
setMenuLinkUrl(null)
return
}
const clickedPane =
@@ -499,6 +516,11 @@ export function useTerminalPaneContextMenu({
return
}
// Why: only hit-test the link once the menu is actually opening; the Windows
// copy/paste path above returns without a menu and needs no link lookup.
setMenuLinkUrl(
clickedPane ? getTerminalHttpLinkForMouseEvent(clickedPane.terminal, event.nativeEvent) : null
)
menuOpenedAtRef.current = Date.now()
const bounds = boundsElement.getBoundingClientRect()
setPoint({ x: event.clientX - bounds.left, y: event.clientY - bounds.top })
@@ -510,12 +532,14 @@ export function useTerminalPaneContextMenu({
if (!manager) {
event.preventDefault()
contextPaneIdRef.current = null
setMenuLinkUrl(null)
return
}
const target = event.target
if (!(target instanceof Node)) {
event.preventDefault()
contextPaneIdRef.current = null
setMenuLinkUrl(null)
return
}
const clickedPane = manager.getPanes().find((pane) => pane.container.contains(target)) ?? null
@@ -544,6 +568,8 @@ export function useTerminalPaneContextMenu({
menuOpenedAtRef,
paneCount,
menuPaneId,
menuLinkUrl,
onOpenLinkInDefaultBrowser,
onContextMenuCapture,
onPaneTitleContextMenu,
onCopy,
+1
View File
@@ -2450,6 +2450,7 @@
"0a917b591a": "Paste",
"f3eeb1de13": "Copy",
"c2f0b72b8d": "Insert",
"openLinkDefaultBrowser": "Open in Default Browser",
"925f49f210": "Expand Pane",
"df766809e0": "Collapse Pane",
"cff67afad1": "Copy Context"
+1
View File
@@ -2450,6 +2450,7 @@
"0a917b591a": "Pasta",
"f3eeb1de13": "Copiar",
"c2f0b72b8d": "Insertar",
"openLinkDefaultBrowser": "Abrir en el navegador predeterminado",
"925f49f210": "Expandir panel",
"df766809e0": "Contraer panel",
"cff67afad1": "Copy Context"
+1
View File
@@ -2450,6 +2450,7 @@
"0a917b591a": "ペースト",
"f3eeb1de13": "コピー",
"c2f0b72b8d": "入れる",
"openLinkDefaultBrowser": "既定のブラウザで開く",
"925f49f210": "ペインを展開する",
"df766809e0": "ペインを折りたたむ",
"cff67afad1": "Copy Context"
+1
View File
@@ -2450,6 +2450,7 @@
"0a917b591a": "붙여넣기",
"f3eeb1de13": "복사",
"c2f0b72b8d": "삽입",
"openLinkDefaultBrowser": "기본 브라우저에서 열기",
"925f49f210": "창 확장",
"df766809e0": "창 축소",
"cff67afad1": "Copy Context"
+1
View File
@@ -2450,6 +2450,7 @@
"0a917b591a": "粘贴",
"f3eeb1de13": "复制",
"c2f0b72b8d": "插入",
"openLinkDefaultBrowser": "在默认浏览器中打开",
"925f49f210": "展开窗格",
"df766809e0": "折叠窗格",
"cff67afad1": "Copy Context"