Skip to content

Commit cadf778

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 cadf778

4 files changed

Lines changed: 661 additions & 35 deletions

File tree

pkg/tui/components/markdown/fast_renderer.go

Lines changed: 219 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,46 @@ 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, but don't extend past inline markdown markers.
1812+
// Use urlStopMarkdownChars (excludes _ and \ which are valid in URLs)
1813+
// to avoid splitting URLs like https://example.com/Thing_(foo).
1814+
remaining := text[i:]
1815+
if nextMarker := strings.IndexAny(remaining, urlStopMarkdownChars); nextMarker >= 0 {
1816+
remaining = remaining[:nextMarker]
1817+
}
1818+
urlLen := findURLEnd(remaining)
1819+
autoURL := text[i : i+urlLen]
1820+
// Emit OSC 8 hyperlink
1821+
writeHyperlinkStart(out, autoURL)
1822+
p.styles.ansiLink.renderTo(out, autoURL)
1823+
writeHyperlinkEnd(out)
1824+
width += textWidth(autoURL)
1825+
i += urlLen
1826+
start = i
1827+
continue
1828+
}
17501829
i++
17511830
}
1752-
// If we didn't advance (started on an unmatched marker), consume it as literal
1753-
if i == start {
1831+
// If we didn't advance from the original position (unmatched marker), consume one char as literal
1832+
if i == origStart {
17541833
i++
17551834
}
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)
1835+
// Emit remaining plain text
1836+
if i > start && start < n {
1837+
plainText := text[start:i]
1838+
restoreStyle.renderTo(out, plainText)
1839+
width += textWidth(plainText)
1840+
}
17601841
}
17611842
}
17621843

@@ -1837,6 +1918,25 @@ func isWord(b byte) bool {
18371918
// inlineMarkdownChars contains all characters that trigger inline markdown processing.
18381919
const inlineMarkdownChars = "\\`*_~["
18391920

1921+
// urlStopMarkdownChars is the subset of inline markdown markers that should
1922+
// terminate auto-linked URL detection. Excludes _ and \\ because they appear
1923+
// frequently in valid URLs (e.g. https://example.com/Thing_(foo)).
1924+
const urlStopMarkdownChars = "`*~["
1925+
1926+
// findFirstURL returns the index of the first "https://" or "http://" in s, or -1.
1927+
func findFirstURL(s string) int {
1928+
if idx := strings.Index(s, "https://"); idx != -1 {
1929+
if httpIdx := strings.Index(s, "http://"); httpIdx != -1 && httpIdx < idx {
1930+
return httpIdx
1931+
}
1932+
return idx
1933+
}
1934+
if idx := strings.Index(s, "http://"); idx != -1 {
1935+
return idx
1936+
}
1937+
return -1
1938+
}
1939+
18401940
// hasInlineMarkdown checks if text contains any markdown formatting characters.
18411941
// This allows a fast path to skip processing plain text.
18421942
// Uses strings.ContainsAny which is highly optimized in the Go standard library.
@@ -1999,7 +2099,7 @@ func (p *parser) renderCodeBlockWithIndent(code, lang, indent string, availableW
19992099
if i > start {
20002100
segment := text[start:i]
20012101
segment = expandTabs(segment, lineWidth)
2002-
writeSegmentWrapped(segment, tok.style)
2102+
writeCodeSegmentsWithAutoLinks(segment, tok.style, &lineBuilder, writeSegmentWrapped)
20032103
}
20042104
flushLine()
20052105
start = i + 1
@@ -2009,7 +2109,7 @@ func (p *parser) renderCodeBlockWithIndent(code, lang, indent string, availableW
20092109
if start < len(text) {
20102110
segment := text[start:]
20112111
segment = expandTabs(segment, lineWidth)
2012-
writeSegmentWrapped(segment, tok.style)
2112+
writeCodeSegmentsWithAutoLinks(segment, tok.style, &lineBuilder, writeSegmentWrapped)
20132113
}
20142114
}
20152115

@@ -2026,6 +2126,29 @@ func (p *parser) renderCodeBlockWithIndent(code, lang, indent string, availableW
20262126
p.out.WriteByte('\n')
20272127
}
20282128

2129+
// writeCodeSegmentsWithAutoLinks detects URLs in a code segment and wraps them
2130+
// in OSC 8 hyperlink sequences so they become clickable in the TUI.
2131+
// OSC 8 open/close are written directly to lineBuilder (not measured by writeSegment),
2132+
// and fixHyperlinkWrapping in Render() ensures sequences survive line wrapping.
2133+
func writeCodeSegmentsWithAutoLinks(segment string, style ansiStyle, lineBuilder *strings.Builder, writeSegment func(string, ansiStyle)) {
2134+
for segment != "" {
2135+
idx := findFirstURL(segment)
2136+
if idx < 0 {
2137+
writeSegment(segment, style)
2138+
return
2139+
}
2140+
if idx > 0 {
2141+
writeSegment(segment[:idx], style)
2142+
}
2143+
urlLen := findURLEnd(segment[idx:])
2144+
url := segment[idx : idx+urlLen]
2145+
lineBuilder.WriteString(xansi.SetHyperlink(url))
2146+
writeSegment(url, style)
2147+
lineBuilder.WriteString(xansi.ResetHyperlink())
2148+
segment = segment[idx+urlLen:]
2149+
}
2150+
}
2151+
20292152
// spacesBuffer is a pre-allocated buffer of spaces for padding needs.
20302153
// Slicing this is much faster than strings.Repeat for small amounts.
20312154
const spacesBuffer = " "
@@ -2093,6 +2216,22 @@ func ansiStringWidth(s string) int {
20932216
}
20942217
continue
20952218
}
2219+
// Skip OSC sequences (e.g., \x1b]8;...;\x07 for hyperlinks)
2220+
if i+1 < len(s) && s[i+1] == ']' {
2221+
i += 2
2222+
for i < len(s) {
2223+
if s[i] == '\x07' {
2224+
i++
2225+
break
2226+
}
2227+
if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '\\' {
2228+
i += 2
2229+
break
2230+
}
2231+
i++
2232+
}
2233+
continue
2234+
}
20962235
i++
20972236
continue
20982237
}
@@ -2111,6 +2250,23 @@ func ansiStringWidth(s string) int {
21112250
return width
21122251
}
21132252

