From 0cec9896a111ab1cb9a41edfc310d1a371c65736 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 20:51:09 -0500 Subject: [PATCH 1/2] fix(desktop): open links clicked in the integrated terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../app/right-sidebar/terminal/links.test.ts | 75 +++++++++++++++++++ .../src/app/right-sidebar/terminal/links.ts | 35 +++++++++ .../terminal/use-agent-terminal.ts | 5 +- .../terminal/use-terminal-session.ts | 8 +- 4 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/app/right-sidebar/terminal/links.test.ts create mode 100644 apps/desktop/src/app/right-sidebar/terminal/links.ts diff --git a/apps/desktop/src/app/right-sidebar/terminal/links.test.ts b/apps/desktop/src/app/right-sidebar/terminal/links.test.ts new file mode 100644 index 00000000000..fcbc45853f2 --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/links.test.ts @@ -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 = {}) => ({ 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[0]> = [] + const register = term.registerLinkProvider.bind(term) + + term.registerLinkProvider = provider => { + providers.push(provider) + + return register(provider) + } + + term.loadAddon(terminalWebLinksAddon()) + + await new Promise(resolve => term.write(`${text}\r\n`, resolve)) + + const links = await new Promise(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) + }) +}) diff --git a/apps/desktop/src/app/right-sidebar/terminal/links.ts b/apps/desktop/src/app/right-sidebar/terminal/links.ts new file mode 100644 index 00000000000..41024407134 --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/links.ts @@ -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, + 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) diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts b/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts index bee037094f6..a8d92d9e36e 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts @@ -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 diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts index 09cd815413a..d60bb71bdd4 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts @@ -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 From 4d6589c69c1ea89a1c12aef8f8cf42c638c313dc Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 20:51:40 -0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(desktop):=20stop=20=E2=8C=A5-click=20sp?= =?UTF-8?q?raying=20cursor=20escapes=20into=20the=20terminal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⌥-drag is the app's force-selection gesture over mouse-mode TUIs, but xterm's default alt-click-moves-cursor claims the same click and emits 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. --- .../src/app/right-sidebar/terminal/use-terminal-session.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts index d60bb71bdd4..888d197a55a 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts @@ -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