@@ -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,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.
18381913const 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.
20312143const 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.
21152260func 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
25162679func 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