fix(tui): wrap markdown links in Link so Ghostty/iTerm/kitty get real OSC 8 hyperlinks

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.
This commit is contained in:
Brooklyn Nicholson 2026-04-18 14:39:24 -05:00
parent a7f4d756b7
commit 5c8b291607
2 changed files with 24 additions and 11 deletions

View file

@ -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) => (
<Text color={t.color.amber} key={key} underline>
{label}
</Text>
const renderLink = (key: number, t: Theme, label: string, url: string) => (
<Link key={key} url={url}>
<Text color={t.color.amber} underline>
{label}
</Text>
</Link>
)
const trimBareUrl = (value: string) => {
@ -38,11 +40,17 @@ const trimBareUrl = (value: string) => {
}
}
const renderAutolink = (key: number, t: Theme, raw: string) => (
<Text color={t.color.amber} key={key} underline>
{raw.replace(/^mailto:/, '')}
</Text>
)
const renderAutolink = (key: number, t: Theme, raw: string) => {
const url = raw.startsWith('mailto:') ? raw : raw.includes('@') && !raw.startsWith('http') ? `mailto:${raw}` : raw
return (
<Link key={key} url={url}>
<Text color={t.color.amber} underline>
{raw.replace(/^mailto:/, '')}
</Text>
</Link>
)
}
const indentDepth = (indent: string) => Math.floor(indent.replace(/\t/g, ' ').length / 2)
@ -142,7 +150,7 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
</Text>
)
} 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]) {