From 1d6744e63ef90463b118d7895a2fff3cbec162f7 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 28 Aug 2026 09:02:28 -0400 Subject: [PATCH] fix(jetbrains): linkify urls inside inline code CommonMark's autolink extension skips code spans, so a URL written in backticks rendered as inert text in chat messages while bare URLs became links. Extend the post-render pass in MdCommon that already linkifies file references so it also wraps http(s) URLs, giving them link color, underline, hover, and click handling inside inline code. --- .changeset/jetbrains-inline-code-urls.md | 5 ++ .../ai/kilocode/client/ui/md/MdCommon.kt | 64 +++++++++++++++-- .../kilocode/client/ui/md/MdViewHybridTest.kt | 21 ++++++ .../ai/kilocode/client/ui/md/MdViewTest.kt | 70 +++++++++++++++++++ 4 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 .changeset/jetbrains-inline-code-urls.md diff --git a/.changeset/jetbrains-inline-code-urls.md b/.changeset/jetbrains-inline-code-urls.md new file mode 100644 index 00000000000..e6accb7a366 --- /dev/null +++ b/.changeset/jetbrains-inline-code-urls.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Make `http`/`https` URLs written inside backticks clickable in chat messages. Previously only bare URLs became links, so URLs rendered as inline code — release links, PR links, run URLs — were inert text. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt index 779fb212354..e2eecef6137 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt @@ -19,7 +19,12 @@ internal object MdCommon { private val code = Regex("]*)?>(.*?)", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)) private val tag = Regex("<[^>]+>") private val ref = Regex("(?\"'`]+", RegexOption.IGNORE_CASE) private val single = setOf("readme.md", "package.json", "tsconfig.json", "jsconfig.json", "kilo.json", "kilo.jsonc") + // Entities the markdown renderer emits for characters that cannot appear in a URL; a match that + // runs into one of them is cut short so the link stops at the original delimiter. + private val entities = listOf("<", ">", """) + private const val TRAIL = ".,;:!?" private const val REF_SEGMENT_LIMIT = 16_384 val tags = listOf( @@ -36,7 +41,7 @@ internal object MdCommon { .replace("\r", " ") fun inlineCode(html: String, opts: MdStyle): String { - if (!html.contains('<') && !html.contains('.')) return html + if (!html.contains('<') && !scan(html)) return html val color = hex(opts.inlineCodeFg) val styled = if (html.contains(" REF_SEGMENT_LIMIT || !text.contains('.')) return text + if (text.length > REF_SEGMENT_LIMIT || !scan(text)) return text + if (!text.contains("://")) return files(text) + val out = StringBuilder() + var at = 0 + for (match in url.findAll(text)) { + out.append(files(text.substring(at, match.range.first))) + val link = cut(match.value) + at = match.range.first + link.length + // The renderer already escaped the source, so the matched text is a valid attribute value. + out.append("$link") + } + out.append(files(text.substring(at))) + return out.toString() + } + + private fun files(text: String): String { + if (!text.contains('.')) return text return ref.replace(text) { match -> val path = match.value if (!pathish(path)) return@replace path @@ -173,6 +200,35 @@ internal object MdCommon { } } + /** Trims a raw URL match down to the part that belongs to the link. */ + private fun cut(value: String): String { + var end = value.length + for (item in entities) { + val at = value.indexOf(item) + if (at in 0 until end) end = at + } + while (end > 0) { + val char = value[end - 1] + if (char in TRAIL) { + end-- + continue + } + val open = when (char) { + ')' -> '(' + ']' -> '[' + '}' -> '{' + else -> break + } + val head = value.substring(0, end) + if (head.count { it == open } >= head.count { it == char }) break + end-- + } + return value.substring(0, end) + } + + /** True when [text] can contain a file reference or a URL worth scanning for. */ + private fun scan(text: String): Boolean = text.contains('.') || text.contains("://") + private fun pathish(path: String): Boolean { if (path.contains('/')) return true val name = path.substringBefore(':') diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt index d5cde117805..802abbc3c10 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt @@ -344,6 +344,27 @@ class MdViewHybridTest : BasePlatformTestCase() { assertTrue(html.contains("href=\"native-plan-prompt.txt:37-38\">native-plan-prompt.txt:37-38.")) } + fun `test inline code url renders a live anchor that dispatches link events`() { + val received = mutableListOf() + view.addLinkListener { received.add(it) } + view.set("Release PR: `https://example.com/pull/13524`") + val pane = htmls().single() + val iter = (pane.document as HTMLDocument).getIterator(HTML.Tag.A) + + assertTrue("code span url must render as an anchor", iter.isValid) + assertEquals("https://example.com/pull/13524", iter.attributes.getAttribute(HTML.Attribute.HREF)) + + val event = HyperlinkEvent( + pane, + HyperlinkEvent.EventType.ACTIVATED, + URI("https://example.com/pull/13524").toURL(), + "https://example.com/pull/13524", + ) + pane.hyperlinkListeners.forEach { it.hyperlinkUpdate(event) } + + assertEquals("https://example.com/pull/13524", received.single().href) + } + fun `test existing links are not nested as file refs`() { view.set("[prompt](packages/opencode/src/session/prompt.ts)") val html = view.html() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt index f55b0188ea2..e310bcf99db 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt @@ -166,6 +166,76 @@ class MdViewTest : BasePlatformTestCase() { assertTrue(view.html().contains("https://example.com")) } + fun `test inline code urls become underlined link colored links`() { + view.set("Release PR: `https://github.com/Kilo-Org/kilocode/pull/13524`") + val code = MdCommon.hex(MdCommon.defaults(SessionEditorStyle.current()).inlineCodeFg) + val link = MdCommon.hex(MdCommon.defaults(SessionEditorStyle.current()).linkColor) + val html = view.html() + val sheet = view.overrideSheet() + + assertTrue( + html.contains( + "" + + "" + + "https://github.com/Kilo-Org/kilocode/pull/13524", + ), + ) + assertTrue(sheet.contains("a.kilo-url-ref, code a.kilo-url-ref { color: $link; font-family:")) + assertTrue(sheet.contains("monospace; text-decoration: underline")) + } + + fun `test inline code urls keep query separators in href`() { + view.set("Open `https://example.com/a?b=1&c=2` now") + val html = view.html() + + assertTrue(html.contains("href=\"https://example.com/a?b=1&c=2\">https://example.com/a?b=1&c=2")) + } + + fun `test inline code urls exclude trailing punctuation and unbalanced brackets`() { + view.set("See `https://example.com/a.` and `(https://example.com/b)`") + val html = view.html() + + assertTrue(html.contains("href=\"https://example.com/a\">https://example.com/a.")) + assertTrue(html.contains("(https://example.com/b)")) + } + + fun `test inline code urls keep balanced brackets inside link`() { + view.set("See `https://example.com/a_(b)`") + val html = view.html() + + assertTrue(html.contains("href=\"https://example.com/a_(b)\">https://example.com/a_(b)")) + } + + fun `test autolinked urls are not wrapped again`() { + view.set("Visit https://example.com/a for details") + val html = view.html() + + assertFalse(html.contains("kilo-url-ref")) + } + + fun `test markdown link urls are not wrapped again`() { + view.set("[docs](https://example.com/a)") + val html = view.html() + + assertFalse(html.contains("kilo-url-ref")) + } + + fun `test fenced code urls are not links`() { + view.set("```text\nhttps://example.com/a\n```") + val html = view.html() + + assertTrue(html.contains("https://example.com/a")) + assertFalse(html.contains("kilo-url-ref")) + } + + fun `test inline code urls do not swallow following file refs`() { + view.set("`https://example.com/a` then packages/opencode/src/session/prompt.ts") + val html = view.html() + + assertTrue(html.contains("href=\"https://example.com/a\">https://example.com/a")) + assertTrue(html.contains("")) + } + // ---- append ---- fun `test append accumulates source`() {