diff --git a/.changeset/jetbrains-inline-code-urls.md b/.changeset/jetbrains-inline-code-urls.md new file mode 100644 index 000000000000..e6accb7a366d --- /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/build.gradle.kts b/packages/kilo-jetbrains/frontend/build.gradle.kts index 65d80848ae7c..7d034c5d0430 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 779fb2123541..5123cd7f569c 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 @@ -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", @@ -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 } ") 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 1d3e077951b3..53e142b2dc3f 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/MdViewHybridTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt index d5cde1178057..802abbc3c105 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 f55b0188ea2f..5e7010efcf7c 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,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( + "" + + "" + + "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("")) + } + + 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 f394f4a86dd9..8f5c528c65d2 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" }