Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/jetbrains-inline-code-urls.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions packages/kilo-jetbrains/frontend/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ dependencies {
implementation(libs.commonmark.autolink)
implementation(libs.commonmark.tables)
implementation(libs.commonmark.strikethrough)
// Bundled explicitly rather than relied on as a transitive of commonmark-ext-autolink: the URL
// scanner is used directly to linkify code spans.
implementation(libs.autolink)
implementation(libs.kotlinx.serialization.json)
implementation(libs.zxing.core)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ internal object MdCommon {
private val single = setOf("readme.md", "package.json", "tsconfig.json", "jsconfig.json", "kilo.json", "kilo.jsonc")
private const val REF_SEGMENT_LIMIT = 16_384

/** Anchor class [ai.kilocode.client.ui.md.hybrid.MdProjector]'s code-span linkifier tags its links with. */
const val URL_REF_CLASS = "kilo-url-ref"

val tags = listOf(
"body", "p", "div", "span", "ul", "ol", "li", "table", "thead", "tbody", "tr", "th", "td",
"blockquote", "h1", "h2", "h3", "h4", "h5", "h6", "a", "tt", "code", "samp", "pre",
Expand Down Expand Up @@ -75,6 +78,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.$URL_REF_CLASS, code a.$URL_REF_CLASS { 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 } ")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
package ai.kilocode.client.ui.md.hybrid

import ai.kilocode.client.ui.md.MdCommon
import com.intellij.openapi.fileTypes.PlainTextFileType
import org.commonmark.ext.autolink.AutolinkExtension
import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension
import org.commonmark.ext.gfm.tables.TableBlock
import org.commonmark.ext.gfm.tables.TablesExtension
import org.commonmark.node.AbstractVisitor
import org.commonmark.node.Block
import org.commonmark.node.Code
import org.commonmark.node.Document
import org.commonmark.node.FencedCodeBlock
import org.commonmark.node.IndentedCodeBlock
import org.commonmark.node.Node
import org.commonmark.node.ThematicBreak
import org.commonmark.parser.Parser
import org.commonmark.renderer.NodeRenderer
import org.commonmark.renderer.html.HtmlNodeRendererContext
import org.commonmark.renderer.html.HtmlRenderer
import org.nibor.autolink.LinkExtractor
import org.nibor.autolink.LinkSpan
import org.nibor.autolink.LinkType

internal class MdProjector {
private val extensions = listOf(
Expand All @@ -28,6 +35,9 @@ internal class MdProjector {
.extensions(extensions)
.escapeHtml(true)
.sanitizeUrls(true)
// HtmlRenderer always appends the core node renderer last, so any factory added here wins
// for the node types it handles.
.nodeRendererFactory { context -> CodeLinks(context) }
.build()

fun project(text: String): Projection {
Expand Down Expand Up @@ -213,6 +223,46 @@ internal class MdProjector {
}
}

/**
* Renders `Code` (inline code span) nodes, linkifying any `http(s)` URL found in the literal text.
*
* CommonMark's [AutolinkExtension] only scans [org.commonmark.node.Text] nodes, so a URL written in
* backticks is otherwise never linkified. This reuses the same URL scanner the extension is built on
* ([LinkExtractor], from the `autolink` library CommonMark depends on) to detect links, then relies on
* [org.commonmark.renderer.html.HtmlWriter] to escape both the link text and the `href` attribute the
* same way the core renderer would.
*/
private class CodeLinks(private val context: HtmlNodeRendererContext) : NodeRenderer {
companion object {
private val EXTRACTOR: LinkExtractor = LinkExtractor.builder().linkTypes(setOf(LinkType.URL)).build()
}

override fun getNodeTypes(): Set<Class<out Node>> = setOf(Code::class.java)

override fun render(node: Node) {
val code = node as Code
val html = context.writer
html.tag("code", context.extendAttributes(code, "code", emptyMap()))
val literal = code.literal
for (span in EXTRACTOR.extractSpans(literal)) {
val text = literal.substring(span.beginIndex, span.endIndex)
if (span !is LinkSpan || !web(text)) {
html.text(text)
continue
}
html.tag("a", mapOf("class" to MdCommon.URL_REF_CLASS, "href" to text))
html.text(text)
html.tag("/a")
}
html.tag("/code")
}

// LinkExtractor.linkTypes(URL) matches any "scheme://…", not just http(s) (e.g. file://, ftp://);
// restrict to what SessionFileLinks.isFileHref routes to the browser opener.
private fun web(text: String): Boolean =
text.startsWith("http://", ignoreCase = true) || text.startsWith("https://", ignoreCase = true)
}

internal sealed class Desc {
data class Html(val body: String) : Desc()
data class Code(val text: String, val kind: Kind) : Desc()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,83 @@ 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\">"))
}

fun `test inline code urls stop at characters that cannot appear in a url`() {
view.set("See `https://example.com/a<b>`")
val html = view.html()

assertTrue(html.contains("href=\"https://example.com/a\">https://example.com/a</a>&lt;b&gt;"))
}

// ---- append ----

fun `test append accumulates source`() {
Expand Down
2 changes: 2 additions & 0 deletions packages/kilo-jetbrains/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ okhttp = "4.12.0"
openapi-generator = "7.21.0"
detekt = "1.23.8"
commonmark = "0.28.0"
autolink = "0.12.0"
zxing = "3.5.3"
changelog = "2.5.0"
commons-compress = "1.28.0"
Expand All @@ -21,6 +22,7 @@ commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark"
commonmark-autolink = { module = "org.commonmark:commonmark-ext-autolink", version.ref = "commonmark" }
commonmark-tables = { module = "org.commonmark:commonmark-ext-gfm-tables", version.ref = "commonmark" }
commonmark-strikethrough = { module = "org.commonmark:commonmark-ext-gfm-strikethrough", version.ref = "commonmark" }
autolink = { module = "org.nibor.autolink:autolink", version.ref = "autolink" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-sse = { module = "com.squareup.okhttp3:okhttp-sse", version.ref = "okhttp" }
okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
Expand Down
Loading