# NousResearch/hermes-agent · Clickable URL hyperlinks in terminal UI

## Open now

- 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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #25071. Verify it addresses your case.
- **Recommended:** read PR #25071 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member 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.

## Closed / duplicate issues

- #22895: Terminal tool output URLs are not clickable in Ghostty (Cmd+Click)

## Prior & competing PRs

- **#25071: tui: make URLs clickable + hover-highlight in any terminal**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - 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`
  - fix hunks:
  - `ui-tui/packages/hermes-ink/src/ink/components/Link.tsx`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -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 {
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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 = {
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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) {
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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 = {
    ```
- **#23103: fix(tui): detect Ghostty hyperlinks via TERM=xterm-ghostty (#22895)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `08671d877108769e99ce649bd9ea93a861a0b19b`, `#25071`

## Fix status

- Merged to main in PR #25071 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-13`.
- Subsystem: `hermes_cli`
