From b0b25c8027b4fe314c1d243c63366d5a7a61316b Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:33:42 +0800 Subject: [PATCH 1/3] fix(gui): exclude trailing Markdown emphasis delimiters from autolinked URLs (#197) Bare URLs wrapped in Markdown emphasis (**url**, *url*, ~~url~~) captured the closing delimiters in the link target, so Cmd-hover/Cmd-click on macOS underlined and tried to open an invalid destination like http://localhost:3003**. trimLinkCandidate now strips trailing * and ~ (the emphasis/strikethrough markers) alongside the punctuation and unmatched-closer trimming it already did, looping so interleaved runs like "...3003.**" are fully cleaned. Interior URL characters (http://a.com/x*y) and legitimately balanced parens (en.wikipedia.org/wiki/Foo_(bar)) are preserved. Tests: new chatLinks.test.ts (9 cases incl. the report's three, span end, and non-regression); full gui bun test 144/144; eslint clean. Refs #197 Co-Authored-By: blackfloofie <265516171+blackfloofie@users.noreply.github.com> --- .../components/chat/utils/chatLinks.test.ts | 56 +++++++++++++++++++ gui/src/components/chat/utils/chatLinks.ts | 45 +++++++++++---- 2 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 gui/src/components/chat/utils/chatLinks.test.ts diff --git a/gui/src/components/chat/utils/chatLinks.test.ts b/gui/src/components/chat/utils/chatLinks.test.ts new file mode 100644 index 00000000..8f2d9d1c --- /dev/null +++ b/gui/src/components/chat/utils/chatLinks.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test"; + +import { getChatLinkMatches } from "./chatLinks"; + +function firstUrl(text: string): string | undefined { + return getChatLinkMatches(text).find((match) => match.kind === "url")?.value; +} + +describe("getChatLinkMatches URL vs Markdown delimiters", () => { + test("strips trailing ** from a bold-wrapped bare URL", () => { + // Regression for #197: Cmd-hover underlined the closing ** and opened + // http://localhost:3003** instead of http://localhost:3003. + expect(firstUrl("**http://localhost:3003**")).toBe("http://localhost:3003"); + }); + + test("strips a single trailing * from an italic-wrapped URL", () => { + expect(firstUrl("*https://example.com*")).toBe("https://example.com"); + }); + + test("strips trailing ~~ from a strikethrough-wrapped URL", () => { + expect(firstUrl("~~https://example.com~~")).toBe("https://example.com"); + }); + + test("strips Markdown delimiters interleaved with trailing punctuation", () => { + expect(firstUrl("**http://localhost:3003.**")).toBe("http://localhost:3003"); + }); + + test("preserves a legitimate interior asterisk in the URL path", () => { + expect(firstUrl("see http://a.com/x*y here")).toBe("http://a.com/x*y"); + }); + + test("preserves a legitimate tilde in the URL path", () => { + expect(firstUrl("https://example.com/~user")).toBe("https://example.com/~user"); + }); + + test("still balances closing parens in a Wikipedia-style URL", () => { + expect(firstUrl("https://en.wikipedia.org/wiki/Foo_(bar)")).toBe( + "https://en.wikipedia.org/wiki/Foo_(bar)", + ); + }); + + test("still trims a wrapping paren and trailing sentence punctuation", () => { + expect(firstUrl("(https://example.com)")).toBe("https://example.com"); + expect(firstUrl("visit https://example.com.")).toBe("https://example.com"); + }); + + test("reports the correct span end after trimming the delimiters", () => { + const [match] = getChatLinkMatches("**http://localhost:3003**"); + expect(match).toMatchObject({ + kind: "url", + start: 2, + end: 2 + "http://localhost:3003".length, + value: "http://localhost:3003", + }); + }); +}); diff --git a/gui/src/components/chat/utils/chatLinks.ts b/gui/src/components/chat/utils/chatLinks.ts index 6d3d1df3..b0ef81cf 100644 --- a/gui/src/components/chat/utils/chatLinks.ts +++ b/gui/src/components/chat/utils/chatLinks.ts @@ -4,6 +4,7 @@ const FILE_PATH_PATTERN = const PATH_BOUNDARY_PATTERN = /[\s([{"'`]/u; const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/u; const TRAILING_CLOSERS = new Set([")", "]", "}", ">", "'", '"', "`"]); +const TRAILING_MARKDOWN_DELIMITERS = new Set(["*", "~"]); const FILE_BASENAME_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/u; const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/u; const WINDOWS_UNC_PATH_PATTERN = /^\\\\/u; @@ -58,24 +59,46 @@ export function isMailtoUrl(href: string): boolean { } function trimLinkCandidate(candidate: string): string { - let value = candidate.replace(TRAILING_PUNCTUATION_PATTERN, ""); + let value = candidate; + + let trimmed = true; + while (trimmed && value.length > 0) { + trimmed = false; + + const withoutPunctuation = value.replace(TRAILING_PUNCTUATION_PATTERN, ""); + if (withoutPunctuation.length !== value.length) { + value = withoutPunctuation; + trimmed = true; + continue; + } - while (value.length > 0) { const lastChar = value.at(-1); - if (lastChar == null || !TRAILING_CLOSERS.has(lastChar)) { + if (lastChar == null) { break; } - const opener = lastChar === ")" ? "(" : lastChar === "]" ? "[" : lastChar === "}" ? "{" : null; - if (opener != null) { - const openerCount = value.split(opener).length - 1; - const closerCount = value.split(lastChar).length - 1; - if (closerCount <= openerCount) { - break; - } + // Bare URLs/paths wrapped in Markdown emphasis (**url**, *url*, ~~url~~) capture the + // closing delimiters; strip trailing markers so the clickable target excludes them. + if (TRAILING_MARKDOWN_DELIMITERS.has(lastChar)) { + value = value.slice(0, -1); + trimmed = true; + continue; } - value = value.slice(0, -1); + if (TRAILING_CLOSERS.has(lastChar)) { + const opener = + lastChar === ")" ? "(" : lastChar === "]" ? "[" : lastChar === "}" ? "{" : null; + if (opener != null) { + const openerCount = value.split(opener).length - 1; + const closerCount = value.split(lastChar).length - 1; + if (closerCount <= openerCount) { + break; + } + } + + value = value.slice(0, -1); + trimmed = true; + } } return value; From ec467c898ea85163ab82ddffd5b3bf970905e3ca Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:38:52 +0800 Subject: [PATCH 2/3] test(gui): stacked/mixed emphasis + surrogate-safe trimming (#197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial coverage for trimLinkCandidate: stacked (`***`) and mixed (`~~*…*~~`) trailing emphasis strip fully, and a URL ending in a multi-byte glyph (accents, emoji) is preserved byte-for-byte — trimming only ever removes ASCII markers, so it can never cut a codepoint in half. Refs #197 Co-Authored-By: blackfloofie <265516171+blackfloofie@users.noreply.github.com> --- gui/src/components/chat/utils/chatLinks.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/gui/src/components/chat/utils/chatLinks.test.ts b/gui/src/components/chat/utils/chatLinks.test.ts index 8f2d9d1c..5ebcc19a 100644 --- a/gui/src/components/chat/utils/chatLinks.test.ts +++ b/gui/src/components/chat/utils/chatLinks.test.ts @@ -53,4 +53,17 @@ describe("getChatLinkMatches URL vs Markdown delimiters", () => { value: "http://localhost:3003", }); }); + + test("strips stacked and mixed trailing emphasis delimiters", () => { + expect(firstUrl("***https://example.com***")).toBe("https://example.com"); + expect(firstUrl("~~*https://example.com*~~")).toBe("https://example.com"); + }); + + test("never splits a multi-byte trailing character (surrogate-safe)", () => { + // Trimming only removes ASCII markers, so a URL that legitimately ends in a + // multi-byte glyph (accents, emoji) is preserved byte-for-byte, never cut + // mid-codepoint. + expect(firstUrl("**https://example.com/éé**")).toBe("https://example.com/éé"); + expect(firstUrl("see https://example.com/p\u{1F600}")).toBe("https://example.com/p\u{1F600}"); + }); }); From bb623af2f314cce2adccecea23d3e9b07a31d674 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:59:37 +0800 Subject: [PATCH 3/3] fix(gui): preserve valid trailing URL markers --- .../components/chat/utils/chatLinks.test.ts | 13 +++++- gui/src/components/chat/utils/chatLinks.ts | 40 ++++++++++++++----- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/gui/src/components/chat/utils/chatLinks.test.ts b/gui/src/components/chat/utils/chatLinks.test.ts index 5ebcc19a..7c93cd29 100644 --- a/gui/src/components/chat/utils/chatLinks.test.ts +++ b/gui/src/components/chat/utils/chatLinks.test.ts @@ -29,8 +29,19 @@ describe("getChatLinkMatches URL vs Markdown delimiters", () => { expect(firstUrl("see http://a.com/x*y here")).toBe("http://a.com/x*y"); }); - test("preserves a legitimate tilde in the URL path", () => { + test("preserves legitimate trailing asterisks on unwrapped URLs", () => { + expect(firstUrl("https://example.com/path*")).toBe("https://example.com/path*"); + expect(firstUrl("https://example.com/?q=*")).toBe("https://example.com/?q=*"); + }); + + test("preserves legitimate tildes in unwrapped URLs", () => { expect(firstUrl("https://example.com/~user")).toBe("https://example.com/~user"); + expect(firstUrl("https://example.com/~")).toBe("https://example.com/~"); + }); + + test("removes only the matching closing delimiter", () => { + expect(firstUrl("*https://example.com/path**")).toBe("https://example.com/path*"); + expect(firstUrl("~~https://example.com/path~~~")).toBe("https://example.com/path~"); }); test("still balances closing parens in a Wikipedia-style URL", () => { diff --git a/gui/src/components/chat/utils/chatLinks.ts b/gui/src/components/chat/utils/chatLinks.ts index b0ef81cf..1d8fdb84 100644 --- a/gui/src/components/chat/utils/chatLinks.ts +++ b/gui/src/components/chat/utils/chatLinks.ts @@ -4,7 +4,6 @@ const FILE_PATH_PATTERN = const PATH_BOUNDARY_PATTERN = /[\s([{"'`]/u; const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/u; const TRAILING_CLOSERS = new Set([")", "]", "}", ">", "'", '"', "`"]); -const TRAILING_MARKDOWN_DELIMITERS = new Set(["*", "~"]); const FILE_BASENAME_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/u; const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/u; const WINDOWS_UNC_PATH_PATTERN = /^\\\\/u; @@ -77,14 +76,6 @@ function trimLinkCandidate(candidate: string): string { break; } - // Bare URLs/paths wrapped in Markdown emphasis (**url**, *url*, ~~url~~) capture the - // closing delimiters; strip trailing markers so the clickable target excludes them. - if (TRAILING_MARKDOWN_DELIMITERS.has(lastChar)) { - value = value.slice(0, -1); - trimmed = true; - continue; - } - if (TRAILING_CLOSERS.has(lastChar)) { const opener = lastChar === ")" ? "(" : lastChar === "]" ? "[" : lastChar === "}" ? "{" : null; @@ -104,6 +95,35 @@ function trimLinkCandidate(candidate: string): string { return value; } +function markdownClosingDelimiterBefore(text: string, start: number): string | null { + const opening = text.slice(0, start).match(/[~*]+$/u)?.[0]; + if (opening == null) { + return null; + } + + for (let index = 0; index < opening.length; index += 1) { + if (opening[index] === "*") { + continue; + } + if (opening[index] !== "~" || opening[index + 1] !== "~") { + return null; + } + index += 1; + } + + return [...opening].reverse().join(""); +} + +function trimMarkdownWrappedLinkCandidate(text: string, start: number, candidate: string): string { + const value = trimLinkCandidate(candidate); + const closingDelimiter = markdownClosingDelimiterBefore(text, start); + if (closingDelimiter == null || !value.endsWith(closingDelimiter)) { + return value; + } + + return trimLinkCandidate(value.slice(0, -closingDelimiter.length)); +} + function safeDecode(value: string): string { try { return decodeURIComponent(value); @@ -425,7 +445,7 @@ export function getChatLinkMatches( URL_PATTERN.lastIndex = 0; let urlMatch: RegExpExecArray | null; while ((urlMatch = URL_PATTERN.exec(text)) !== null) { - const value = trimLinkCandidate(urlMatch[0]); + const value = trimMarkdownWrappedLinkCandidate(text, urlMatch.index, urlMatch[0]); if (value.length === 0) { continue; }