2253+
// fixHyperlinkWrapping ensures that OSC 8 hyperlink sequences are properly
2254+
// closed before each newline and re-opened after, so that each terminal line
2255+
// is a self-contained clickable link. This is needed because wrapText/breakWord
2256+
// can split a long hyperlinked URL across multiple lines.
2257+
func fixHyperlinkWrapping(s string) string {
2258+
// Fast path: no hyperlinks, nothing to fix
2259+
if !strings.Contains(s, "\x1b]8;") {
2260+
return s
2261+
}
2262+
var buf strings.Builder
2263+
buf.Grow(len(s) + 128) // small overhead for extra OSC sequences
2264+
w := lipgloss.NewWrapWriter(&buf)
2265+
_, _ = io.WriteString(w, s)
2266+
_ = w.Close()
2267+
return buf.String()
2268+
}
2269+
21142270
// padAllLines pads each line to the target width with trailing spaces.
21152271
func padAllLines(s string, width int) string {
21162272
if width <= 0 || s == "" {
@@ -2456,10 +2612,28 @@ func splitWordsWithStyles(text string) []styledWord {
24562612

24572613
for i := 0; i < len(text); {
24582614
if text[i] == '\x1b' {
2459-
// Start of ANSI sequence
24602615
if wordStart == -1 {
24612616
wordStart = i
24622617
}
2618+
// Check for OSC sequence (\x1b]...)
2619+
if i+1 < len(text) && text[i+1] == ']' {
2620+
oscStart := i
2621+
i += 2
2622+
for i < len(text) {
2623+
if text[i] == '\x07' {
2624+
i++
2625+
break
2626+
}
2627+
if text[i] == '\x1b' && i+1 < len(text) && text[i+1] == '\\' {
2628+
i += 2
2629+
break
2630+
}
2631+
i++
2632+
}
2633+
currentAnsi = append(currentAnsi, text[oscStart:i])
2634+
continue
2635+
}
2636+
// Start of CSI ANSI sequence
24632637
inAnsi = true
24642638
ansiStart = i
24652639
i++
@@ -2515,12 +2689,13 @@ func splitWordsWithStyles(text string) []styledWord {
25152689
// updateActiveStyles updates the list of active ANSI styles based on new codes
25162690
func updateActiveStyles(active, newCodes []string) []string {
25172691
for _, code := range newCodes {
2518-
// Check if this is a reset sequence
2692+
// Skip OSC sequences (hyperlinks) — they're self-contained, not carried across lines
2693+
if strings.HasPrefix(code, "\x1b]") {
2694+
continue
2695+
}
25192696
if code == "\x1b[m" || code == "\x1b[0m" {
2520-
// Clear all active styles
25212697
active = active[:0]
25222698
} else {
2523-
// Add this style to active list
25242699
active = append(active, code)
25252700
}
25262701
}
@@ -2540,6 +2715,25 @@ func breakWord(word string, maxWidth int) []string {
25402715

25412716
for i := 0; i < len(word); {
25422717
if word[i] == '\x1b' {
2718+
// Check for OSC sequence
2719+
if i+1 < len(word) && word[i+1] == ']' {
2720+
oscStart := i
2721+
i += 2
2722+
for i < len(word) {
2723+
if word[i] == '\x07' {
2724+
i++
2725+
break
2726+
}
2727+
if word[i] == '\x1b' && i+1 < len(word) && word[i+1] == '\\' {
2728+
i += 2
2729+
break
2730+
}
2731+
i++
2732+
}
2733+
current.WriteString(word[oscStart:i])
2734+
continue
2735+
}
2736+
// Existing CSI handling
25432737
inAnsi = true
25442738
ansiSeq.WriteByte(word[i])
25452739
i++

0 commit comments

Comments
 (0)