Skip to content

Make markdown links clickable in terminal (OSC 8 hyperlink support) #1754

@aheritier

Description

@aheritier

Problem

Links in markdown output are rendered with underline + color styling but are not clickable via Cmd+Click (macOS) or Ctrl+Click (Linux/Windows). The terminal has no way to know the underlined text represents a URL — it is purely visual.

Image

Desired behavior

Links should be Cmd+Click-able (or Ctrl+Click-able) in terminals that support it, opening the URL in the default browser.

Root cause

The fast markdown renderer applies only CSI SGR sequences (color, underline) to links. It never emits OSC 8 hyperlink escape sequences, which is the standard mechanism terminals use to make text clickable.

There are zero references to OSC 8 (\x1b]8;;) anywhere in the codebase.

Current link rendering (fast_renderer.go, lines 1718–1735)

For [text](url) where text ≠ url, we emit:

<ansiLinkText.prefix> linkText <ansiLinkText.suffix>   ← bold + color only
<space>
<ansiLink.prefix> (url) <ansiLink.suffix>              ← underline + color only

For [url](url) (text = url):

<ansiLink.prefix> linkText <ansiLink.suffix>            ← underline + color only

Neither case wraps the visible text with OSC 8 sequences.

What OSC 8 requires

To make text clickable, we need to wrap the visible portion with:

\x1b]8;;<URL>\x1b\\   <visible styled text>   \x1b]8;;\x1b\\

The \x1b]8;;<URL>\x1b\\ opens the hyperlink, the visible text is displayed (with whatever CSI styling we want), and \x1b]8;;\x1b\\ closes it.

Supported by: iTerm2, Terminal.app (macOS Sequoia+), GNOME Terminal, Windows Terminal, WezTerm, Alacritty, and others. Unsupported terminals simply ignore the sequences.

Technical analysis

1. Add a helper to emit OSC 8 sequences

A small helper function that wraps styled text in OSC 8:

const (
    osc8Open  = "\x1b]8;;"
    osc8Close = "\x1b\\"
)

func writeOSC8Link(out *strings.Builder, url, visibleText string, style ansiStyle) {
    out.WriteString(osc8Open)
    out.WriteString(url)
    out.WriteString(osc8Close)
    style.renderTo(out, visibleText)
    out.WriteString(osc8Open)
    out.WriteString(osc8Close)
}

2. Update renderInlineWithStyleTo link rendering (line ~1718)

Replace the direct ansiLinkText.renderTo / ansiLink.renderTo calls with writeOSC8Link, wrapping both the link text and the URL portion.

3. Update ANSI-aware utility functions

Several functions only handle CSI sequences (\x1b[...m) and would miscount OSC 8 sequences as visible characters:

Function Line What to change
ansiStringWidth() 2077 Skip OSC 8 sequences (\x1b]8;...ST) — they have zero visual width
splitWordsWithStyles() 2395 Track/skip OSC 8 sequences when splitting words, same as CSI
breakWord() 2484 Skip OSC 8 sequences when breaking long words at width boundaries
updateActiveStyles() 2469 OSC 8 open/close should not be tracked as "active styles" across line breaks (hyperlinks should not span wrapped lines)

The common pattern: when encountering \x1b], scan forward to the String Terminator (\x1b\\ or \x07) and skip the entire sequence for width calculations.

4. Update test stripANSI regex

The test helper regex (\x1b\[[0-9;]*m) only strips CSI sequences. It needs to also strip OSC 8 sequences to avoid test breakage:

var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m|\x1b]8;[^\x1b]*\x1b\\\\`)

5. Existing link test

TestFastRendererLinks (line ~341) already asserts on visible text content. It should be extended to verify the presence of OSC 8 sequences in the raw output.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/tuiFor features/issues/fixes related to the TUIkind/enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions