diff --git a/src/tui/osc8-hyperlinks.test.ts b/src/tui/osc8-hyperlinks.test.ts index 72be16c130ca..673c41ebfed4 100644 --- a/src/tui/osc8-hyperlinks.test.ts +++ b/src/tui/osc8-hyperlinks.test.ts @@ -47,6 +47,21 @@ describe("extractUrls", () => { const urls = extractUrls("https://example.com/path?q=1&r=2#section"); expect(urls).toEqual(["https://example.com/path?q=1&r=2#section"]); }); + + it("extracts markdown link hrefs with parentheses in the URL", () => { + const url = "https://en.wikipedia.org/wiki/URL_(disambiguation)"; + expect(extractUrls(`[Wikipedia](${url})`)).toEqual([url]); + }); + + it("handles bare URLs with trailing closing paren as punctuation", () => { + const urls = extractUrls("(see https://example.com/path)"); + expect(urls).toEqual(["https://example.com/path"]); + }); + + it("handles markdown link with angle brackets and parenthetical URL", () => { + const url = "https://en.wikipedia.org/wiki/Special_(film)"; + expect(extractUrls(`[link](<${url}>)`)).toEqual([url]); + }); }); describe("addOsc8Hyperlinks", () => { @@ -126,6 +141,13 @@ describe("addOsc8Hyperlinks", () => { expect(result[0]).toContain(`\x1b]8;;${long}\x07${fragment}\x1b]8;;\x07`); }); + it("wraps URLs with parentheses in markdown rendered text", () => { + const url = "https://en.wikipedia.org/wiki/URL_(disambiguation)"; + const line = `Wikipedia (${url})`; + const result = addOsc8Hyperlinks([line], [url]); + expect(result[0]).toBe(`Wikipedia (\x1b]8;;${url}\x07${url}\x1b]8;;\x07)`); + }); + it("handles URL split across three lines", () => { const fullUrl = "https://example.com/a/very/long/path/that/keeps/going/and/going"; const lines = ["https://example.com/a/very/lon", "g/path/that/keeps/going/and/g", "oing"]; diff --git a/src/tui/osc8-hyperlinks.ts b/src/tui/osc8-hyperlinks.ts index 35116e60505e..509896d144da 100644 --- a/src/tui/osc8-hyperlinks.ts +++ b/src/tui/osc8-hyperlinks.ts @@ -6,6 +6,37 @@ const ANSI_RE = new RegExp(`${SGR_PATTERN}|${OSC8_PATTERN}`, "g"); const SGR_START_RE = new RegExp(`^${SGR_PATTERN}`); const OSC8_START_RE = new RegExp(`^${OSC8_PATTERN}`); +/** Allow one level of balanced parentheses inside a URL so markdown link + * targets like `https://en.wikipedia.org/wiki/URL_(disambiguation)` are + * fully captured instead of truncated at the first `)`. */ +const URL_PATH_WITH_PARENS = /https?:\/\/[^()\s<>]*(?:\([^()\s<>]*\)[^()\s<>]*)*/g; + +/** Strip trailing `)` characters that don't have a matching `(` in the URL. + * Bare URLs in prose can pick up a trailing `)` that belongs to surrounding + * punctuation, e.g. `(see https://example.com/path)` — the `)` after `path` + * is sentence punctuation, not part of the URL. */ +function trimUnbalancedTrailingParens(url: string): string { + let open = 0; + for (const ch of url) { + if (ch === "(") { + open++; + } else if (ch === ")") { + open--; + } + } + // If close parens outnumber open parens, the trailing ones are not + // part of the URL — strip them. + if (open >= 0) { + return url; + } + let trimmed = url; + while (trimmed.endsWith(")") && open < 0) { + trimmed = trimmed.slice(0, -1); + open++; + } + return trimmed; +} + /** * Extract all unique URLs from raw markdown text. * Finds both bare URLs and markdown link hrefs [text](url). @@ -14,20 +45,20 @@ export function extractUrls(markdown: string): string[] { const urls = new Set(); // Markdown link hrefs: [text](url), with optional <...> and optional title. - const mdLinkRe = /\[(?:[^\]]*)\]\(\s*]+)>?(?:\s+["'][^"']*["'])?\s*\)/g; + const mdLinkRe = new RegExp( + `\\[(?:[^\\]]*)\\]\\(\\s*?(?:\\s+["'][^"']*["'])?\\s*\\)`, + "g", + ); let m: RegExpExecArray | null; while ((m = mdLinkRe.exec(markdown)) !== null) { urls.add(m[1]); } // Bare URLs (remove markdown links first to avoid double-matching) - const stripped = markdown.replace( - /\[(?:[^\]]*)\]\(\s*]+>?(?:\s+["'][^"']*["'])?\s*\)/g, - "", - ); - const bareRe = /https?:\/\/[^\s)\]>]+/g; + const stripped = markdown.replace(mdLinkRe, ""); + const bareRe = /https?:\/\/[^\s\]>]+/g; while ((m = bareRe.exec(stripped)) !== null) { - urls.add(m[0]); + urls.add(trimUnbalancedTrailingParens(m[0])); } return [...urls]; @@ -86,12 +117,12 @@ function findUrlRanges( } // Find new URL starts in visible text - const urlRe = /https?:\/\/[^\s)\]>]+/g; + const urlRe = /https?:\/\/[^\s\]>]+/g; urlRe.lastIndex = searchFrom; let match: RegExpExecArray | null; while ((match = urlRe.exec(visibleText)) !== null) { - const fragment = match[0]; + const fragment = trimUnbalancedTrailingParens(match[0]); const start = match.index; // Resolve fragment to a known URL (exact > prefix > superstring)