Merge pull request #75126 from NousResearch/bb/terminal-links

Open links clicked in the integrated terminal
This commit is contained in:
brooklyn! 2026-07-30 21:58:20 -05:00 committed by GitHub
commit 9dd7ac670a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 124 additions and 4 deletions

View file

@ -0,0 +1,75 @@
import type { ILink, Terminal as TerminalType } from '@xterm/xterm'
import { Terminal } from '@xterm/xterm'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { isTerminalLinkActivation, terminalLinkHandler, terminalWebLinksAddon } from './links'
const openExternal = vi.fn()
const click = (init: Partial<MouseEvent> = {}) => ({ ctrlKey: false, metaKey: false, ...init })
beforeEach(() => {
openExternal.mockClear()
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { openExternal } })
// jsdom reports a non-mac platform, so the activation modifier resolves to
// Ctrl unless we say otherwise.
Object.defineProperty(navigator, 'platform', { configurable: true, value: 'MacIntel' })
})
// Drive the addon the way xterm does: load it on a real terminal, write a URL
// into the buffer, then activate the link its provider reports.
async function clickLinkIn(text: string, event: MouseEvent) {
const term = new Terminal({ allowProposedApi: true, cols: 80, rows: 10 })
const providers: Array<Parameters<TerminalType['registerLinkProvider']>[0]> = []
const register = term.registerLinkProvider.bind(term)
term.registerLinkProvider = provider => {
providers.push(provider)
return register(provider)
}
term.loadAddon(terminalWebLinksAddon())
await new Promise<void>(resolve => term.write(`${text}\r\n`, resolve))
const links = await new Promise<ILink[]>(resolve => providers[0].provideLinks(1, found => resolve(found ?? [])))
links[0]?.activate(event, links[0].text)
return links[0]?.text
}
describe('terminal links', () => {
it('opens a ⌘-clicked URL through the desktop bridge, not the window.open Electron denies', async () => {
const uri = 'https://example.com/path'
expect(await clickLinkIn(uri, new MouseEvent('click', { metaKey: true }))).toBe(uri)
expect(openExternal).toHaveBeenCalledWith(uri)
})
it('leaves a bare click to the selection, so a misclick never launches a browser', async () => {
await clickLinkIn('https://example.com/path', new MouseEvent('click'))
expect(openExternal).not.toHaveBeenCalled()
})
it('routes OSC 8 hyperlinks the same way, instead of xterm\u2019s confirm() dialog', () => {
terminalLinkHandler.activate(new MouseEvent('click', { metaKey: true }), 'https://example.com/osc8', {
end: { x: 10, y: 1 },
start: { x: 1, y: 1 }
})
expect(openExternal).toHaveBeenCalledWith('https://example.com/osc8')
})
})
describe('isTerminalLinkActivation', () => {
it('takes the platform modifier: ⌘ on macOS, Ctrl elsewhere', () => {
expect(isTerminalLinkActivation(click({ metaKey: true }), true)).toBe(true)
expect(isTerminalLinkActivation(click({ ctrlKey: true }), false)).toBe(true)
})
it('keeps Ctrl+click free on macOS, where the OS reads it as a right-click', () => {
expect(isTerminalLinkActivation(click({ ctrlKey: true }), true)).toBe(false)
})
})

View file

@ -0,0 +1,35 @@
import { WebLinksAddon } from '@xterm/addon-web-links'
import type { ILinkHandler } from '@xterm/xterm'
import { openExternalLink } from '@/lib/external-link'
import { isMacPlatform } from './selection'
// Both of xterm's link paths — the web-links addon (URLs it finds in the
// buffer) and the core OSC 8 provider (hyperlinks a CLI emits explicitly) —
// activate through `window.open()`, which the window's setWindowOpenHandler
// denies: a click did nothing but log "Opening link blocked as opener could not
// be cleared", and OSC 8 fronted that dead end with a raw confirm() dialog.
// Route both through the desktop bridge, the path every other external link in
// the app takes.
//
// ⌘-click on macOS, Ctrl-click elsewhere — VS Code's integrated terminal,
// Terminal.app, and iTerm2 all agree. A bare click belongs to the selection, so
// a misclick on a URL can't launch a browser mid-sentence. ⌥ stays out of it:
// that's the force-selection drag over mouse-mode TUIs.
export function isTerminalLinkActivation(
event: Pick<MouseEvent, 'ctrlKey' | 'metaKey'>,
isMac = isMacPlatform()
): boolean {
return isMac ? event.metaKey : event.ctrlKey
}
const activate = (event: MouseEvent, uri: string) => {
if (isTerminalLinkActivation(event)) {
openExternalLink(uri)
}
}
export const terminalLinkHandler: ILinkHandler = { activate }
export const terminalWebLinksAddon = () => new WebLinksAddon(activate)

