mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(desktop): open links clicked in the integrated terminal
Both of xterm's link paths activate through `window.open()`, which the window's setWindowOpenHandler denies, so ⌘-clicking a URL did nothing but log "Opening link blocked as opener could not be cleared" — and the OSC 8 path 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, matching VS Code's integrated terminal, Terminal.app, and iTerm2. A bare click stays with the selection so a misclick on a URL can't launch a browser.
This commit is contained in:
parent
cc4cab2f59
commit
0cec9896a1
4 changed files with 119 additions and 4 deletions
75
apps/desktop/src/app/right-sidebar/terminal/links.test.ts
Normal file
75
apps/desktop/src/app/right-sidebar/terminal/links.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
35
apps/desktop/src/app/right-sidebar/terminal/links.ts
Normal file
35
apps/desktop/src/app/right-sidebar/terminal/links.ts
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -514,6 +514,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 +540,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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue