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.
This commit is contained in:
kirillk
2026-08-28 09:02:28 -04:00
parent 156fb64fdb
commit 1d6744e63e
4 changed files with 156 additions and 4 deletions
+5
View File
@@ -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.
@@ -19,7 +19,12 @@ internal object MdCommon {
private val code = Regex("<code(\\s[^>]*)?>(.*?)</code>", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
private val tag = Regex("<[^>]+>")
private val ref = Regex("(?<![\\w@:/.-])((?:\\.?[A-Za-z0-9_-]{1,80}/){1,20}[A-Za-z0-9_.-]{1,120}\\.[A-Za-z0-9_-]{1,20}(?::\\d{1,7}(?:-\\d{1,7})?)?|[A-Za-z0-9_.-]{1,120}\\.(?:ts|tsx|js|jsx|mjs|cjs|kt|kts|java|md|mdx|txt|json|jsonc|yaml|yml|toml|xml|html|css|scss|rs|go|py|rb|php|swift|c|h|cpp|hpp|cs|sh|zsh|bash|sql|gradle)(?::\\d{1,7}(?:-\\d{1,7})?)?)")
private val url = Regex("https?://[^\\s<>\"'`]+", 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("&lt;", "&gt;", "&quot;")
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("<code", ignoreCase = true)) {
val out = StringBuilder()
@@ -75,6 +80,7 @@ internal object MdCommon {
rules.append("em, i { color: ${hex(opts.emphasisFg)} } ")
rules.append("a { color: ${hex(opts.linkColor)} } ")
rules.append("a.kilo-file-ref, code a.kilo-file-ref { color: ${hex(SessionUiStyle.View.Markdown.string())}; font-family: '${css(opts.codeFont)}', monospace; text-decoration: underline } ")
rules.append("a.kilo-url-ref, code a.kilo-url-ref { color: ${hex(opts.linkColor)}; font-family: '${css(opts.codeFont)}', monospace; text-decoration: underline } ")
rules.append("ul, ol { color: ${hex(opts.listMarkerFg)} } ")
rules.append("li { color: ${hex(opts.foreground)} } ")
rules.append("tt, code, samp, pre, pre code { font-family: '${css(opts.codeFont)}', monospace; border-width: 0 } ")
@@ -136,7 +142,7 @@ internal object MdCommon {
}
private fun refs(html: String): String {
if (!html.contains('.')) return html
if (!scan(html)) return html
val out = StringBuilder()
var at = 0
for (match in protect.findAll(html)) {
@@ -149,7 +155,7 @@ internal object MdCommon {
}
private fun tags(html: String): String {
if (!html.contains('.')) return html
if (!scan(html)) return html
val out = StringBuilder()
var at = 0
for (match in tag.findAll(html)) {
@@ -161,8 +167,29 @@ internal object MdCommon {
return out.toString()
}
/**
* Linkifies plain text between tags: `http(s)` URLs first, then file references in whatever text
* is left over. URLs are handled here rather than by the markdown parser because CommonMark's
* autolink extension skips code spans, so a URL in backticks would otherwise stay inert text.
*/
private fun paths(text: String): String {
if (text.length > 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("<a class=\"kilo-url-ref\" href=\"$link\">$link</a>")
}
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(':')
@@ -344,6 +344,27 @@ class MdViewHybridTest : BasePlatformTestCase() {
assertTrue(html.contains("href=\"native-plan-prompt.txt:37-38\">native-plan-prompt.txt:37-38</a>."))
}
fun `test inline code url renders a live anchor that dispatches link events`() {
val received = mutableListOf<MdView.LinkEvent>()
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()
@@ -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(
"<code style=\"color: $code\">" +
"<a class=\"kilo-url-ref\" href=\"https://github.com/Kilo-Org/kilocode/pull/13524\">" +
"https://github.com/Kilo-Org/kilocode/pull/13524</a></code>",
),
)
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&amp;c=2\">https://example.com/a?b=1&amp;c=2</a>"))
}
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</a>."))
assertTrue(html.contains("(<a class=\"kilo-url-ref\" href=\"https://example.com/b\">https://example.com/b</a>)"))
}
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)</a>"))
}
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</a>"))
assertTrue(html.contains("<a class=\"kilo-file-ref\" href=\"packages/opencode/src/session/prompt.ts\">"))
}
// ---- append ----
fun `test append accumulates source`() {