Skip to content

Commit 881a965

Browse files
feat(tui): make markdown links and URLs clickable in the terminal
Problem: Long URLs (e.g., Grafana dashboard links with encoded query parameters) were rendered as plain text in the TUI and were not clickable. Markdown links like [text](url) displayed both the link text AND the full URL, which cluttered the output. URLs inside code blocks that wrapped across multiple lines were impossible to click in any terminal. Solution: Emit OSC 8 hyperlink escape sequences around URLs so that terminals with native support (iTerm2, Kitty, WezTerm) render them as clickable links. For terminals without OSC 8 support (like Warp), extend the TUI's existing hover/click URL detection system to read OSC 8 sequences and resolve clicks on the visible link text to the hidden URL. Changes: Markdown renderer (fast_renderer.go): - [text](url) now emits OSC 8 sequences around the visible text instead of displaying the raw URL in parentheses - Bare URLs (https://...) in inline text are auto-detected and wrapped in OSC 8 sequences - URLs in fenced code blocks are detected and wrapped in OSC 8 - ansiStringWidth(), splitWordsWithStyles(), breakWord() updated to skip OSC sequences in width calculations and word splitting - updateActiveStyles() filters out OSC 8 sequences so hyperlinks are not incorrectly propagated across line wraps - fixHyperlinkWrapping() uses lipgloss.WrapWriter to ensure each wrapped line gets its own OSC 8 open/close pair URL detection (urldetect.go): - New extractOSC8Links() parses OSC 8 sequences from rendered lines and maps them to display column positions - New findAllURLSpans() merges OSC 8 links with visible URL detection, giving priority to OSC 8 spans on overlap - urlAtPosition() and updateHoveredURL() updated to use the combined detection, enabling hover underline and click-to-open for OSC 8 links Assisted-By: docker-agent
1 parent 3f8515c commit 881a965

4 files changed

Lines changed: 635 additions & 35 deletions

File tree

pkg/tui/components/markdown/fast_renderer.go

Lines changed: 208 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package markdown
44

