From 5c8b291607e23eb11d5df70f560490bdd0b3dd6e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 14:39:24 -0500 Subject: [PATCH] fix(tui): wrap markdown links in Link so Ghostty/iTerm/kitty get real OSC 8 hyperlinks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderLink was discarding the URL entirely — it rendered the label as amber underlined text and dropped the href. Result: Cmd+Click / Ctrl+Click did nothing in any terminal, including Ghostty. Now both markdown links `[label](url)` and bare `https://…` URLs are wrapped in @hermes/ink's Link component, which emits OSC 8 (\\x1b]8;;url\\x07label\\x1b]8;;\\x07) when supportsHyperlinks() returns true. ADDITIONAL_HYPERLINK_TERMINALS already includes ghostty, iTerm2, kitty, alacritty, Hyper. Autolinks that look like bare emails (foo@bar.com) now prepend mailto: in the href so they open the mail client correctly. Also adds a typed declaration for Link in hermes-ink.d.ts. --- ui-tui/src/components/markdown.tsx | 30 +++++++++++++++++++----------- ui-tui/src/types/hermes-ink.d.ts | 5 +++++ 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index d43357b691..5e1063837b 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -1,4 +1,4 @@ -import { Box, Text } from '@hermes/ink' +import { Box, Link, Text } from '@hermes/ink' import { memo, type ReactNode, useMemo } from 'react' import { highlightLine, isHighlightable } from '../lib/syntax.js' @@ -23,10 +23,12 @@ type Fence = { len: number } -const renderLink = (key: number, t: Theme, label: string) => ( - - {label} - +const renderLink = (key: number, t: Theme, label: string, url: string) => ( + + + {label} + + ) const trimBareUrl = (value: string) => { @@ -38,11 +40,17 @@ const trimBareUrl = (value: string) => { } } -const renderAutolink = (key: number, t: Theme, raw: string) => ( - - {raw.replace(/^mailto:/, '')} - -) +const renderAutolink = (key: number, t: Theme, raw: string) => { + const url = raw.startsWith('mailto:') ? raw : raw.includes('@') && !raw.startsWith('http') ? `mailto:${raw}` : raw + + return ( + + + {raw.replace(/^mailto:/, '')} + + + ) +} const indentDepth = (indent: string) => Math.floor(indent.replace(/\t/g, ' ').length / 2) @@ -142,7 +150,7 @@ function MdInline({ t, text }: { t: Theme; text: string }) { ) } else if (m[4] && m[5]) { - parts.push(renderLink(parts.length, t, m[4])) + parts.push(renderLink(parts.length, t, m[4], m[5])) } else if (m[6]) { parts.push(renderAutolink(parts.length, t, m[6])) } else if (m[7]) { diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 9b2deec35f..051451d419 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -63,6 +63,11 @@ declare module '@hermes/ink' { export const Box: React.ComponentType export const AlternateScreen: React.ComponentType export const Ansi: React.ComponentType + export const Link: React.ComponentType<{ + readonly children?: React.ReactNode + readonly fallback?: React.ReactNode + readonly url: string + }> export const NoSelect: React.ComponentType export const ScrollBox: React.ComponentType export const Text: React.ComponentType