NousResearch/hermes-agent

Clickable URL hyperlinks in terminal UI

Cluster 22895 · hermes cli. Data as of

Nothing open on GitHub. Fix merged in #25071 on 13 May 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #25071. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and PRs concern detecting and enabling clickable hyperlinks in terminal environments, including specific handling for Ghostty and general terminal URL click support.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #25071 tui: make URLs clickable + hover-highlight in any terminal

    merged to main

    files: ui-tui/packages/hermes-ink/src/ink/components/Link.tsx, ui-tui/packages/hermes-ink/src/ink/hyperlinkHover.ts, ui-tui/packages/hermes-ink/src/ink/ink.tsx, ui-tui/packages/hermes-ink/src/ink/root.ts, ui-tui/src/entry.tsx, ui-tui/src/lib/openExternalUrl.test.ts, ui-tui/src/lib/openExternalUrl.ts, ui-tui/src/types/hermes-ink.d.ts

    Show the diff
    • ui-tui/packages/hermes-ink/src/ink/components/Link.tsx
      @@ -1,53 +1,38 @@
       import type { ReactNode } from 'react'
       import React from 'react'
      -import { c as _c } from 'react/compiler-runtime'
      -
      -import { supportsHyperlinks } from '../supports-hyperlinks.js'
       
       import Text from './Text.js'
       export type Props = {
         readonly children?: ReactNode
         readonly url: string
      +  // Kept for backwards-compat: prior versions rendered `fallback` instead of
      +  // the linked content on terminals where supportsHyperlinks() was false. We
      +  // now always emit the hyperlink metadata so the in-process click/hover
      +  // dispatcher can act on it regardless of the terminal's own OSC 8 support
      +  // (see comment in the function body), so `fallback` is no longer wired up.
      +  // Leaving the prop on the interface keeps existing call sites compiling.
         readonly fallback?: ReactNode
       }
       
      -export default function Link(t0: Props) {
      -  const $ = _c(5)
      -
      -  const { children, url, fallback } = t0
      -
      +export default function Link({ children, url }: Props): React.ReactNode {
      +  // Always emit <ink-link>: the renderer stores `hyperlink` per cell in the
      +  // screen buffer, which the click dispatcher (Ink.getHyperlinkAt →
      +  // onHyperlinkClick) reads on mouseup to open URLs externally. Gating this
      +  // on supportsHyperlinks() broke clicks in Apple Terminal / any terminal
      +  // not on the OSC 8 allowlist — the cell's hyperlink field stayed empty,
      +  // so the click pipeline had nothing to open.
      +  //
      +  // The OSC 8 escape itself is emitted unconditionally by the renderer
      +  // (wrapWithOsc8Link in render-node-to-output.ts, oscLink in log-update.ts).
      +  // Terminals that don't understand OSC 8 silently strip it — including
      +  // Apple Terminal, which is why hover/click affordance has to come from
      +  // the in-process overlay (applyHyperlinkHoverHighlight) and not from the
      +  // terminal's own link rendering.
         const content = children ?? url
       
      -  if (supportsHyperlinks()) {
      -    let t1
      -
      -    if ($[0] !== content || $[1] !== url) {
      -      t1 = (
      -        <Text>
      -          <ink-link href={url}>{content}</ink-link>
      -        </Text>
      -      )
      -      $[0] = content
      -      $[1] = url
      -      $[2] = t1
      -    } else {
      -      t1 = $[2]
      -    }
      -
      -    return t1
      -  }
      -
      -  const t1 = fallback ?? content
      -  let t2
      -
      -  if ($[3] !== t1) {
      -    t2 = <Text>{t1}</Text>
      -    $[3] = t1
      -    $[4] = t2
      -  } else {
      -    t2 = $[4]
      -  }
      -
      -  return t2
      +  return (
      +    <Text>
      +      <ink-link href={url}>{content}</ink-link>
      +    </Text>
      +  )
       }
      -//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdE5vZGUiLCJSZWFjdCIsInN1cHBvcnRzSHlwZXJsaW5rcyIsIlRleHQiLCJQcm9wcyIsImNoaWxkcmVuIiwidXJsIiwiZmFsbGJhY2siLCJMaW5rIiwidDAiLCIkIiwiX2MiLCJjb250ZW50IiwidDEiLCJ0MiJdLCJzb3VyY2VzIjpbIkxpbmsudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB0eXBlIHsgUmVhY3ROb2RlIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBzdXBwb3J0c0h5cGVybGlua3MgfSBmcm9tICcuLi9zdXBwb3J0cy1oeXBlcmxpbmtzLmpzJ1xuaW1wb3J0IFRleHQgZnJvbSAnLi9UZXh0LmpzJ1xuXG5leHBvcnQgdHlwZSBQcm9wcyA9IHtcbiAgcmVhZG9ubHkgY2hpbGRyZW4/OiBSZWFjdE5vZGVcbiAgcmVhZG9ubHkgdXJsOiBzdHJpbmdcbiAgcmVhZG9ubHkgZmFsbGJhY2s/OiBSZWFjdE5vZGVcbn1cblxuZXhwb3J0IGRlZmF1bHQgZnVuY3Rpb24gTGluayh7XG4gIGNoaWxkcmVuLFxuICB1cmwsXG4gIGZhbGxiYWNrLFxufTogUHJvcHMpOiBSZWFjdC5SZWFjdE5vZGUge1xuICAvLyBVc2UgY2hpbGRyZW4gaWYgcHJvdmlkZWQsIG90aGVyd2lzZSBkaXNwbGF5IHRoZSBVUkxcbiAgY29uc3QgY29udGVudCA9IGNoaWxkcmVuID8/IHVybFxuXG4gIGlmIChzdXBwb3J0c0h5cGVybGlua3MoKSkge1xuICAgIC8vIFdyYXAgaW4gVGV4dCB0byBlbnN1cmUgd2UncmUgaW4gYSB0ZXh0IGNvbnRleHRcbiAgICAvLyAoaW5rLWxpbmsgaXMgYSB0ZXh0IGVsZW1lbnQgbGlrZSBpbmstdGV4dClcbiAgICByZXR1cm4gKFxuICAgICAgPFRleHQ+XG4gICAgICAgIDxpbmstbGluayBocmVmPXt1cmx9Pntjb250ZW50fTwvaW5rLWxpbms+XG4gICAgICA8L1RleHQ+XG4gICAgKVxuICB9XG5cbiAgcmV0dXJuIDxUZXh0PntmYWxsYmFjayA/PyBjb250ZW50fTwvVGV4dD5cbn1cbiJdLCJtYXBwaW5ncyI6IjtBQUFBLGNBQWNBLFNBQVMsUUFBUSxPQUFPO0FBQ3RDLE9BQU9DLEtBQUssTUFBTSxPQUFPO0FBQ3pCLFNBQVNDLGtCQUFrQixRQUFRLDJCQUEyQjtBQUM5RCxPQUFPQyxJQUFJLE1BQU0sV0FBVztBQUU1QixPQUFPLEtBQUtDLEtBQUssR0FBRztFQUNsQixTQUFTQyxRQUFRLENBQUMsRUFBRUwsU0FBUztFQUM3QixTQUFTTSxHQUFHLEVBQUUsTUFBTTtFQUNwQixTQUFTQyxRQUFRLENBQUMsRUFBRVAsU0FBUztBQUMvQixDQUFDO0FBRUQsZUFBZSxTQUFBUSxLQUFBQyxFQUFBO0VBQUEsTUFBQUMsQ0FBQSxHQUFBQyxFQUFBO0VBQWM7SUFBQU4sUUFBQTtJQUFBQyxHQUFBO0lBQUFDO0VBQUEsSUFBQUUsRUFJckI7RUFFTixNQUFBRyxPQUFBLEdBQWdCUCxRQUFlLElBQWZDLEdBQWU7RUFFL0IsSUFBSUosa0JBQWtCLENBQUMsQ0FBQztJQUFBLElBQUFXLEVBQUE7SUFBQSxJQUFBSCxDQUFBLFFBQUFFLE9BQUEsSUFBQUYsQ0FBQSxRQUFBSixHQUFBO01BSXBCTyxFQUFBLElBQUMsSUFBSSxDQUNILFNBQXlDLENBQXpCUCxJQUFHLENBQUhBLElBQUUsQ0FBQyxDQUFHTSxRQUFNLENBQUUsRUFBOUIsUUFBeUMsQ0FDM0MsRUFGQyxJQUFJLENBRUU7TUFBQUYsQ0FBQSxNQUFBRSxPQUFBO01BQUFGLENBQUEsTUFBQUosR0FBQTtNQUFBSSxDQUFBLE1BQUFHLEVBQUE7SUFBQTtNQUFBQSxFQUFBLEdBQUFILENBQUE7SUFBQTtJQUFBLE9BRlBHLEVBRU87RUFBQTtFQUlHLE1BQUFBLEVBQUEsR0FBQU4sUUFBbUIsSUFBbkJLLE9BQW1CO0VBQUEsSUFBQUUsRUFBQTtFQUFBLElBQUFKLENBQUEsUUFBQUcsRUFBQTtJQUExQkMsRUFBQSxJQUFDLElBQUksQ0FBRSxDQUFBRCxFQUFrQixDQUFFLEVBQTFCLElBQUksQ0FBNkI7SUFBQUgsQ0FBQSxNQUFBRyxFQUFBO0lBQUFILENBQUEsTUFBQUksRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUosQ0FBQTtFQUFBO0VBQUEsT0FBbENJLEVBQWtDO0FBQUEiLCJpZ25vcmVMaXN0IjpbXX0=
    • ui-tui/packages/hermes-ink/src/ink/hyperlinkHover.ts
      @@ -0,0 +1,52 @@
      +import { cellAtIndex, CellWidth, type Screen, setCellStyleId, type StylePool } from './screen.js'
      +
      +/**
      + * Highlight every cell whose OSC 8 hyperlink matches `hoveredUrl` by inverting
      + * its style. This is the cursor-hover affordance for clickable links: terminal
      + * applications can't change the system mouse cursor, so we light up the link
      + * itself when the pointer is over it. Same overlay machinery as
      + * applySearchHighlight — post-layout, pure SGR, picked up by the diff.
      + *
      + * Returns true if any cell was highlighted. The caller decides whether to
      + * promote that into a full-frame damage request — for hover specifically,
      + * full damage is only useful on enter/leave/change transitions (so the
      + * previous frame's inverted cells get re-emitted), not on every steady-state
      + * frame the pointer sits on the link.
      + */
      +export function applyHyperlinkHoverHighlight(
      +  screen: Screen,
      +  hoveredUrl: string | undefined,
      +  stylePool: StylePool
      +): boolean {
      +  if (!hoveredUrl) {
      +    return false
      +  }
      +
      +  const w = screen.width
      +  const height = screen.height
      +  let applied = false
      +
      +  for (let row = 0; row < height; row++) {
      +    const rowOff = row * w
      +
      +    for (let col = 0; col < w; col++) {
      +      const cell = cellAtIndex(screen, rowOff + col)
      +
      +      // Skip SpacerTail — the head cell at col-1 owns the hyperlink, and
      +      // setCellStyleId on the tail would split the styling of a wide-char
      +      // glyph mid-cell. The head's restyle covers both halves.
      +      if (cell.width === CellWidth.SpacerTail) {
      +        continue
      +      }
      +
      +      if (cell.hyperlink !== hoveredUrl) {
      +        continue
      +      }
      +
      +      applied = true
      +      setCellStyleId(screen, col, row, stylePool.withInverse(cell.styleId))
      +    }
      +  }
      +
      +  return applied
      +}
    • ui-tui/packages/hermes-ink/src/ink/ink.tsx
      @@ -24,6 +24,7 @@ import { KeyboardEvent } from './events/keyboard-event.js'
       import { FocusManager } from './focus.js'
       import { emptyFrame, type Frame, type FrameEvent } from './frame.js'
       import { dispatchClick, dispatchHover, dispatchMouse } from './hit-test.js'
      +import { applyHyperlinkHoverHighlight } from './hyperlinkHover.js'
       import instances from './instances.js'
       import { LogUpdate } from './log-update.js'
       import { nodeCache } from './node-cache.js'
      @@ -150,6 +151,21 @@ export type Options = {
         patchConsole: boolean
         waitUntilExit?: () => Promise<void>
         onFrame?: (event: FrameEvent) => void
      +  /**
      +   * Called when a click lands on a cell with an OSC 8 hyperlink (or a
      +   * plain-text URL detected by findPlainTextUrlAt). The host is responsible
      +   * for opening the URL — `child_process.spawn` with an argv array (NOT
      +   * shell-mode) to the platform's native opener: `open` on macOS,
      +   * `xdg-open` on Linux/BSD, `explorer.exe` on Windows. Avoid
      +   * `cmd.exe /c start` — `start` is a cmd builtin that reparses the URL
      +   * through cmd's tokenizer (`&` / `|` / `^` / `<` / `>` get split or
      +   * reinterpreted), which both breaks plain URLs with `&` in query
      +   * strings and undermines any caller-side protocol allowlist. Without
      +   * this wired up, links rendered by `<Link>` look underlined but do
      +   * nothing on click in any terminal where mouse tracking is on
      +   * (Cmd+click is consumed by the TUI, not Terminal.app).
      +   */
      +  onHyperlinkClick?: (url: string) => void
       }
       export default class Ink {
         private readonly log: LogUpdate
      @@ -232,6 +248,19 @@ export default class Ink {
         // so App.tsx's handleMouseEvent is stateless — dispatchHover diffs
         // against this set and mutates it in place.
         private readonly hoveredNodes = new Set<dom.DOMElement>()
      +
      +  // The OSC 8 hyperlink URL under the pointer, or undefined when the cursor
      +  // isn't on a link. Updated from dispatchHover; consumed by the render-pass
      +  // overlay (applyHyperlinkHoverHighlight) to invert link cells under the
      +  // pointer. This is the closest the TUI can get to the desktop's
      +  // cursor-changes-on-hover affordance — terminals don't expose cursor
      +  // shape control to applications.
      +  private hoveredHyperlink: string | undefined = undefined
      +
      +  // Last value of hoveredHyperlink that we actually painted. Compared in
      +  // onRender so we can scope full-screen damage to enter/leave/change
      +  // transitions, not every steady-state hover frame.
      +  private lastRenderedHoveredHyperlink: string | undefined = undefined
         // Set by <AlternateScreen> via setAltScreenActive(). Controls the
         // renderer's cursor.y clamping (keeps cursor in-viewport to avoid
         // LF-induced scroll when screen.height === terminalRows) and gates
      @@ -287,6 +316,14 @@ export default class Ink {
             this.restoreStderr = this.patchStderr()
           }
       
      +    // Host-supplied hyperlink-open callback. The mouse-event pipeline
      +    // (App.tsx → onOpenHyperlink → Ink.openHyperlink → onHyperlinkClick)
      +    // is fully wired internally; without this assignment the optional
      +    // chain in openHyperlink() bails silently and clicks on URLs do
      +    // nothing. The field stays writable so tests / debug overlays can
      +    // still rebind it after construction.
      +    this.onHyperlinkClick = options.onHyperlinkClick
      +
           this.terminal = {
             stdout: options.stdout,
             stderr: options.stderr
      @@ -769,6 +806,26 @@ export default class Ink {
             // Position-highlight (below) overlays CURRENT (yellow) on top.
             hlActive = applySearchHighlight(frame.screen, this.searchHighlightQuery, this.stylePool)
       
      +      // Hyperlink hover overlay: inverts every cell of the link currently
      +      // under the pointer. Cheap-ish (linear scan of the visible buffer),
      +      // only fires when hoveredHyperlink is set.
      +      //
      +      // hlActive controls full-screen damage (used by selection/search to
      +      // make sure the previous frame's inverted cells get re-diffed when
      +      // the highlight set changes). For hover, the *transition* is what
      +      // needs the full-damage hammer — enter / leave / change-to-other-link.
      +      // During steady-state hover the painted cells don't change and the
      +      // ordinary per-cell diff handles the no-op. Folding the steady-state
      +      // case into hlActive would burn full-screen diffs every frame while
      +      // the pointer just sits on the link.
      +      const hoverApplied = applyHyperlinkHoverHighlight(frame.screen, this.hoveredHyperlink, this.stylePool)
      +      const hoverTransition = this.hoveredHyperlink !== this.lastRenderedHoveredHyperlink
      +      this.lastRenderedHoveredHyperlink = this.hoveredHyperlink
      +
      +      if (hoverApplied && hoverTransition) {
      +        hlActive = true
      +      }
      +
             // Position-based CURRENT: write yellow at positions[currentIdx] +
             // rowOffset. No scanning — positions came from a prior scan when
             // the message first mounted. Message-relative + rowOffset = screen.
      @@ -1182,6 +1239,16 @@ export default class Ink {
           this.altScreenActive = active
           this.altScreenMouseTracking = active && mouseTracking
       
      +    // Hover state is alt-screen-scoped: dispatchHover is gated on
      +    // altScreenActive, so once we leave the alt screen there's no path to
      +    // clear it on our own. Without this reset, remounting <AlternateScreen>
      +    // would render a phantom hover highlight from the previous session
      +    // until the next mouse-move event arrived. Clear both the live value
      +    // and the last-rendered tracker so the next onRender sees no transition
      +    // and no overlay.
      +    this.hoveredHyperlink = undefined
      +    this.lastRenderedHoveredHyperlink = undefined
      +
           if (active) {
             this.resetFramesForAltScreen()
           } else {
      @@ -1770,6 +1837,34 @@ export default class Ink {
           }
       
           dispatchHover(this.rootNode, col, row, this.hoveredNodes)
      +
      +    // Hover affordance for hyperlinks: read the cell at the pointer, store
      +    // its URL (or clear when the pointer leaves a link), and request a
      +    // repaint when the value changes. The render-pass overlay paints the
      +    // highlight; we just track which URL is "hot".
      +    //
      +    // IMPORTANT: bypass getHyperlinkAt() here — its plain-text URL fallback
      +    // (findPlainTextUrlAt) would return URLs for cells whose `cell.hyperlink`
      +    // is undefined, which the overlay (applyHyperlinkHoverHighlight)
      +    // wouldn't match. That'd burn re-renders without ever producing an
      +    // affordance. Read the OSC 8 hyperlink directly off the cell so the
      +    // hover state is a 1:1 fit for what the overlay can paint. The
      +    // plain-text URL fallback still works for clicks; hover is a strictly
      +    // weaker signal and OK to skip on plain-text URLs.
      +    const screen = this.frontFrame.screen
      +    const cell = cellAt(screen, col, row)
      +    let next = cell?.hyperlink
      +
      +    // SpacerTail (second half of a wide-char / emoji glyph) stores the
      +    // hyperlink on the head cell at col-1. Same logic as getHyperlinkAt.
      +    if (!next && cell?.width === CellWidth.SpacerTail && col > 0) {
      +      next = cellAt(screen, col - 1, row)?.hyperlink
      +    }
      +
      +    if (next !== this.hoveredHyperlink) {
      +      this.hoveredHyperlink = next
      +      this.scheduleRender()
      +    }
         }
         dispatchKeyboardEvent(parsedKey: ParsedKey): void {
           const target = this.focusManager.activeElement ?? this.rootNode
      @@ -1814,8 +1909,13 @@ export default class Ink {
         }
       
         /**
      -   * Optional callback fired when clicking an OSC 8 hyperlink in fullscreen
      -   * mode. Set by FullscreenLayout via useLayoutEffect.
      +   * Optional callback fired when clicking a cell that has an associated URL
      +   * in fullscreen mode. `url` may be either an OSC 8 hyperlink (from a
      +   * `<Link>` render or external OSC 8 escape that landed in the buffer) or
      +   * a plain-text URL detected on the clicked row by findPlainTextUrlAt
      +   * (App.tsx routes both into the same callback). Set from the host via
      +   * the `onHyperlinkClick` Render/Ink option, or directly on the instance
      +   * for late-bound test scenarios.
          */
         onHyperlinkClick: ((url: string) => void) | undefined
       
    • ui-tui/packages/hermes-ink/src/ink/root.ts
      @@ -44,6 +44,22 @@ export type RenderOptions = {
          * Called after each frame render with timing and flicker information.
          */
         onFrame?: (event: FrameEvent) => void
      +
      +  /**
      +   * Called when a click lands on a cell with an OSC 8 hyperlink (or a
      +   * plain-text URL the renderer detects on the same row). The host owns
      +   * the actual open — `child_process.spawn` with an argv array (NOT
      +   * shell-mode) to the platform's native opener: `open` on macOS,
      +   * `xdg-open` on Linux/BSD, `explorer.exe` on Windows. Avoid
      +   * `cmd.exe /c start` — `start` is a cmd builtin that reparses the URL
      +   * through cmd's tokenizer (`&` / `|` / `^` / `<` / `>` get split or
      +   * reinterpreted as command syntax), which both breaks plain URLs with
      +   * `&` in query strings and undermines any protocol allowlist on the
      +   * caller side. Hermes wires this in `entry.tsx`; library users who
      +   * don't pass it will see clickable underline styling but no action on
      +   * click in any terminal where mouse tracking is on.
      +   */
      +  onHyperlinkClick?: (url: string) => void
       }
       
       export type Instance = {
      @@ -138,7 +154,8 @@ export async function createRoot({
         stderr = process.stderr,
         exitOnCtrlC = true,
         patchConsole = true,
      -  onFrame
      +  onFrame,
      +  onHyperlinkClick
       }: RenderOptions = {}): Promise<Root> {
         // See wrappedRender — preserve microtask boundary from the old WASM await.
         await Promise.resolve()
      @@ -149,7 +166,8 @@ export async function createRoot({
           stderr,
           exitOnCtrlC,
           patchConsole,
      -    onFrame
      +    onFrame,
      +    onHyperlinkClick
         })
       
         // Register in the instances map so that code that looks up the Ink
    • ui-tui/src/entry.tsx
      @@ -9,6 +9,7 @@ import { GatewayClient } from './gatewayClient.js'
       import { setupGracefulExit } from './lib/gracefulExit.js'
       import { formatBytes, type HeapDumpResult, performHeapDump } from './lib/memory.js'
       import { type MemorySnapshot, startMemoryMonitor } from './lib/memoryMonitor.js'
      +import { openExternalUrl } from './lib/openExternalUrl.js'
       import { resetTerminalModes } from './lib/terminalModes.js'
       
       if (!process.stdin.isTTY) {
      @@ -85,4 +86,14 @@ const onFrame =
             }
           : undefined
       
      -ink.render(<App gw={gw} />, { exitOnCtrlC: false, onFrame })
      +ink.render(<App gw={gw} />, {
      +  exitOnCtrlC: false,
      +  onFrame,
      +  // Open URLs in the user's default browser when a link cell is clicked.
      +  // The TUI's mouse tracking captures click events before Terminal.app's
      +  // own URL detection can fire, so without this hook clicks on `<Link>`
      +  // do nothing in any terminal where mouseTracking is on.
      +  onHyperlinkClick: url => {
      +    openExternalUrl(url)
      +  }
      +})
    • ui-tui/src/lib/openExternalUrl.test.ts
      @@ -0,0 +1,217 @@
      +import type { ChildProcess, spawn as SpawnFn } from 'node:child_process'
      +import { EventEmitter } from 'node:events'
      +
      +import { describe, expect, it, vi } from 'vitest'
      +
      +import { openCommand, openExternalUrl, parseSafeUrl } from './openExternalUrl.js'
      +
      +type SpawnLike = typeof SpawnFn
      +
      +describe('parseSafeUrl', () => {
      +  it('accepts http and https URLs', () => {
      +    expect(parseSafeUrl('https://example.com')?.href).toBe('https://example.com/')
      +    expect(parseSafeUrl('http://example.com/path?q=1')?.href).toBe('http://example.com/path?q=1')
      +  })
      +
      +  it('rejects file: URLs (would let a hostile model trigger arbitrary local handlers)', () => {
      +    expect(parseSafeUrl('file:///etc/passwd')).toBeNull()
      +  })
      +
      +  it('rejects javascript:, data:, and vbscript: URLs', () => {
      +    expect(parseSafeUrl('javascript:alert(1)')).toBeNull()
      +    expect(parseSafeUrl('data:text/html,<script>alert(1)</script>')).toBeNull()
      +    expect(parseSafeUrl('vbscript:msgbox')).toBeNull()
      +  })
      +
      +  it('rejects mailto:, ftp:, and other non-web protocols', () => {
      +    expect(parseSafeUrl('mailto:[redacted:email]')).toBeNull()
      +    expect(parseSafeUrl('ftp://example.com')).toBeNull()
      +    expect(parseSafeUrl('ssh://example.com')).toBeNull()
      +  })
      +
      +  it('rejects unparseable strings', () => {
      +    expect(parseSafeUrl('not a url')).toBeNull()
      +    expect(parseSafeUrl('')).toBeNull()
      +  })
      +
      +  it('rejects non-string inputs defensively', () => {
      +    expect(parseSafeUrl(undefined as unknown as string)).toBeNull()
      +    expect(parseSafeUrl(null as unknown as string)).toBeNull()
      +    expect(parseSafeUrl(123 as unknown as string)).toBeNull()
      +  })
      +})
      +
      +describe('openCommand', () => {
      +  it('returns macOS open(1) on darwin', () => {
      +    expect(openCommand('darwin')).toEqual({ command: 'open', args: [] })
      +  })
      +
      +  it('routes through explorer.exe on win32 — not cmd.exe — so URLs with & | ^ < > stay safe', () => {
      +    // win32 must not route through cmd.exe — see comment in openCommand.
      +    // Test pins the contract that we use explorer.exe (non-shell) so URLs
      +    // with `&`/`|`/`^`/`<`/`>` aren't reparsed by cmd's tokenizer.
      +    const cmd = openCommand('win32')
      +    expect(cmd?.command).toBe('explorer.exe')
      +    expect(cmd?.args).toEqual([])
      +  })
      +
      +  it('falls back to xdg-open on linux/bsd', () => {
      +    expect(openCommand('linux')).toEqual({ command: 'xdg-open', args: [] })
      +    expect(openCommand('freebsd')).toEqual({ command: 'xdg-open', args: [] })
      +    expect(openCommand('openbsd')).toEqual({ command: 'xdg-open', args: [] })
      +  })
      +
      +  it('returns null for unknown platforms (aix, sunos, cygwin, etc.)', () => {
      +    // Avoid optimistically dispatching xdg-open on platforms where it
      +    // probably isn't installed — the caller's `if (!command) return false`
      +    // path surfaces "no opener" honestly instead.
      +    expect(openCommand('aix')).toBeNull()
      +    expect(openCommand('sunos')).toBeNull()
      +    expect(openCommand('cygwin')).toBeNull()
      +    expect(openCommand('haiku')).toBeNull()
      +    expect(openCommand('')).toBeNull()
      +  })
      +})
      +
      +describe('openExternalUrl on unsupported platforms', () => {
      +  it('returns false without spawning when the platform has no known opener', () => {
      +    const spawn = vi.fn() as unknown as SpawnLike
      +
      +    expect(openExternalUrl('https://example.com/', { spawn, platform: () => 'aix' })).toBe(false)
      +    expect(spawn).not.toHaveBeenCalled()
      +  })
      +})
      +
      +describe('openExternalUrl', () => {
      +  // Tracks the most recent fake child so tests can inspect its 'error'
      +  // handlers and emit on it. Use a loose EventEmitter alias rather than
      +  // ChildProcess — the latter's `unref` signature is strictly `() => void`
      +  // and doesn't accept `vi.fn()` without a generic.
      +  type FakeChild = EventEmitter & { unref: () => void }
      +
      +  function mockSpawn(): {
      +    spawn: SpawnLike
      +    calls: Array<{ command: string; args: readonly string[] }>
      +    lastChild: () => FakeChild | undefined
      +  } {
      +    const calls: Array<{ command: string; args: readonly string[] }> = []
      +    let lastChild: FakeChild | undefined
      +
      +    const spawn = vi.fn((command: string, args: readonly string[]) => {
      +      calls.push({ command, args })
      +
      +      // Use a real EventEmitter so .once('error', cb) wires up correctly
      +      // and we can synthesize async failures by emitting 'error' from the
      +      // test. The cast is the same one Node uses internally — ChildProcess
      +      // extends EventEmitter.
      +      const child = new EventEmitter() as FakeChild
      +
      +      child.unref = () => {}
      +      lastChild = child
      +
      +      return child as unknown as ChildProcess
      +    }) as unknown as SpawnLike
      +
      +    return { spawn, calls, lastChild: () => lastChild }
      +  }
      +
      +  it('opens a normal https URL via the platform command', () => {
      +    const { spawn, calls } = mockSpawn()
      +
      +    expect(openExternalUrl('https://example.com/foo', { spawn, platform: () => 'darwin' })).toBe(true)
      +    expect(calls).toHaveLength(1)
      +    expect(calls[0]!.command).toBe('open')
      +    expect(calls[0]!.args).toEqual(['https://example.com/foo'])
      +  })
      +
      +  it('uses xdg-open on linux', () => {
      +    const { spawn, calls } = mockSpawn()
      +
      +    openExternalUrl('https://example.com/', { spawn, platform: () => 'linux' })
      +    expect(calls[0]!.command).toBe('xdg-open')
      +  })
      +
      +  it('refuses to open file: URLs and does not spawn', () => {
      +    const { spawn, calls } = mockSpawn()
      +
      +    expect(openExternalUrl('file:///etc/passwd', { spawn, platform: () => 'darwin' })).toBe(false)
      +    expect(calls).toHaveLength(0)
      +  })
      +
      +  it('refuses to open javascript: URLs and does not spawn', () => {
      +    const { spawn, calls } = mockSpawn()
      +
      +    expect(openExternalUrl('javascript:alert(1)', { spawn, platform: () => 'darwin' })).toBe(false)
      +    expect(calls).toHaveLength(0)
      +  })
      +
      +  it('passes URLs containing shell metacharacters as plain args (no shell interpolation)', () => {
      +    const { spawn, calls } = mockSpawn()
      +
      +    // A URL with `; & ` plus URL-encoded backticks. spawn(..., args) without
      +    // shell:true means the OS receives these as a single argv element.
      +    const hostile = 'https://example.com/path%3Bevil%20%26%20rm%20-rf'
      +
      +    openExternalUrl(hostile, { spawn, platform: () => 'darwin' })
      +    expect(calls).toHaveLength(1)
      +    expect(calls[0]!.args[calls[0]!.args.length - 1]).toBe(hostile)
      +  })
      +
      +  it('on win32, a URL with & | ^ < > is forwarded as a single argv element via explorer.exe', () => {
      +    const { spawn, calls } = mockSpawn()
      +
      +    // Plain http URL with & in query (very common, e.g. analytics params)
      +    // plus other cmd metacharacters that would split or reinterpret the
      +    // command if win32 routed through cmd.exe /c start. Note that the URL
      +    // parser percent-encodes `<` and `>` (which is fine — encoded forms
      +    // can't be reinterpreted by any shell), but `&`, `|`, `^` survive
      +    // and would tokenize cmd.exe if we ever regressed back to it.
      +    const meta = 'https://example.com/q?a=1&b=2|c^d<e>f'
      +
      +    expect(openExternalUrl(meta, { spawn, platform: () => 'win32' })).toBe(true)
      +    expect(calls).toHaveLength(1)
      +    expect(calls[0]!.command).toBe('explorer.exe')
      +    // The URL must arrive as exactly one argv element — not split on &/|/^/etc.
      +    const forwarded = calls[0]!.args[0]!
      +    expect(calls[0]!.args).toHaveLength(1)
      +    expect(forwarded).toContain('a=1&b=2')
      +    expect(forwarded).toContain('|c^d')
      +  })
      +
      +  it('on win32, common http URLs with & query params are forwarded intact', () => {
      +    const { spawn, calls } = mockSpawn()
      +    const url = 'https://example.com/search?q=foo&page=2&utm_source=hermes'
      +
      +    openExternalUrl(url, { spawn, platform: () => 'win32' })
      +    expect(calls[0]!.args).toEqual([url])
      +  })
      +
      +  it('returns false on synchronous spawn failure', () => {
      +    const spawn = vi.fn(() => {
      +      throw new Error('ENOENT')
      +    }) as unknown as SpawnLike
      +
      +    expect(openExternalUrl('https://example.com/', { spawn, platform: () => 'linux' })).toBe(false)
      +  })
      +
      +  it('does not crash the host when the spawned process emits an async error', () => {
      +    // Real-world case: `xdg-open` / `explorer.exe` missing on PATH. spawn()
      +    // returns a ChildProcess synchronously, then emits 'error' once the
      +    // exec actually fails. Without a registered 'error' listener, Node
      +    // re-throws the event as an uncaught exception → TUI dies. We attach
      +    // a no-op listener inside openExternalUrl; this test pins that contract.
      +    const { spawn, lastChild } = mockSpawn()
      +
      +    expect(openExternalUrl('https://example.com/', { spawn, platform: () => 'linux' })).toBe(true)
      +
      +    const child = lastChild()
      +    expect(child).toBeDefined()
      +    // Must have a listener registered BEFORE we emit, or EventEmitter will
      +    // throw synchronously here (which is exactly the crash we're preventing).
      +    expect(child!.listenerCount('error')).toBeGreaterThan(0)
      +
      +    // Emit and assert it doesn't throw. If the listener weren't attached,
      +    // this would throw 'Unhandled error' and fail the test.
      +    expect(() => child!.emit('error', new Error('ENOENT: xdg-open not found'))).not.toThrow()
      +  })
      +})
    • ui-tui/src/lib/openExternalUrl.ts
      @@ -0,0 +1,158 @@
      +import { spawn, type SpawnOptions } from 'node:child_process'
      +import { platform } from 'node:os'
      +
      +/**
      + * Opens an external URL in the user's default browser/handler.
      + *
      + * Wired into the Ink instance via `onHyperlinkClick` in entry.tsx, so any
      + * mouse click on a `<Link>` cell (or a row containing a plain-text URL the
      + * renderer detected) goes here. Mouse tracking inside the TUI prevents
      + * Terminal.app's native Cmd+click from firing — the click is captured
      + * before the terminal application sees it — so we have to handle the open
      + * ourselves.
      + *
      + * Safety:
      + * - http(s) only. Anything else (`file:`, `data:`, `javascript:`, etc.) is
      + *   rejected — a hostile model could otherwise emit `<Link url="file:///">`
      + *   and trick a click into running an arbitrary local handler.
      + * - Hostname is parsed via `URL`; only well-formed URLs are forwarded.
      + * - Spawned via `child_process.spawn` with arg array (no shell), so a URL
      + *   containing shell metacharacters (`;`, `&`, backticks) cannot be
      + *   interpreted as a command.
      + *
      + * Returns `true` if the spawn was attempted, `false` if the open could
      + * not proceed — covers (a) URL rejected by `parseSafeUrl` (non-http(s),
      + * malformed, etc.), (b) no known opener for the current platform
      + * (`openCommand` returned null), or (c) `spawn()` threw synchronously
      + * before the child was created. Async failures after spawn (`'error'`
      + * event because the binary couldn't exec) still return `true` because
      + * the spawn was attempted — the no-op error listener absorbs the event
      + * so the TUI doesn't crash, and the user just doesn't see their browser
      + * pop.
      + */
      +export function openExternalUrl(rawUrl: string, dependencies: OpenDependencies = {}): boolean {
      +  const url = parseSafeUrl(rawUrl)
      +
      +  if (!url) {
      +    return false
      +  }
      +
      +  const spawnFn = dependencies.spawn ?? spawn
      +  const platformId = dependencies.platform?.() ?? platform()
      +
      +  const command = openCommand(platformId)
      +
      +  if (!command) {
      +    return false
      +  }
      +
      +  try {
      +    const child = spawnFn(command.command, [...command.args, url.toString()], {
      +      // Detach so closing the TUI later doesn't kill the browser process,
      +      // and ignore stdio so we don't leak FDs into our raw-mode terminal.
      +      // Without `ignore` here, Chrome's stderr can land in the alt screen.
      +      detached: true,
      +      stdio: 'ignore'
      +    } satisfies SpawnOptions)
      +
      +    // Async failure path: spawn returns a ChildProcess synchronously even
      +    // when the binary is missing (ENOENT on `xdg-open` / `explorer.exe`),
      +    // unreachable (EACCES), or otherwise unusable — the failure surfaces
      +    // later as an 'error' event. Without a handler, an unhandled 'error'
      +    // on an EventEmitter crashes Node, which would tear down the whole
      +    // TUI. Attach a no-op listener BEFORE unref() so the event has a
      +    // consumer; we already returned `true` synchronously, so the user
      +    // just won't see their browser open — same as if the URL had been
      +    // rejected upstream.
      +    child.once('error', () => {
      +      // Intentional no-op. The TUI keeps running; user gets no browser
      +      // pop, which is the failure mode we promised in the doc comment.
      +    })
      +
      +    child.unref()
      +
      +    return true
      +  } catch {
      +    // spawn can also throw synchronously on argv-validation failures
      +    // (e.g. NUL in the path). Treat it as a no-op rather than crashing.
      +    return false
      +  }
      +}
      +
      +export type OpenDependencies = {
      +  spawn?: typeof spawn
      +  platform?: () => string
      +}
      +
      +/**
      + * Validate and normalize a URL for opening externally.
      + * Exported for testing.
      + */
      +export function parseSafeUrl(value: string): null | URL {
      +  if (!value || typeof value !== 'string') {
      +    return null
      +  }
      +
      +  let parsed: URL
      +
      +  try {
      +    parsed = new URL(value)
      +  } catch {
      +    return null
      +  }
      +
      +  // http(s) only — opening file://, data:, javascript:, vbscript:, etc.
      +  // would let a malicious model run a local handler with attacker-controlled
      +  // input on a single click.
      +  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
      +    return null
      +  }
      +
      +  // Reject empty or all-whitespace hostnames defensively. URL parsing
      +  // accepts URLs like 'http:///foo' on some Node versions; we don't want
      +  // to forward those to `open`.
      +  if (!parsed.hostname.trim()) {
      +    return null
      +  }
      +
      +  return parsed
      +}
      +
      +type OpenCommand = { command: string; args: readonly string[] }
      +
      +/**
      + * Per-platform open command. We deliberately avoid `cmd.exe /c start` on
      + * Windows even though it's the canonical example, because `start` is a cmd
      + * builtin: the URL string is reparsed by cmd's command-line tokenizer and
      + * characters like `&`, `|`, `^`, `<`, `>` either break the command or get
      + * interpreted as additional commands. That undermines the protocol
      + * allowlist's safety story and also breaks plain http(s) URLs with `&` in
      + * query strings. `explorer.exe <url>` is the safe, non-shell alternative —
      + * it invokes the registered protocol handler for http(s) without going
      + * through cmd. Linux/BSD use `xdg-open` directly with no shell wrapping.
      + *
      + * Returns null for platforms where we don't know a safe opener (e.g. `aix`,
      + * `sunos`, `cygwin`). The caller's `if (!command) return false` path then
      + * surfaces "no opener" instead of optimistically trying `xdg-open` on a
      + * platform that probably doesn't have it.
      + */
      +export function openCommand(platformId: string): OpenCommand | null {
      +  if (platformId === 'darwin') {
      +    return { command: 'open', args: [] }
      +  }
      +
      +  if (platformId === 'win32') {
      +    return { command: 'explorer.exe', args: [] }
      +  }
      +
      +  // Linux + the BSD family ship xdg-open via xdg-utils. Everything else
      +  // (aix, sunos, cygwin, haiku, etc.) returns null so openExternalUrl's
      +  // command-not-found fallback fires honestly.
      +  const XDG_OPEN_PLATFORMS = new Set(['linux', 'freebsd', 'openbsd', 'netbsd', 'dragonfly'])
      +
      +  if (XDG_OPEN_PLATFORMS.has(platformId)) {
      +    return { command: 'xdg-open', args: [] }
      +  }
      +
      +  return null
      +}
    • ui-tui/src/types/hermes-ink.d.ts
      @@ -66,6 +66,7 @@ declare module '@hermes/ink' {
           readonly exitOnCtrlC?: boolean
           readonly patchConsole?: boolean
           readonly onFrame?: (event: FrameEvent) => void
      +    readonly onHyperlinkClick?: (url: string) => void
         }
       
         export type Instance = {

1 PR closed without merging

From the close comments: done elsewhere 1

  • #23103 fix(tui): detect Ghostty hyperlinks via TERM=xterm-ghostty (#22895) · done elsewhere → 08671d8, #25071
Closed issues · 1
  • Closed#22895Terminal tool output URLs are not clickable in Ghostty (Cmd+Click)
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
hermes cli
Merged PRs
#25071
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
13 May 2026

For your agent

The same facts, typed.