diff --git a/packages/kilo-jetbrains/frontend/build.gradle.kts b/packages/kilo-jetbrains/frontend/build.gradle.kts
index 65d80848ae7..7d034c5d043 100644
--- a/packages/kilo-jetbrains/frontend/build.gradle.kts
+++ b/packages/kilo-jetbrains/frontend/build.gradle.kts
@@ -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)
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 e2eecef6137..5123cd7f569 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,14 +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
+ /** 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",
@@ -41,7 +39,7 @@ internal object MdCommon {
.replace("\r", " ")
fun inlineCode(html: String, opts: MdStyle): String {
- if (!html.contains('<') && !scan(html)) return html
+ if (!html.contains('<') && !html.contains('.')) return html
val color = hex(opts.inlineCodeFg)
val styled = if (html.contains(" 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
+ if (text.length > REF_SEGMENT_LIMIT || !text.contains('.')) return text
return ref.replace(text) { match ->
val path = match.value
if (!pathish(path)) return@replace path
@@ -200,35 +177,6 @@ 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/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdProjector.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdProjector.kt
index 1d3e077951b..53e142b2dc3 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdProjector.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdProjector.kt
@@ -1,5 +1,6 @@
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
@@ -7,13 +8,19 @@ 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(
@@ -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 {
@@ -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> = 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()
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 e310bcf99db..5e7010efcf7 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
@@ -236,6 +236,13 @@ class MdViewTest : BasePlatformTestCase() {
assertTrue(html.contains(""))
}
+ fun `test inline code urls stop at characters that cannot appear in a url`() {
+ view.set("See `https://example.com/a`")
+ val html = view.html()
+
+ assertTrue(html.contains("href=\"https://example.com/a\">https://example.com/a<b>"))
+ }
+
// ---- append ----
fun `test append accumulates source`() {
diff --git a/packages/kilo-jetbrains/gradle/libs.versions.toml b/packages/kilo-jetbrains/gradle/libs.versions.toml
index f394f4a86dd..8f5c528c65d 100644
--- a/packages/kilo-jetbrains/gradle/libs.versions.toml
+++ b/packages/kilo-jetbrains/gradle/libs.versions.toml
@@ -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"
@@ -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" }