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