View file

@ -1,6 +1,5 @@
import { FitAddon } from '@xterm/addon-fit'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { WebglAddon } from '@xterm/addon-webgl'
import { Terminal } from '@xterm/xterm'
import { useEffect, useRef } from 'react'
@ -12,6 +11,7 @@ import { useTheme } from '@/themes/context'
import { registerAgentTerminalWriter } from './agent-terminal-stream'
import { makeTerminalReader, registerTerminalReader } from './buffer'
import { mirrorSelection, terminalClipboardIntent } from './clipboard'
import { terminalLinkHandler, terminalWebLinksAddon } from './links'
import { isMacPlatform, resolveSurfaceColor, terminalTheme } from './selection'
// Read-only terminal for an agent background process: a write-only xterm (no PTY,
@ -51,6 +51,7 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id:
fontWeightBold: 'bold',
letterSpacing: 0,
lineHeight: 1.12,
linkHandler: terminalLinkHandler,
minimumContrastRatio: 4.5,
scrollback: 1000,
theme: surfaceTheme()
@ -59,7 +60,7 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id:
const fit = new FitAddon()
term.loadAddon(fit)
term.loadAddon(new Unicode11Addon())
term.loadAddon(new WebLinksAddon())
term.loadAddon(terminalWebLinksAddon())
term.unicode.activeVersion = '11'
term.open(host)
termRef.current = term

View file

@ -1,7 +1,6 @@
import { FitAddon } from '@xterm/addon-fit'
import { SerializeAddon } from '@xterm/addon-serialize'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { WebglAddon } from '@xterm/addon-webgl'
import { Terminal } from '@xterm/xterm'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -16,6 +15,7 @@ import { $terminalInjection } from '../store'
import { makeTerminalReader, registerTerminalReader } from './buffer'
import { mirrorSelection, terminalClipboardIntent } from './clipboard'
import { terminalLinkHandler, terminalWebLinksAddon } from './links'
import {
isAddSelectionShortcut,
isMacPlatform,
@ -499,6 +499,11 @@ export function useTerminalSession({
const term = new Terminal({
allowProposedApi: true,
// ⌥-drag is our force-selection gesture (below), and xterm's default
// alt-click-moves-cursor claims the same click, emitting one cursor
// left/right escape per column of travel — shells that don't consume them
// echo the raw `^[[D` burst into the buffer. One gesture, one meaning.
altClickMovesCursor: false,
// Opaque canvas = WebGL's crisp fast-path. allowTransparency instead bakes
// glyphs as grayscale-alpha for compositing over a see-through canvas, which
// reads soft on every platform; VS Code keeps it off and our surface
@ -514,6 +519,10 @@ export function useTerminalSession({
fontWeightBold: 'bold',
letterSpacing: 0,
lineHeight: 1.12,
// OSC 8 hyperlinks (gh, cargo, npm, ls --hyperlink) activate through this
// handler; without it xterm shows a raw confirm() and then a window.open
// Electron denies.
linkHandler: terminalLinkHandler,
// Full-screen TUIs (hermes --tui, vim) grab the mouse, so a plain drag
// can't select — ⌥-drag (macOS) / Shift-drag (else) forces a native
// selection over mouse-mode apps, which ⌘/Ctrl+L then sends to chat.
@ -536,7 +545,7 @@ export function useTerminalSession({
term.loadAddon(fit)
term.loadAddon(serialize)
term.loadAddon(new Unicode11Addon())
term.loadAddon(new WebLinksAddon())
term.loadAddon(terminalWebLinksAddon())
term.unicode.activeVersion = '11'
// Replay last session's scrollback before the fresh shell boots. The process