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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions