@@ -4,6 +4,7 @@ package markdown
44
55import (
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.
15411586func (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.
18381919const 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.
20312154const 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.
21152271func 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
25162690func 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