55
import (
66
"cmp"
7+
"io"
78
"slices"
89
"strings"
910
"sync"
@@ -14,6 +15,7 @@ import (
1415
"charm.land/lipgloss/v2"
1516
"github.com/alecthomas/chroma/v2"
1617
"github.com/alecthomas/chroma/v2/lexers"
18+
xansi "github.com/charmbracelet/x/ansi"
1719
runewidth "github.com/mattn/go-runewidth"
1820

1921
"github.com/docker/docker-agent/pkg/tui/styles"
@@ -286,7 +288,7 @@ func (r *FastRenderer) Render(input string) (string, error) {
286288
p.reset(input, r.width)
287289
result := p.parse()
288290
parserPool.Put(p)
289-
return padAllLines(result, r.width), nil
291+
return padAllLines(fixHyperlinkWrapping(result), r.width), nil
290292
}
291293

292294
// parser holds the state for parsing markdown.
@@ -1536,6 +1538,49 @@ func isHorizontalRule(line string) bool {
15361538
return count >= 3
15371539
}
15381540

1541+
// writeHyperlinkStart writes the OSC 8 opening sequence for a clickable hyperlink.
1542+
func writeHyperlinkStart(b *strings.Builder, url string) {
1543+
b.WriteString(xansi.SetHyperlink(url))
1544+
}
1545+
1546+
// writeHyperlinkEnd writes the OSC 8 closing sequence to end a hyperlink.
1547+
func writeHyperlinkEnd(b *strings.Builder) {
1548+
b.WriteString(xansi.ResetHyperlink())
1549+
}
1550+
1551+
// findURLEnd returns the length of a URL starting at the given position.
1552+
// It stops at whitespace, or certain trailing punctuation that is unlikely
1553+
// part of the URL (e.g., trailing period, comma, parenthesis if unmatched).
1554+
func findURLEnd(s string) int {
1555+
i := 0
1556+
parenDepth := 0
1557+
for i < len(s) {
1558+
c := s[i]
1559+
if c <= ' ' {
1560+
break
1561+
}
1562+
if c == '(' {
1563+
parenDepth++
1564+
} else if c == ')' {
1565+
if parenDepth > 0 {
1566+
parenDepth--
1567+
} else {
1568+
break
1569+
}
1570+
}
1571+
i++
1572+
}
1573+
for i > 0 {
1574+
c := s[i-1]
1575+
if c == '.' || c == ',' || c == ';' || c == ':' || c == '!' || c == '?' {
1576+
i--
1577+
} else {
1578+
break
1579+
}
1580+
}
1581+
return i
1582+
}
1583+
15391584
// renderInline processes inline markdown elements: bold, italic, code, links, etc.
15401585
// It uses the document's base text style for restoring after styled elements.
15411586
func (p *parser) renderInline(text string) string {
@@ -1571,22 +1616,29 @@ func (p *parser) renderInlineWithStyleTo(out *strings.Builder, text string, rest
15711616
return 0
15721617
}
15731618

1574-
// Fast path: check if text contains any markdown characters
1619+
// Fast path: check if text contains any markdown characters or URLs
15751620
// If not, apply the restore style directly and return
15761621
firstMarker := strings.IndexAny(text, inlineMarkdownChars)
1577-
if firstMarker == -1 {
1622+
firstURL := findFirstURL(text)
1623+
if firstMarker == -1 && firstURL == -1 {
15781624
restoreStyle.renderTo(out, text)
15791625
return textWidth(text)
15801626
}
15811627

1628+
// Determine the first trigger position (marker or URL)
1629+
firstTrigger := firstMarker
1630+
if firstTrigger == -1 || (firstURL != -1 && firstURL < firstTrigger) {
1631+
firstTrigger = firstURL
1632+
}
1633+
15821634
width := 0
15831635

15841636
// Optimization: write any leading plain text in one batch
1585-
if firstMarker > 0 {
1586-
plain := text[:firstMarker]
1637+
if firstTrigger > 0 {
1638+
plain := text[:firstTrigger]
15871639
restoreStyle.renderTo(out, plain)
15881640
width += textWidth(plain)
1589-
text = text[firstMarker:]
1641+
text = text[firstTrigger:]
15901642
}
15911643

15921644
i := 0
@@ -1726,16 +1778,16 @@ func (p *parser) renderInlineWithStyleTo(out *strings.Builder, text string, rest
17261778
if closeParen != -1 {
17271779
url := rest[:closeParen]
17281780
if linkText != url {
1781+
// Emit OSC 8 hyperlink wrapping styled link text
1782+
writeHyperlinkStart(out, url)
17291783
p.styles.ansiLinkText.renderTo(out, linkText)
1730-
out.WriteByte(' ')
1731-
out.WriteString(p.styles.ansiLink.prefix)
1732-
out.WriteByte('(')
1733-
out.WriteString(url)
1734-
out.WriteByte(')')
1735-
out.WriteString(p.styles.ansiLink.suffix)
1736-
width += textWidth(linkText) + 1 + textWidth(url) + 2 // +1 for space, +2 for parens
1784+
writeHyperlinkEnd(out)
1785+
width += textWidth(linkText)
17371786
} else {
1787+
// URL is the same as the text — emit clickable link with URL as text
1788+
writeHyperlinkStart(out, url)
17381789
p.styles.ansiLink.renderTo(out, linkText)
1790+
writeHyperlinkEnd(out)
17391791
width += textWidth(linkText)
17401792
}
17411793
i = i + closeBracket + 2 + closeParen + 1
@@ -1746,17 +1798,40 @@ func (p *parser) renderInlineWithStyleTo(out *strings.Builder, text string, rest
17461798
default:
17471799
// Regular character - collect consecutive plain text
17481800
start := i
1801+
origStart := i // Track original start to detect no-progress
17491802
for i < n && !isInlineMarker(text[i]) {
1803+
// Check for auto-link URLs
1804+
if (i+8 <= n && text[i:i+8] == "https://") || (i+7 <= n && text[i:i+7] == "http://") {
1805+
// First, emit any plain text before the URL
1806+
if i > start {
1807+
plainText := text[start:i]
1808+
restoreStyle.renderTo(out, plainText)
1809+
width += textWidth(plainText)
1810+
}
1811+
// Find URL boundaries
1812+
urlLen := findURLEnd(text[i:])
1813+
autoURL := text[i : i+urlLen]
1814+
// Emit OSC 8 hyperlink
1815+
writeHyperlinkStart(out, autoURL)
1816+
p.styles.ansiLink.renderTo(out, autoURL)
1817+
writeHyperlinkEnd(out)
1818+
width += textWidth(autoURL)
1819+
i += urlLen
1820+
start = i
1821+
continue
1822+
}
17501823
i++
17511824
}
1752-
// If we didn't advance (started on an unmatched marker), consume it as literal
1753-
if i == start {
1825+
// If we didn't advance from the original position (unmatched marker), consume one char as literal
1826+
if i == origStart {
17541827
i++
17551828
}
1756-
// Always apply restore style to plain text for consistent coloring
1757-
plainText := text[start:i]
1758-
restoreStyle.renderTo(out, plainText)
1759-
width += textWidth(plainText)
1829+
// Emit remaining plain text
1830+
if i > start && start < n {
1831+
plainText := text[start:i]
1832+
restoreStyle.renderTo(out, plainText)
1833+
width += textWidth(plainText)
1834+
}
17601835
}
17611836
}
17621837

@@ -1837,6 +1912,20 @@ func isWord(b byte) bool {
18371912
// inlineMarkdownChars contains all characters that trigger inline markdown processing.
18381913
const inlineMarkdownChars = "\\`*_~["
18391914

1915+
// findFirstURL returns the index of the first "https://" or "http://" in s, or -1.
1916+
func findFirstURL(s string) int {
1917+
if idx := strings.Index(s, "https://"); idx != -1 {
1918+
if httpIdx := strings.Index(s, "http://"); httpIdx != -1 && httpIdx < idx {
1919+
return httpIdx
1920+
}
1921+
return idx
1922+
}
1923+
if idx := strings.Index(s, "http://"); idx != -1 {
1924+
return idx
1925+
}
1926+
return -1
1927+
}
1928+
18401929
// hasInlineMarkdown checks if text contains any markdown formatting characters.
18411930
// This allows a fast path to skip processing plain text.
18421931
// Uses strings.ContainsAny which is highly optimized in the Go standard library.
@@ -1999,7 +2088,7 @@ func (p *parser) renderCodeBlockWithIndent(code, lang, indent string, availableW
19992088
if i > start {
20002089
segment := text[start:i]
20012090
segment = expandTabs(segment, lineWidth)
2002-
writeSegmentWrapped(segment, tok.style)
2091+
writeCodeSegmentsWithAutoLinks(segment, tok.style, &lineBuilder, writeSegmentWrapped)
20032092
}
20042093
flushLine()
20052094
start = i + 1
@@ -2009,7 +2098,7 @@ func (p *parser) renderCodeBlockWithIndent(code, lang, indent string, availableW
20092098
if start < len(text) {
20102099
segment := text[start:]
20112100
segment = expandTabs(segment, lineWidth)
2012-
writeSegmentWrapped(segment, tok.style)
2101+
writeCodeSegmentsWithAutoLinks(segment, tok.style, &lineBuilder, writeSegmentWrapped)
20132102
}
20142103
}
20152104

@@ -2026,6 +2115,29 @@ func (p *parser) renderCodeBlockWithIndent(code, lang, indent string, availableW
20262115
p.out.WriteByte('\n')
20272116
}
20282117

2118+
// writeCodeSegmentsWithAutoLinks detects URLs in a code segment and wraps them
2119+
// in OSC 8 hyperlink sequences so they become clickable in the TUI.
2120+
// OSC 8 open/close are written directly to lineBuilder (not measured by writeSegment),
2121+
// and fixHyperlinkWrapping in Render() ensures sequences survive line wrapping.
2122+
func writeCodeSegmentsWithAutoLinks(segment string, style ansiStyle, lineBuilder *strings.Builder, writeSegment func(string, ansiStyle)) {
2123+
for segment != "" {
2124+
idx := findFirstURL(segment)
2125+
if idx < 0 {
2126+
writeSegment(segment, style)
2127+
return
2128+
}
2129+
if idx > 0 {
2130+
writeSegment(segment[:idx], style)
2131+
}
2132+
urlLen := findURLEnd(segment[idx:])
2133+
url := segment[idx : idx+urlLen]
2134+
lineBuilder.WriteString(xansi.SetHyperlink(url))
2135+
writeSegment(url, style)
2136+
lineBuilder.WriteString(xansi.ResetHyperlink())
2137+
segment = segment[idx+urlLen:]
2138+
}
2139+
}
2140+
20292141
// spacesBuffer is a pre-allocated buffer of spaces for padding needs.
20302142
// Slicing this is much faster than strings.Repeat for small amounts.
20312143
const spacesBuffer = " "
@@ -2093,6 +2205,22 @@ func ansiStringWidth(s string) int {
20932205
}
20942206
continue
20952207
}
2208+
// Skip OSC sequences (e.g., \x1b]8;...;\x07 for hyperlinks)
2209+
if i+1 < len(s) && s[i+1] == ']' {
2210+
i += 2
2211+
for i < len(s) {
2212+
if s[i] == '\x07' {
2213+
i++
2214+
break
2215+
}
2216+
if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '\\' {
2217+
i += 2
2218+
break
2219+
}
2220+
i++
2221+
}
2222+
continue
2223+
}
20962224
i++
20972225
continue
20982226
}
@@ -2111,6 +2239,23 @@ func ansiStringWidth(s string) int {
21112239
return width
21122240
}
21132241

2242+
// fixHyperlinkWrapping ensures that OSC 8 hyperlink sequences are properly
2243+
// closed before each newline and re-opened after, so that each terminal line
2244+
// is a self-contained clickable link. This is needed because wrapText/breakWord
2245+
// can split a long hyperlinked URL across multiple lines.
2246+
func fixHyperlinkWrapping(s string) string {
2247+
// Fast path: no hyperlinks, nothing to fix
2248+
if !strings.Contains(s, "\x1b]8;") {
2249+
return s
2250+
}
2251+
var buf strings.Builder
2252+
buf.Grow(len(s) + 128) // small overhead for extra OSC sequences
2253+
w := lipgloss.NewWrapWriter(&buf)
2254+
_, _ = io.WriteString(w, s)
2255+
_ = w.Close()
2256+
return buf.String()
2257+
}
2258+
21142259
// padAllLines pads each line to the target width with trailing spaces.
21152260
func padAllLines(s string, width int) string {
21162261
if width <= 0 || s == "" {
@@ -2456,10 +2601,28 @@ func splitWordsWithStyles(text string) []styledWord {
24562601

24572602
for i := 0; i < len(text); {
24582603
if text[i] == '\x1b' {
2459-
// Start of ANSI sequence
24602604
if wordStart == -1 {
24612605
wordStart = i
24622606
}
2607+
// Check for OSC sequence (\x1b]...)
2608+
if i+1 < len(text) && text[i+1] == ']' {
2609+
oscStart := i
2610+
i += 2
2611+
for i < len(text) {
2612+
if text[i] == '\x07' {
2613+
i++
2614+
break
2615+
}
2616+
if text[i] == '\x1b' && i+1 < len(text) && text[i+1] == '\\' {
2617+
i += 2
2618+
break
2619+
}
2620+
i++
2621+
}
2622+
currentAnsi = append(currentAnsi, text[oscStart:i])
2623+
continue
2624+
}
2625+
// Start of CSI ANSI sequence
24632626
inAnsi = true
24642627
ansiStart = i
24652628
i++
@@ -2515,12 +2678,13 @@ func splitWordsWithStyles(text string) []styledWord {
25152678
// updateActiveStyles updates the list of active ANSI styles based on new codes
25162679
func updateActiveStyles(active, newCodes []string) []string {
25172680
for _, code := range newCodes {
2518-
// Check if this is a reset sequence
2681+
// Skip OSC sequences (hyperlinks) — they're self-contained, not carried across lines
2682+
if strings.HasPrefix(code, "\x1b]") {
2683+
continue
2684+
}
25192685
if code == "\x1b[m" || code == "\x1b[0m" {
2520-
// Clear all active styles
25212686
active = active[:0]
25222687
} else {
2523-
// Add this style to active list
25242688
active = append(active, code)
25252689
}
25262690
}
@@ -2540,6 +2704,25 @@ func breakWord(word string, maxWidth int) []string {
25402704

25412705
for i := 0; i < len(word); {
25422706
if word[i] == '\x1b' {
2707+
// Check for OSC sequence
2708+
if i+1 < len(word) && word[i+1] == ']' {
2709+
oscStart := i
2710+
i += 2
2711+
for i < len(word) {
2712+
if word[i] == '\x07' {
2713+
i++
2714+
break
2715+
}
2716+
if word[i] == '\x1b' && i+1 < len(word) && word[i+1] == '\\' {
2717+
i += 2
2718+
break
2719+
}
2720+
i++
2721+
}
2722+
current.WriteString(word[oscStart:i])
2723+
continue
2724+
}
2725+
// Existing CSI handling
25432726
inAnsi = true
25442727
ansiSeq.WriteByte(word[i])
25452728
i++

0 commit comments

Comments
 (0)