tui: make URLs clickable + hover-highlight in any terminal - #25071
Merged
Conversation
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
hermes --tui, ask for an answer with URLs<Box onClick>arrow buttons in the chrome worked fine.Root cause (two layers of dead plumbing)
1.
<Link>only emitted<ink-link>whensupportsHyperlinks()was true.On Apple_Terminal that returns false, so the per-cell
hyperlinkfield in the screen buffer stayed empty.Ink.getHyperlinkAt(col, row)had nothing to return on click. The visible underline was just decoration.2.
Ink.onHyperlinkClickwas declared but never assigned.The full pipeline existed:
A doc comment said "Set by FullscreenLayout via useLayoutEffect" — but
grep -rshows 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 (wrapWithOsc8Linkin render-node-to-output.ts,oscLinkin 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— addonHyperlinkClick?: (url: string) => voidtoOptions/RenderOptions, wire to the existingInk.onHyperlinkClickfield in the constructor.src/lib/openExternalUrl.ts— small platform-aware opener usingchild_process.spawnwith arg-array (no shell). http(s) only; rejectsfile:,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.openexplorer.exe(NOTcmd.exe /c start—startis 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)xdg-openentry.tsx— passonHyperlinkClick: openExternalUrltoink.render.hyperlinkHover.ts+ Ink hover wiring — track the URL under the pointer inInk.hoveredHyperlink, update fromdispatchHover, inverse-highlight every cell of the matching link in the render-pass overlay (same pattern asapplySearchHighlight). 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— addonHyperlinkClickto the hand-maintainedRenderOptionsshim so consumers type-check.Verification
npm run type-check— cleannpm run test— 715 passing, 0 type errors across the whole TUI suitenpm run build— cleanNew tests
src/lib/openExternalUrl.test.ts(17 cases) covers:open, Windowsexplorer.exe, Linuxxdg-open)&query params forwarded intact on win32Reverts
The earlier attempt in this branch that version-gated Apple_Terminal in
supports-hyperlinks.tswas 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
LC_TERMINAL.App.tsx(the existingprocess.env.TERM_PROGRAM !== 'vscode' && !isXtermJs()guard suppresses our open call so VS Code's own link-opener doesn't fire twice).Review history
cmd.exe /c startas effectively a shell, misleading comment inLink.tsxre: OSC 8 gating, and missing win32 regression test → addressed in046d226ce.