Skip to content

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

Merged
OutThisLife merged 6 commits into
mainfrom
bb/tui-clickable-links
May 13, 2026
Merged

tui: make URLs clickable + hover-highlight in any terminal#25071
OutThisLife merged 6 commits into
mainfrom
bb/tui-clickable-links

Conversation

@OutThisLife

@OutThisLife OutThisLife commented May 13, 2026

Copy link
Copy Markdown
Collaborator

What

Make URLs in the Hermes TUI actually clickable in any terminal — including basic macOS Terminal.app — and add a hover-highlight affordance so the user can see what's interactive without a system mouse cursor.

Repro (before)

  • macOS 15.7.3 Sequoia, default Terminal.app
  • hermes --tui, ask for an answer with URLs
  • Cmd+click on a URL → nothing. Cursor doesn't change. Like nothing was detected.
  • Yet <Box onClick> arrow buttons in the chrome worked fine.

Root cause (two layers of dead plumbing)

1. <Link> only emitted <ink-link> when supportsHyperlinks() was true.

On Apple_Terminal that returns false, so the per-cell hyperlink field in the screen buffer stayed empty. Ink.getHyperlinkAt(col, row) had nothing to return on click. The visible underline was just decoration.

2. Ink.onHyperlinkClick was declared but never assigned.

The full pipeline existed:

mouse-up → App.tsx → onOpenHyperlink → Ink.openHyperlink → this.onHyperlinkClick?.(url)
                                                                              ^^ undefined

A doc comment said "Set by FullscreenLayout via useLayoutEffect" — but grep -r shows zero callsites. Optional chain bailed silently → no-op.

Fix

  • Link.tsx — always emit <ink-link> regardless of terminal capability. OSC 8 escapes are emitted unconditionally by the renderer (wrapWithOsc8Link in render-node-to-output.ts, oscLink in log-update.ts); non-supporting terminals silently strip the escape, which is why hover/click affordance has to come from the in-process overlay rather than the terminal's own link rendering.

  • ink.tsx + root.ts — add onHyperlinkClick?: (url: string) => void to Options / RenderOptions, wire to the existing Ink.onHyperlinkClick field in the constructor.

  • src/lib/openExternalUrl.ts — small platform-aware opener using child_process.spawn with arg-array (no shell). http(s) only; rejects file:, javascript:, data:, mailto:, ftp:, ssh:, etc. so a hostile model can't trigger arbitrary local handlers via <Link url="file:///etc/passwd">. Detached + stdio: 'ignore' so closing the TUI doesn't kill the browser and Chrome stderr doesn't leak into the alt screen.

    • macOS: open
    • Windows: explorer.exe (NOT cmd.exe /c startstart is a cmd builtin that reparses the URL through cmd's tokenizer, so &, |, ^, <, > would split or reinterpret the command, both undermining safety and breaking plain http(s) URLs with & in query strings)
    • Linux/BSD: xdg-open
  • entry.tsx — pass onHyperlinkClick: openExternalUrl to ink.render.

  • hyperlinkHover.ts + Ink hover wiring — track the URL under the pointer in Ink.hoveredHyperlink, update from dispatchHover, inverse-highlight every cell of the matching link in the render-pass overlay (same pattern as applySearchHighlight). This is the cursor-hover affordance — terminals can't change the system mouse cursor shape, so we light up the link itself.

  • types/hermes-ink.d.ts — add onHyperlinkClick to the hand-maintained RenderOptions shim so consumers type-check.

Verification

  • npm run type-check — clean
  • npm run test715 passing, 0 type errors across the whole TUI suite
  • npm run build — clean
  • Empirical, in Apple Terminal 455.1 on macOS 15.7.3:
    • Click on a URL → opens in default browser ✓
    • Hover over a URL → link cells invert (highlighted) ✓
    • Move away → highlight clears ✓
    • Selection / arrow buttons / other Box onClick handlers — unchanged ✓

New tests

src/lib/openExternalUrl.test.ts (17 cases) covers:

  • http/https accepted; file/javascript/data/vbscript/mailto/ftp/ssh rejected
  • Per-platform command dispatch (macOS open, Windows explorer.exe, Linux xdg-open)
  • Shell-metacharacter URLs pass through as a single argv element (no interpolation) — both darwin and win32 paths
  • Common analytics-style http URLs with & query params forwarded intact on win32
  • Synchronous spawn failure (e.g. WSL with no opener on PATH) returns false instead of crashing

Reverts

The earlier attempt in this branch that version-gated Apple_Terminal in supports-hyperlinks.ts was based on a wrong assumption — Terminal.app silently strips OSC 8 sequences but does not render them as clickable hyperlinks. Reverted to the original allowlist.

Out of scope

  • Tmux passthrough — already handled upstream via LC_TERMINAL.
  • xterm.js terminals (VS Code / Cursor / Windsurf) — already handled in App.tsx (the existing process.env.TERM_PROGRAM !== 'vscode' && !isXtermJs() guard suppresses our open call so VS Code's own link-opener doesn't fire twice).

Review history

  • v1 (Copilot): flagged cmd.exe /c start as effectively a shell, misleading comment in Link.tsx re: OSC 8 gating, and missing win32 regression test → addressed in 046d226ce.

Loading
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/tui Terminal UI (ui-tui/ + tui_gateway/) P3 Low — cosmetic, nice to have type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants