diff --git a/pkg/tui/components/markdown/fast_renderer.go b/pkg/tui/components/markdown/fast_renderer.go index 687c644d5..8e40dd07d 100644 --- a/pkg/tui/components/markdown/fast_renderer.go +++ b/pkg/tui/components/markdown/fast_renderer.go @@ -4,6 +4,7 @@ package markdown import ( "cmp" + "io" "slices" "strings" "sync" @@ -14,6 +15,7 @@ import ( "charm.land/lipgloss/v2" "github.com/alecthomas/chroma/v2" "github.com/alecthomas/chroma/v2/lexers" + xansi "github.com/charmbracelet/x/ansi" runewidth "github.com/mattn/go-runewidth" "github.com/docker/docker-agent/pkg/tui/styles" @@ -286,7 +288,7 @@ func (r *FastRenderer) Render(input string) (string, error) { p.reset(input, r.width) result := p.parse() parserPool.Put(p) - return padAllLines(result, r.width), nil + return padAllLines(fixHyperlinkWrapping(result), r.width), nil } // parser holds the state for parsing markdown. @@ -1536,6 +1538,49 @@ func isHorizontalRule(line string) bool { return count >= 3 } +// writeHyperlinkStart writes the OSC 8 opening sequence for a clickable hyperlink. +func writeHyperlinkStart(b *strings.Builder, url string) { + b.WriteString(xansi.SetHyperlink(url)) +} + +// writeHyperlinkEnd writes the OSC 8 closing sequence to end a hyperlink. +func writeHyperlinkEnd(b *strings.Builder) { + b.WriteString(xansi.ResetHyperlink()) +} + +// findURLEnd returns the length of a URL starting at the given position. +// It stops at whitespace, or certain trailing punctuation that is unlikely +// part of the URL (e.g., trailing period, comma, parenthesis if unmatched). +func findURLEnd(s string) int { + i := 0 + parenDepth := 0 + for i < len(s) { + c := s[i] + if c <= ' ' { + break + } + if c == '(' { + parenDepth++ + } else if c == ')' { + if parenDepth > 0 { + parenDepth-- + } else { + break + } + } + i++ + } + for i > 0 { + c := s[i-1] + if c == '.' || c == ',' || c == ';' || c == ':' || c == '!' || c == '?' { + i-- + } else { + break + } + } + return i +} + // renderInline processes inline markdown elements: bold, italic, code, links, etc. // It uses the document's base text style for restoring after styled elements. func (p *parser) renderInline(text string) string { @@ -1571,22 +1616,29 @@ func (p *parser) renderInlineWithStyleTo(out *strings.Builder, text string, rest return 0 } - // Fast path: check if text contains any markdown characters + // Fast path: check if text contains any markdown characters or URLs // If not, apply the restore style directly and return firstMarker := strings.IndexAny(text, inlineMarkdownChars) - if firstMarker == -1 { + firstURL := findFirstURL(text) + if firstMarker == -1 && firstURL == -1 { restoreStyle.renderTo(out, text) return textWidth(text) } + // Determine the first trigger position (marker or URL) + firstTrigger := firstMarker + if firstTrigger == -1 || (firstURL != -1 && firstURL < firstTrigger) { + firstTrigger = firstURL + } + width := 0 // Optimization: write any leading plain text in one batch - if firstMarker > 0 { - plain := text[:firstMarker] + if firstTrigger > 0 { + plain := text[:firstTrigger] restoreStyle.renderTo(out, plain) width += textWidth(plain) - text = text[firstMarker:] + text = text[firstTrigger:] } i := 0 @@ -1726,16 +1778,16 @@ func (p *parser) renderInlineWithStyleTo(out *strings.Builder, text string, rest if closeParen != -1 { url := rest[:closeParen] if linkText != url { + // Emit OSC 8 hyperlink wrapping styled link text + writeHyperlinkStart(out, url) p.styles.ansiLinkText.renderTo(out, linkText) - out.WriteByte(' ') - out.WriteString(p.styles.ansiLink.prefix) - out.WriteByte('(') - out.WriteString(url) - out.WriteByte(')') - out.WriteString(p.styles.ansiLink.suffix) - width += textWidth(linkText) + 1 + textWidth(url) + 2 // +1 for space, +2 for parens + writeHyperlinkEnd(out) + width += textWidth(linkText) } else { + // URL is the same as the text — emit clickable link with URL as text + writeHyperlinkStart(out, url) p.styles.ansiLink.renderTo(out, linkText) + writeHyperlinkEnd(out) width += textWidth(linkText) } i = i + closeBracket + 2 + closeParen + 1 @@ -1746,17 +1798,46 @@ func (p *parser) renderInlineWithStyleTo(out *strings.Builder, text string, rest default: // Regular character - collect consecutive plain text start := i + origStart := i // Track original start to detect no-progress for i < n && !isInlineMarker(text[i]) { + // Check for auto-link URLs + if (i+8 <= n && text[i:i+8] == "https://") || (i+7 <= n && text[i:i+7] == "http://") { + // First, emit any plain text before the URL + if i > start { + plainText := text[start:i] + restoreStyle.renderTo(out, plainText) + width += textWidth(plainText) + } + // Find URL boundaries, but don't extend past inline markdown markers. + // Use urlStopMarkdownChars (excludes _ and \ which are valid in URLs) + // to avoid splitting URLs like https://example.com/Thing_(foo). + remaining := text[i:] + if nextMarker := strings.IndexAny(remaining, urlStopMarkdownChars); nextMarker >= 0 { + remaining = remaining[:nextMarker] + } + urlLen := findURLEnd(remaining) + autoURL := text[i : i+urlLen] + // Emit OSC 8 hyperlink + writeHyperlinkStart(out, autoURL) + p.styles.ansiLink.renderTo(out, autoURL) + writeHyperlinkEnd(out) + width += textWidth(autoURL) + i += urlLen + start = i + continue + } i++ } - // If we didn't advance (started on an unmatched marker), consume it as literal - if i == start { + // If we didn't advance from the original position (unmatched marker), consume one char as literal + if i == origStart { i++ } - // Always apply restore style to plain text for consistent coloring - plainText := text[start:i] - restoreStyle.renderTo(out, plainText) - width += textWidth(plainText) + // Emit remaining plain text + if i > start && start < n { + plainText := text[start:i] + restoreStyle.renderTo(out, plainText) + width += textWidth(plainText) + } } } @@ -1837,6 +1918,25 @@ func isWord(b byte) bool { // inlineMarkdownChars contains all characters that trigger inline markdown processing. const inlineMarkdownChars = "\\`*_~[" +// urlStopMarkdownChars is the subset of inline markdown markers that should +// terminate auto-linked URL detection. Excludes _ and \\ because they appear +// frequently in valid URLs (e.g. https://example.com/Thing_(foo)). +const urlStopMarkdownChars = "`*~[" + +// findFirstURL returns the index of the first "https://" or "http://" in s, or -1. +func findFirstURL(s string) int { + if idx := strings.Index(s, "https://"); idx != -1 { + if httpIdx := strings.Index(s, "http://"); httpIdx != -1 && httpIdx < idx { + return httpIdx + } + return idx + } + if idx := strings.Index(s, "http://"); idx != -1 { + return idx + } + return -1 +} + // hasInlineMarkdown checks if text contains any markdown formatting characters. // This allows a fast path to skip processing plain text. // 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 if i > start { segment := text[start:i] segment = expandTabs(segment, lineWidth) - writeSegmentWrapped(segment, tok.style) + writeCodeSegmentsWithAutoLinks(segment, tok.style, &lineBuilder, writeSegmentWrapped) } flushLine() start = i + 1 @@ -2009,7 +2109,7 @@ func (p *parser) renderCodeBlockWithIndent(code, lang, indent string, availableW if start < len(text) { segment := text[start:] segment = expandTabs(segment, lineWidth) - writeSegmentWrapped(segment, tok.style) + writeCodeSegmentsWithAutoLinks(segment, tok.style, &lineBuilder, writeSegmentWrapped) } } @@ -2026,6 +2126,29 @@ func (p *parser) renderCodeBlockWithIndent(code, lang, indent string, availableW p.out.WriteByte('\n') } +// writeCodeSegmentsWithAutoLinks detects URLs in a code segment and wraps them +// in OSC 8 hyperlink sequences so they become clickable in the TUI. +// OSC 8 open/close are written directly to lineBuilder (not measured by writeSegment), +// and fixHyperlinkWrapping in Render() ensures sequences survive line wrapping. +func writeCodeSegmentsWithAutoLinks(segment string, style ansiStyle, lineBuilder *strings.Builder, writeSegment func(string, ansiStyle)) { + for segment != "" { + idx := findFirstURL(segment) + if idx < 0 { + writeSegment(segment, style) + return + } + if idx > 0 { + writeSegment(segment[:idx], style) + } + urlLen := findURLEnd(segment[idx:]) + url := segment[idx : idx+urlLen] + lineBuilder.WriteString(xansi.SetHyperlink(url)) + writeSegment(url, style) + lineBuilder.WriteString(xansi.ResetHyperlink()) + segment = segment[idx+urlLen:] + } +} + // spacesBuffer is a pre-allocated buffer of spaces for padding needs. // Slicing this is much faster than strings.Repeat for small amounts. const spacesBuffer = " " @@ -2093,6 +2216,22 @@ func ansiStringWidth(s string) int { } continue } + // Skip OSC sequences (e.g., \x1b]8;...;\x07 for hyperlinks) + if i+1 < len(s) && s[i+1] == ']' { + i += 2 + for i < len(s) { + if s[i] == '\x07' { + i++ + break + } + if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '\\' { + i += 2 + break + } + i++ + } + continue + } i++ continue } @@ -2111,6 +2250,23 @@ func ansiStringWidth(s string) int { return width } +// fixHyperlinkWrapping ensures that OSC 8 hyperlink sequences are properly +// closed before each newline and re-opened after, so that each terminal line +// is a self-contained clickable link. This is needed because wrapText/breakWord +// can split a long hyperlinked URL across multiple lines. +func fixHyperlinkWrapping(s string) string { + // Fast path: no hyperlinks, nothing to fix + if !strings.Contains(s, "\x1b]8;") { + return s + } + var buf strings.Builder + buf.Grow(len(s) + 128) // small overhead for extra OSC sequences + w := lipgloss.NewWrapWriter(&buf) + _, _ = io.WriteString(w, s) + _ = w.Close() + return buf.String() +} + // padAllLines pads each line to the target width with trailing spaces. func padAllLines(s string, width int) string { if width <= 0 || s == "" { @@ -2456,10 +2612,28 @@ func splitWordsWithStyles(text string) []styledWord { for i := 0; i < len(text); { if text[i] == '\x1b' { - // Start of ANSI sequence if wordStart == -1 { wordStart = i } + // Check for OSC sequence (\x1b]...) + if i+1 < len(text) && text[i+1] == ']' { + oscStart := i + i += 2 + for i < len(text) { + if text[i] == '\x07' { + i++ + break + } + if text[i] == '\x1b' && i+1 < len(text) && text[i+1] == '\\' { + i += 2 + break + } + i++ + } + currentAnsi = append(currentAnsi, text[oscStart:i]) + continue + } + // Start of CSI ANSI sequence inAnsi = true ansiStart = i i++ @@ -2515,12 +2689,13 @@ func splitWordsWithStyles(text string) []styledWord { // updateActiveStyles updates the list of active ANSI styles based on new codes func updateActiveStyles(active, newCodes []string) []string { for _, code := range newCodes { - // Check if this is a reset sequence + // Skip OSC sequences (hyperlinks) — they're self-contained, not carried across lines + if strings.HasPrefix(code, "\x1b]") { + continue + } if code == "\x1b[m" || code == "\x1b[0m" { - // Clear all active styles active = active[:0] } else { - // Add this style to active list active = append(active, code) } } @@ -2540,6 +2715,25 @@ func breakWord(word string, maxWidth int) []string { for i := 0; i < len(word); { if word[i] == '\x1b' { + // Check for OSC sequence + if i+1 < len(word) && word[i+1] == ']' { + oscStart := i + i += 2 + for i < len(word) { + if word[i] == '\x07' { + i++ + break + } + if word[i] == '\x1b' && i+1 < len(word) && word[i+1] == '\\' { + i += 2 + break + } + i++ + } + current.WriteString(word[oscStart:i]) + continue + } + // Existing CSI handling inAnsi = true ansiSeq.WriteByte(word[i]) i++ diff --git a/pkg/tui/components/markdown/fast_renderer_test.go b/pkg/tui/components/markdown/fast_renderer_test.go index 33038fbec..8c12b2495 100644 --- a/pkg/tui/components/markdown/fast_renderer_test.go +++ b/pkg/tui/components/markdown/fast_renderer_test.go @@ -12,11 +12,12 @@ import ( "github.com/stretchr/testify/require" ) -// stripANSI removes ANSI escape sequences from a string. +// ansiRegex matches CSI escape sequences (used by some tests to inspect sequences). var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`) +// stripANSI removes ANSI escape sequences (CSI and OSC) from a string. func stripANSI(s string) string { - return ansiRegex.ReplaceAllString(s, "") + return ansi.Strip(s) } func TestFastRendererBasicText(t *testing.T) { @@ -391,8 +392,13 @@ func TestFastRendererLinks(t *testing.T) { result, err := r.Render(input) require.NoError(t, err) plain := stripANSI(result) + // Link text should be visible assert.Contains(t, plain, "this link") - assert.Contains(t, plain, "example.com") + // URL should NOT appear as visible text (it's in OSC 8 sequence) + assert.NotContains(t, plain, "example.com") + // But the OSC 8 sequence should be present + assert.Contains(t, result, "\x1b]8;;https://example.com\x07") + assert.Contains(t, result, "\x1b]8;;\x07") } func TestFastRendererUnorderedLists(t *testing.T) { @@ -1708,6 +1714,199 @@ func splitIntoStreamingChunks(content string) []string { return chunks } +func TestFastRendererLinkOSC8(t *testing.T) { + t.Parallel() + input := "[Grafana](https://grafana.example.com/d/abc123?from=now-1h&to=now&var-host=prod-01)" + r := NewFastRenderer(80) + result, err := r.Render(input) + require.NoError(t, err) + + plain := stripANSI(result) + // Only link text visible, not the long URL + assert.Contains(t, plain, "Grafana") + assert.NotContains(t, plain, "grafana.example.com") + + // OSC 8 hyperlink wraps the text + assert.Contains(t, result, "\x1b]8;;https://grafana.example.com/d/abc123?from=now-1h&to=now&var-host=prod-01\x07") + assert.Contains(t, result, "\x1b]8;;\x07") +} + +func TestFastRendererLinkSameTextAndURL(t *testing.T) { + t.Parallel() + input := "[https://example.com](https://example.com)" + r := NewFastRenderer(80) + result, err := r.Render(input) + require.NoError(t, err) + + plain := stripANSI(result) + assert.Contains(t, plain, "https://example.com") + // OSC 8 should still be present + assert.Contains(t, result, "\x1b]8;;https://example.com\x07") +} + +func TestFastRendererAutoLinkURL(t *testing.T) { + t.Parallel() + input := "Visit https://example.com/page for details" + r := NewFastRenderer(80) + result, err := r.Render(input) + require.NoError(t, err) + + plain := stripANSI(result) + assert.Contains(t, plain, "https://example.com/page") + // URL should be wrapped in OSC 8 + assert.Contains(t, result, "\x1b]8;;https://example.com/page\x07") + assert.Contains(t, result, "\x1b]8;;\x07") +} + +func TestFastRendererAutoLinkTrailingPunctuation(t *testing.T) { + t.Parallel() + input := "Check https://example.com/page." + r := NewFastRenderer(80) + result, err := r.Render(input) + require.NoError(t, err) + + // The trailing period should NOT be part of the URL + assert.Contains(t, result, "\x1b]8;;https://example.com/page\x07") +} + +func TestFastRendererAutoLinkWithParens(t *testing.T) { + t.Parallel() + input := "See https://en.wikipedia.org/wiki/Thing_(disambiguation) for info" + r := NewFastRenderer(80) + result, err := r.Render(input) + require.NoError(t, err) + + // Parens in URL should be preserved when balanced + assert.Contains(t, result, "\x1b]8;;https://en.wikipedia.org/wiki/Thing_(disambiguation)\x07") +} + +func TestFastRendererAutoLinkAdjacentMarkdown(t *testing.T) { + t.Parallel() + // URL immediately followed by bold markdown — URL should stop before the * + input := "Visit https://example.com*important note*" + r := NewFastRenderer(80) + result, err := r.Render(input) + require.NoError(t, err) + + // The URL should be only https://example.com (not including *important note*) + assert.Contains(t, result, "\x1b]8;;https://example.com\x07") + // "important note" should be rendered as bold (between * markers), not as part of the URL + plain := stripANSI(result) + assert.Contains(t, plain, "important note") +} + +func TestFastRendererAutoLinkMinimalURL(t *testing.T) { + t.Parallel() + // Minimal valid-ish URL: "https://x" — should be detected + input := "https://x" + r := NewFastRenderer(80) + result, err := r.Render(input) + require.NoError(t, err) + assert.Contains(t, result, "\x1b]8;;https://x\x07") +} + +func TestFastRendererAutoLinkAtEndOfText(t *testing.T) { + t.Parallel() + // URL at the very end of the text with no trailing space or punctuation. + // This previously caused a slice bounds panic. + input := "Check this: https://example.com/path" + r := NewFastRenderer(80) + result, err := r.Render(input) + require.NoError(t, err) + + // Should contain the OSC 8 hyperlink sequence + assert.Contains(t, result, "\x1b]8;;https://example.com/path\x07") + assert.Contains(t, result, "\x1b]8;;\x07") + + // Visible text should contain the URL + plain := stripANSI(result) + assert.Contains(t, plain, "Check this:") + assert.Contains(t, plain, "https://example.com/path") +} + +func TestFastRendererLinkWidthCalculation(t *testing.T) { + t.Parallel() + // With OSC 8, the URL is invisible — only link text width counts + input := "[Go](https://very-long-url.example.com/path/to/page?query=value&other=thing)" + r := NewFastRenderer(40) + result, err := r.Render(input) + require.NoError(t, err) + + for line := range strings.SplitSeq(strings.TrimRight(result, "\n"), "\n") { + w := ansi.StringWidth(line) + assert.LessOrEqual(t, w, 40, "Line exceeds width: %q (width=%d)", line, w) + } +} + +func TestFastRendererAutoLinkLongURLWrapping(t *testing.T) { + t.Parallel() + longURL := "https://grafana.example.com/explore?left=%7B%22datasource%22%3A%22logs%22%2C%22queries%22%3A%5B%7B%22expr%22%3A%22%7Bservice%3D%5C%22test%5C%22%7D%22%7D%5D%7D" + r := NewFastRenderer(60) + result, err := r.Render(longURL) + require.NoError(t, err) + + lines := strings.Split(result, "\n") + for i, line := range lines { + stripped := ansi.Strip(line) + if strings.TrimSpace(stripped) == "" { + continue // skip empty/padding lines + } + hasOpen := strings.Contains(line, "\x1b]8;;http") + hasClose := strings.Contains(line, "\x1b]8;;\x07") + assert.True(t, hasOpen, "line %d should have OSC 8 open: %q", i, stripped) + assert.True(t, hasClose, "line %d should have OSC 8 close: %q", i, stripped) + } +} + +func TestFastRendererCodeBlockURL(t *testing.T) { + t.Parallel() + input := "```\nhttps://example.com/very/long/path\n```" + r := NewFastRenderer(80) + result, err := r.Render(input) + require.NoError(t, err) + // The URL in the code block should be wrapped in OSC 8 + assert.Contains(t, result, "\x1b]8;;https://example.com/very/long/path\x07") + assert.Contains(t, result, "\x1b]8;;\x07") +} + +func TestFastRendererCodeBlockLongURLWrapping(t *testing.T) { + t.Parallel() + longURL := "https://grafana.example.com/explore?left=%7B%22datasource%22%3A%22logs%22%2C%22queries%22%3A%5B%7B%22expr%22%3A%22%7Bservice%3D%5C%22test%5C%22%7D%22%7D%5D%7D" + input := "```\n" + longURL + "\n```" + r := NewFastRenderer(60) + result, err := r.Render(input) + require.NoError(t, err) + // Each non-empty content line should have OSC 8 open and close + lines := strings.Split(result, "\n") + for i, line := range lines { + stripped := ansi.Strip(line) + if strings.TrimSpace(stripped) == "" { + continue + } + // Lines containing URL text should have OSC 8 + if strings.Contains(stripped, "https://") || strings.Contains(stripped, "grafana") || strings.Contains(stripped, "%22") { + hasOpen := strings.Contains(line, "\x1b]8;;http") + hasClose := strings.Contains(line, "\x1b]8;;\x07") + assert.True(t, hasOpen, "line %d should have OSC 8 open: %q", i, stripped) + assert.True(t, hasClose, "line %d should have OSC 8 close: %q", i, stripped) + } + } +} + +func TestFastRendererCodeBlockMixedTextAndURL(t *testing.T) { + t.Parallel() + input := "```\nvisit https://example.com for details\n```" + r := NewFastRenderer(80) + result, err := r.Render(input) + require.NoError(t, err) + // The URL should be wrapped in OSC 8, but surrounding text should not + assert.Contains(t, result, "\x1b]8;;https://example.com\x07") + assert.Contains(t, result, "\x1b]8;;\x07") + plain := stripANSI(result) + assert.Contains(t, plain, "visit") + assert.Contains(t, plain, "for details") +} + // BenchmarkStreamingFastRenderer benchmarks rendering progressively growing markdown. // This simulates the streaming use case where content arrives in chunks and // the entire accumulated content is re-rendered on each update. diff --git a/pkg/tui/components/messages/urldetect.go b/pkg/tui/components/messages/urldetect.go index ee285bac1..d6b74b18b 100644 --- a/pkg/tui/components/messages/urldetect.go +++ b/pkg/tui/components/messages/urldetect.go @@ -2,6 +2,7 @@ package messages import ( "strings" + "unicode/utf8" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" @@ -20,13 +21,10 @@ type hoveredURL struct { // urlAtPosition extracts a URL from the rendered line at the given display column. // Returns the URL string if found, or empty string if the click position is not on a URL. func urlAtPosition(renderedLine string, col int) string { - plainLine := ansi.Strip(renderedLine) - if plainLine == "" { + if renderedLine == "" { return "" } - - // Find all URL spans in the plain text - for _, span := range findURLSpans(plainLine) { + for _, span := range findAllURLSpans(renderedLine) { if col >= span.startCol && col < span.endCol { return span.url } @@ -40,6 +38,182 @@ type urlSpan struct { endCol int // display column where URL ends (exclusive) } +// extractOSC8Links finds OSC 8 hyperlinks in a rendered line and returns +// their URL + display column positions. The display columns correspond to +// the visible text after stripping ANSI sequences. +func extractOSC8Links(renderedLine string) []urlSpan { + var spans []urlSpan + + displayCol := 0 + i := 0 + s := renderedLine + + for i < len(s) { + // Check for OSC 8 opening: \x1b]8;; or \x1b]8;params; + if i+4 < len(s) && s[i] == '\x1b' && s[i+1] == ']' && s[i+2] == '8' && s[i+3] == ';' { + j := i + 4 + // Skip params until next ';' + for j < len(s) && s[j] != ';' && s[j] != '\x07' { + j++ + } + if j < len(s) && s[j] == ';' { + j++ // skip the ';' + // Extract URL until BEL (\x07) or ST (\x1b\\) + urlStart := j + for j < len(s) { + if s[j] == '\x07' { + break + } + if s[j] == '\x1b' && j+1 < len(s) && s[j+1] == '\\' { + break + } + j++ + } + url := s[urlStart:j] + + // Skip the terminator + if j < len(s) && s[j] == '\x07' { + j++ + } else if j+1 < len(s) && s[j] == '\x1b' && s[j+1] == '\\' { + j += 2 + } + i = j + + // Empty URL means this is a reset/close — ignore + if url == "" { + continue + } + + // Read the visible text until we hit the closing OSC 8 reset + textStartCol := displayCol + for i < len(s) { + // Check for closing OSC 8: \x1b]8;;\x07 + if i+4 < len(s) && s[i] == '\x1b' && s[i+1] == ']' && s[i+2] == '8' && s[i+3] == ';' { + k := i + 4 + for k < len(s) && s[k] != ';' && s[k] != '\x07' { + k++ + } + if k < len(s) && s[k] == ';' { + k++ + } + // Skip until terminator + for k < len(s) { + if s[k] == '\x07' { + k++ + break + } + if s[k] == '\x1b' && k+1 < len(s) && s[k+1] == '\\' { + k += 2 + break + } + k++ + } + i = k + break + } + // Skip CSI sequences (\x1b[...) + if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '[' { + i += 2 + for i < len(s) && (s[i] < '@' || s[i] > '~') { + i++ + } + if i < len(s) { + i++ + } + continue + } + // Visible character + r, size := utf8.DecodeRuneInString(s[i:]) + displayCol += runewidth.RuneWidth(r) + i += size + } + + if url != "" && displayCol > textStartCol { + spans = append(spans, urlSpan{ + url: url, + startCol: textStartCol, + endCol: displayCol, + }) + } + continue + } + // Malformed OSC, skip + i = j + continue + } + + // Skip CSI sequences + if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '[' { + i += 2 + for i < len(s) && (s[i] < '@' || s[i] > '~') { + i++ + } + if i < len(s) { + i++ + } + continue + } + + // Skip other OSC sequences (non-hyperlink) + if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == ']' { + i += 2 + for i < len(s) { + if s[i] == '\x07' { + i++ + break + } + if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '\\' { + i += 2 + break + } + i++ + } + continue + } + + // Visible character — advance display column + r, size := utf8.DecodeRuneInString(s[i:]) + displayCol += runewidth.RuneWidth(r) + i += size + } + + return spans +} + +// findAllURLSpans finds all clickable URLs in a rendered line by combining: +// 1. OSC 8 hyperlinks (from invisible sequences in the rendered line) +// 2. Visible URLs (from plain text detection) +// OSC 8 links take priority when they overlap with visible URL spans. +func findAllURLSpans(renderedLine string) []urlSpan { + osc8Spans := extractOSC8Links(renderedLine) + plainLine := ansi.Strip(renderedLine) + visibleSpans := findURLSpans(plainLine) + + if len(osc8Spans) == 0 { + return visibleSpans + } + if len(visibleSpans) == 0 { + return osc8Spans + } + + // Merge: OSC 8 spans take priority. Remove visible spans that overlap. + merged := make([]urlSpan, 0, len(osc8Spans)+len(visibleSpans)) + merged = append(merged, osc8Spans...) + for _, vs := range visibleSpans { + overlaps := false + for _, os := range osc8Spans { + if vs.startCol < os.endCol && vs.endCol > os.startCol { + overlaps = true + break + } + } + if !overlaps { + merged = append(merged, vs) + } + } + return merged +} + // findURLSpans finds all URLs in plain text and returns their display column ranges. func findURLSpans(text string) []urlSpan { var spans []urlSpan @@ -144,8 +318,7 @@ func (m *model) updateHoveredURL(line, col int) { m.ensureAllItemsRendered() if line >= 0 && line < len(m.renderedLines) { - plainLine := ansi.Strip(m.renderedLines[line]) - for _, span := range findURLSpans(plainLine) { + for _, span := range findAllURLSpans(m.renderedLines[line]) { if col >= span.startCol && col < span.endCol { newHover := &hoveredURL{line: line, startCol: span.startCol, endCol: span.endCol} if m.hoveredURL == nil || *m.hoveredURL != *newHover { diff --git a/pkg/tui/components/messages/urldetect_test.go b/pkg/tui/components/messages/urldetect_test.go index d0ea09d73..8b39ce08b 100644 --- a/pkg/tui/components/messages/urldetect_test.go +++ b/pkg/tui/components/messages/urldetect_test.go @@ -165,6 +165,66 @@ func TestBalanceParens(t *testing.T) { } } +func TestExtractOSC8Links(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + wantURLs []string + wantCols [][2]int + }{ + { + name: "no links", + input: "hello world", + wantURLs: nil, + }, + { + name: "single OSC 8 link", + input: "click \x1b]8;;https://example.com\x07here\x1b]8;;\x07 please", + wantURLs: []string{"https://example.com"}, + wantCols: [][2]int{{6, 10}}, + }, + { + name: "OSC 8 link with ANSI styling inside", + input: "\x1b]8;;https://example.com\x07\x1b[1;34mStyled Link\x1b[0m\x1b]8;;\x07", + wantURLs: []string{"https://example.com"}, + wantCols: [][2]int{{0, 11}}, + }, + { + name: "multiple OSC 8 links", + input: "\x1b]8;;https://a.com\x07A\x1b]8;;\x07 and \x1b]8;;https://b.com\x07B\x1b]8;;\x07", + wantURLs: []string{"https://a.com", "https://b.com"}, + wantCols: [][2]int{{0, 1}, {6, 7}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := extractOSC8Links(tt.input) + assert.Equal(t, len(tt.wantURLs), len(got), "span count mismatch") + for i, span := range got { + assert.Equal(t, tt.wantURLs[i], span.url, "url mismatch at index %d", i) + assert.Equal(t, tt.wantCols[i][0], span.startCol, "startCol mismatch at index %d", i) + assert.Equal(t, tt.wantCols[i][1], span.endCol, "endCol mismatch at index %d", i) + } + }) + } +} + +func TestURLAtPositionOSC8(t *testing.T) { + t.Parallel() + // Simulates what the markdown renderer emits for [Grafana](https://grafana.example.com/...) + line := "check \x1b]8;;https://grafana.example.com/dashboard\x07\x1b[1;34mGrafana\x1b[0m\x1b]8;;\x07 link" + + // Clicking on "Grafana" text (cols 6-12) should return the URL + assert.Equal(t, urlAtPosition(line, 6), "https://grafana.example.com/dashboard") + assert.Equal(t, urlAtPosition(line, 10), "https://grafana.example.com/dashboard") + + // Clicking outside should return empty + assert.Equal(t, urlAtPosition(line, 0), "") + assert.Equal(t, urlAtPosition(line, 15), "") +} + func TestUnderlineLine(t *testing.T) { tests := []struct { name string