fix(tui): harden OSC8 URL boundaries (#100780)

* test(tui): cover parenthetical link rendering

* fix(tui): trim unmatched URL parentheses

* fix(tui): reject incomplete URL ranges

* fix(tui): harden wrapped URL boundaries
This commit is contained in:
Peter Steinberger
2026-07-06 10:29:46 +01:00
committed by GitHub
parent 5000f324cb
commit 661cc82c6e
3 changed files with 136 additions and 27 deletions
@@ -34,4 +34,13 @@ describe("HyperlinkMarkdown", () => {
expect(normalized.some((line) => line.includes("is_palindrome"))).toBe(true);
expect(normalized.some((line) => line.includes("__init__"))).toBe(true);
});
it("links the complete parenthetical URL rendered by pi-tui", () => {
const url = "https://en.wikipedia.org/wiki/URL_(disambiguation)";
const markdown = new HyperlinkMarkdown(`[Wikipedia](${url})`, 0, 0, markdownTheme);
const rendered = markdown.render(120).join("\n");
expect(rendered).toContain(`\x1b]8;;${url}\x07${url}\x1b]8;;\x07`);
});
});
+66
View File
@@ -48,16 +48,37 @@ describe("extractUrls", () => {
expect(urls).toEqual(["https://example.com/path?q=1&r=2#section"]);
});
it("extracts a bare URL with a bracketed IPv6 authority", () => {
const url = "http://[::1]:8080/path";
expect(extractUrls(url)).toEqual([url]);
});
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("does not extract an incomplete markdown link destination", () => {
expect(extractUrls("[broken](https://)")).toEqual([]);
});
it.each(["[broken](https://.)", "https://.", "https:///path"])(
"does not extract a URL without a real authority from %s",
(text) => {
expect(extractUrls(text)).toEqual([]);
},
);
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("drops punctuation after an unmatched closing paren", () => {
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]);
@@ -92,6 +113,44 @@ describe("addOsc8Hyperlinks", () => {
expect(result[1]).toContain(`\x1b]8;;${fullUrl}\x07`);
});
it("wraps a URL with a bracketed IPv6 authority", () => {
const url = "http://[::1]:8080/path";
expect(addOsc8Hyperlinks([url], [url])).toEqual([`\x1b]8;;${url}\x07${url}\x1b]8;;\x07`]);
});
it("wraps a URL broken immediately after its scheme", () => {
const fullUrl = "https://example.com/path";
const result = addOsc8Hyperlinks(["https://", "example.com/path"], [fullUrl]);
expect(result[0]).toBe(`\x1b]8;;${fullUrl}\x07https://\x1b]8;;\x07`);
expect(result[1]).toBe(`\x1b]8;;${fullUrl}\x07example.com/path\x1b]8;;\x07`);
});
it("does not cross-link a scheme-only fragment to a partial domain match", () => {
const result = addOsc8Hyperlinks(["https://", "example.org"], ["https://example.com"]);
expect(result).toEqual(["https://", "example.org"]);
});
it("does not cross-link a scheme-only fragment to a longer URL token", () => {
const result = addOsc8Hyperlinks(
["https://", "example.com/pathology"],
["https://example.com/path"],
);
expect(result).toEqual(["https://", "example.com/pathology"]);
});
it("does not recover a punctuated incomplete URL as a wrapped URL", () => {
const result = addOsc8Hyperlinks(["broken (https://)", "example.com"], ["https://example.com"]);
expect(result).toEqual(["broken (https://)", "example.com"]);
});
it("does not wrap a punctuation-only URL body", () => {
expect(addOsc8Hyperlinks(["https://."], ["https://."])).toEqual(["https://."]);
});
it("handles URL with ANSI styling codes", () => {
const url = "https://example.com";
// Simulate styled text: green URL
@@ -148,6 +207,13 @@ describe("addOsc8Hyperlinks", () => {
expect(result[0]).toBe(`Wikipedia (\x1b]8;;${url}\x07${url}\x1b]8;;\x07)`);
});
it("does not resolve an incomplete URL to another known URL", () => {
const url = "https://example.com";
const result = addOsc8Hyperlinks(["broken (https://)", url], [url]);
expect(result[0]).toBe("broken (https://)");
expect(result[1]).toContain(`\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"];
+61 -27
View File
@@ -9,32 +9,31 @@ 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;
const URL_PATH_WITH_PARENS = /https?:\/\/[^()\s<>]+(?:\([^()\s<>]*\)[^()\s<>]*)*/g;
/** Strip trailing `)` characters that don't have a matching `(` in the URL.
/** Strip the suffix starting at a `)` without 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. */
* and anything after it are sentence punctuation, not part of the URL. */
function trimUnbalancedTrailingParens(url: string): string {
let open = 0;
for (const ch of url) {
for (let index = 0; index < url.length; index++) {
const ch = url[index];
if (ch === "(") {
open++;
} else if (ch === ")") {
if (open === 0) {
return url.slice(0, index);
}
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;
return url;
}
function hasUrlContent(url: string): boolean {
const authority = url.slice(url.indexOf("://") + 3).split(/[/?#]/, 1)[0];
return /[\p{L}\p{N}]/u.test(authority) || /^\[[0-9a-f:.]+\](?::\d+)?$/i.test(authority);
}
/**
@@ -51,14 +50,19 @@ export function extractUrls(markdown: string): string[] {
);
let m: RegExpExecArray | null;
while ((m = mdLinkRe.exec(markdown)) !== null) {
urls.add(m[1]);
if (hasUrlContent(m[1])) {
urls.add(m[1]);
}
}
// Bare URLs (remove markdown links first to avoid double-matching)
const stripped = markdown.replace(mdLinkRe, "");
const bareRe = /https?:\/\/[^\s\]>]+/g;
const bareRe = /https?:\/\/(?:\[[0-9a-f:.]+\](?::\d+)?[^\s\]>]*|[^\s[\]>]+)/gi;
while ((m = bareRe.exec(stripped)) !== null) {
urls.add(trimUnbalancedTrailingParens(m[0]));
const url = trimUnbalancedTrailingParens(m[0]);
if (hasUrlContent(url)) {
urls.add(url);
}
}
return [...urls];
@@ -82,6 +86,7 @@ function findUrlRanges(
visibleText: string,
knownUrls: string[],
pending: { url: string; consumed: number } | null,
nextVisibleText?: string,
): { ranges: UrlRange[]; pending: { url: string; consumed: number } | null } {
const ranges: UrlRange[] = [];
let newPending: { url: string; consumed: number } | null = null;
@@ -117,7 +122,7 @@ function findUrlRanges(
}
// Find new URL starts in visible text
const urlRe = /https?:\/\/[^\s\]>]+/g;
const urlRe = /https?:\/\/(?:\[[0-9a-f:.]+\](?::\d+)?[^\s\]>]*|[^\s[\]>]*)/gi;
urlRe.lastIndex = searchFrom;
let match: RegExpExecArray | null;
@@ -129,11 +134,40 @@ function findUrlRanges(
let resolvedUrl = fragment;
let found = false;
for (const known of knownUrls) {
if (known === fragment) {
resolvedUrl = known;
found = true;
break;
// A wrap may split immediately after the scheme. Only accept that fragment
// when the next line actually continues a known URL; otherwise a stray
// `https://` could inherit an unrelated target from the URL list.
if (!hasUrlContent(fragment)) {
const hasUnpunctuatedSchemeAtLineEnd =
fragment === match[0] && visibleText.slice(start + match[0].length).trim().length === 0;
if (!hasUnpunctuatedSchemeAtLineEnd) {
continue;
}
const nextToken = nextVisibleText?.trimStart().match(/^[^\s\]>]+/)?.[0] ?? "";
const nextFragment = trimUnbalancedTrailingParens(nextToken);
for (const known of knownUrls) {
if (!known.startsWith(fragment)) {
continue;
}
const remaining = known.slice(fragment.length);
const continuesKnownUrl = nextFragment.length > 0 && remaining.startsWith(nextFragment);
if (continuesKnownUrl && known.length > resolvedUrl.length) {
resolvedUrl = known;
found = true;
}
}
if (!found) {
continue;
}
}
if (!found) {
for (const known of knownUrls) {
if (known === fragment) {
resolvedUrl = known;
found = true;
break;
}
}
}
if (!found) {
@@ -247,10 +281,10 @@ export function addOsc8Hyperlinks(lines: string[], urls: string[]): string[] {
}
let pending: { url: string; consumed: number } | null = null;
const visibleLines = lines.map(stripAnsi);
return lines.map((line) => {
const visible = stripAnsi(line);
const result = findUrlRanges(visible, urls, pending);
return lines.map((line, index) => {
const result = findUrlRanges(visibleLines[index], urls, pending, visibleLines[index + 1]);
pending = result.pending;
return applyOsc8Ranges(line, result.ranges);
});