diff --git a/packages/kilo-jetbrains/frontend/build.gradle.kts b/packages/kilo-jetbrains/frontend/build.gradle.kts index d2408e8549e..735d87eff95 100644 --- a/packages/kilo-jetbrains/frontend/build.gradle.kts +++ b/packages/kilo-jetbrains/frontend/build.gradle.kts @@ -19,6 +19,11 @@ dependencies { implementation(project(":shared")) + implementation(libs.commonmark) + implementation(libs.commonmark.autolink) + implementation(libs.commonmark.tables) + implementation(libs.commonmark.strikethrough) + testImplementation(kotlin("test")) testImplementation("junit:junit:4.13.2") testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.11.4") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt new file mode 100644 index 00000000000..146075250e5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt @@ -0,0 +1,298 @@ +package ai.kilocode.client.ui.md + +import com.intellij.ui.components.JBHtmlPane +import com.intellij.ui.components.JBHtmlPaneConfiguration +import com.intellij.ui.components.JBHtmlPaneStyleConfiguration +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import org.commonmark.ext.autolink.AutolinkExtension +import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension +import org.commonmark.ext.gfm.tables.TablesExtension +import org.commonmark.parser.Parser +import org.commonmark.renderer.html.HtmlRenderer +import java.awt.Color +import java.awt.Font +import java.awt.Point +import javax.swing.JComponent +import javax.swing.event.HyperlinkEvent +import javax.swing.text.html.StyleSheet + +/** + * Markdown rendering component backed by [JBHtmlPane] with editor-aware styling. + * + * By default, font and colors are derived from the global editor colour scheme. + * All style properties are optional overrides on top of those defaults. + * Call [resetStyles] to revert to editor defaults after overriding. + * + * Create instances via [MdView.html]. All public methods must be called on the EDT. + */ +@Suppress("UnstableApiUsage") +abstract class MdView private constructor() { + + abstract val component: JComponent + abstract fun set(text: String) + abstract fun append(delta: String) + abstract fun clear() + /** Revert all style overrides to editor-derived defaults. */ + abstract fun resetStyles() + abstract fun addLinkListener(listener: LinkListener) + abstract fun removeLinkListener(listener: LinkListener) + + abstract var font: Font + abstract var foreground: Color + abstract var background: Color + abstract var linkColor: Color + abstract var codeBg: Color + abstract var preBg: Color + abstract var preFg: Color + abstract var codeFont: String + abstract var quoteBorder: Color + abstract var quoteFg: Color + abstract var tableBorder: Color + + /** + * When `false`, the component is transparent — the parent's background shows through + * and no background is forced in the CSS body rule. + */ + abstract var opaque: Boolean + + data class LinkEvent( + val href: String, + val point: Point? = null, + ) + + fun interface LinkListener { + fun onLink(event: LinkEvent) + } + + internal abstract fun markdown(): String + internal abstract fun html(): String + /** Returns the current CSS override rules applied on top of JBHtmlPane's default stylesheet. */ + internal abstract fun overrideSheet(): String + internal abstract fun simulateLink(href: String) + + companion object { + fun html(): MdView = HtmlImpl() + } + + @Suppress("UnstableApiUsage") + private class HtmlImpl : MdView() { + + private val listeners = mutableListOf() + private val source = StringBuilder() + private var rendered = "" + + private val extensions = listOf( + AutolinkExtension.create(), + TablesExtension.create(), + StrikethroughExtension.create(), + ) + + private val parser: Parser = Parser.builder().extensions(extensions).build() + + private val renderer: HtmlRenderer = HtmlRenderer.builder() + .extensions(extensions) + .escapeHtml(true) + .sanitizeUrls(true) + .build() + + // nullable overrides — null means "use JBHtmlPane / editor default" + private var fontOverride: Font? = null + private var foregroundOverride: Color? = null + private var backgroundOverride: Color? = null + private var linkColorOverride: Color? = null + private var codeBgOverride: Color? = null + private var preBgOverride: Color? = null + private var preFgOverride: Color? = null + private var codeFontOverride: String? = null + private var quoteBorderOverride: Color? = null + private var quoteFgOverride: Color? = null + private var tableBorderOverride: Color? = null + private var opaqueState = true + + private val pane: JBHtmlPane = JBHtmlPane( + JBHtmlPaneStyleConfiguration { + // colorSchemeProvider defaults to EditorColorsManager.getInstance().globalScheme + enableInlineCodeBackground = true + enableCodeBlocksBackground = true + }, + JBHtmlPaneConfiguration { + // fontResolver defaults to EditorCssFontResolver.getGlobalInstance() via JBHtmlPane's ImplService + customStyleSheetProvider { buildOverrideStyleSheet() } + } + ).apply { + isEditable = false + isOpaque = true + background = UIUtil.getPanelBackground() + + addHyperlinkListener { e -> + if (e.eventType == HyperlinkEvent.EventType.ACTIVATED) { + val href = e.description ?: return@addHyperlinkListener + val pt = (e.inputEvent as? java.awt.event.MouseEvent)?.point + val event = LinkEvent(href, pt) + for (l in listeners) l.onLink(event) + } + } + } + + override val component: JComponent get() = pane + + // -- style properties (non-null API backed by nullable overrides) ---- + + override var font: Font + get() = fontOverride ?: JBUI.Fonts.label() + set(value) { fontOverride = value; markDirty() } + + override var foreground: Color + get() = foregroundOverride ?: UIUtil.getLabelForeground() + set(value) { foregroundOverride = value; markDirty() } + + override var background: Color + get() = backgroundOverride ?: pane.background + set(value) { + backgroundOverride = value + if (opaqueState) pane.background = value + markDirty() + } + + override var linkColor: Color + get() = linkColorOverride ?: Color(0x58, 0x9D, 0xF6) + set(value) { linkColorOverride = value; markDirty() } + + override var codeBg: Color + get() = codeBgOverride ?: Color(0x3C, 0x3F, 0x41) + set(value) { codeBgOverride = value; markDirty() } + + override var preBg: Color + get() = preBgOverride ?: Color(0x2B, 0x2B, 0x2B) + set(value) { preBgOverride = value; markDirty() } + + override var preFg: Color + get() = preFgOverride ?: Color(0xA9, 0xB7, 0xC6) + set(value) { preFgOverride = value; markDirty() } + + override var codeFont: String + // _EditorFontNoLigatures_ is resolved by EditorCssFontResolver to the global editor font + get() = codeFontOverride ?: "_EditorFontNoLigatures_" + set(value) { codeFontOverride = value; markDirty() } + + override var quoteBorder: Color + get() = quoteBorderOverride ?: Color(0x55, 0x55, 0x55) + set(value) { quoteBorderOverride = value; markDirty() } + + override var quoteFg: Color + get() = quoteFgOverride ?: Color(0x99, 0x99, 0x99) + set(value) { quoteFgOverride = value; markDirty() } + + override var tableBorder: Color + get() = tableBorderOverride ?: Color(0x55, 0x55, 0x55) + set(value) { tableBorderOverride = value; markDirty() } + + override var opaque: Boolean + get() = opaqueState + set(value) { + opaqueState = value + pane.isOpaque = value + if (value) pane.background = backgroundOverride ?: UIUtil.getPanelBackground() + markDirty() + } + + override fun resetStyles() { + fontOverride = null + foregroundOverride = null + backgroundOverride = null + linkColorOverride = null + codeBgOverride = null + preBgOverride = null + preFgOverride = null + codeFontOverride = null + quoteBorderOverride = null + quoteFgOverride = null + tableBorderOverride = null + opaqueState = true + pane.isOpaque = true + pane.background = UIUtil.getPanelBackground() + markDirty() + } + + // -- content API --------------------------------------------------- + + override fun set(text: String) { + source.clear() + source.append(text) + render() + } + + override fun append(delta: String) { + source.append(delta) + render() + } + + override fun clear() { + source.clear() + rendered = "" + pane.text = "" + } + + override fun addLinkListener(listener: LinkListener) { listeners.add(listener) } + override fun removeLinkListener(listener: LinkListener) { listeners.remove(listener) } + + override fun markdown(): String = source.toString() + override fun html(): String = rendered + override fun overrideSheet(): String = buildOverrideRulesString() + + override fun simulateLink(href: String) { + val event = LinkEvent(href) + for (l in listeners) l.onLink(event) + } + + private fun markDirty() { + pane.reloadCssStylesheets() + if (source.isNotEmpty()) render() + } + + private fun render() { + val body = renderer.render(parser.parse(source.toString())) + rendered = body + pane.text = "$body" + pane.caretPosition = 0 + } + + private fun buildOverrideStyleSheet(): StyleSheet { + val sheet = StyleSheet() + val rules = buildOverrideRulesString() + if (rules.isNotEmpty()) { + try { sheet.addRule(rules) } catch (_: Exception) {} + } + return sheet + } + + private fun buildOverrideRulesString(): String { + val rules = StringBuilder() + + val body = mutableListOf() + foregroundOverride?.let { body.add("color: ${hex(it)}") } + if (!opaqueState) body.add("background: transparent") + fontOverride?.let { + body.add("font-family: '${it.family}', sans-serif") + body.add("font-size: ${it.size}pt") + } + if (body.isNotEmpty()) rules.append("body { ${body.joinToString("; ")} } ") + + linkColorOverride?.let { rules.append("a { color: ${hex(it)} } ") } + codeFontOverride?.let { rules.append("tt, code, samp, pre { font-family: '${it}', monospace } ") } + preBgOverride?.let { rules.append("pre { background: ${hex(it)} } ") } + preFgOverride?.let { rules.append("pre { color: ${hex(it)} } ") } + codeBgOverride?.let { rules.append("code { background: ${hex(it)} } ") } + quoteBorderOverride?.let { rules.append("blockquote { border-left-color: ${hex(it)} } ") } + quoteFgOverride?.let { rules.append("blockquote { color: ${hex(it)} } ") } + tableBorderOverride?.let { rules.append("th, td { border-color: ${hex(it)} } ") } + + return rules.toString().trim() + } + + companion object { + private fun hex(c: Color): String = String.format("#%02x%02x%02x", c.red, c.green, c.blue) + } + } +} 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 new file mode 100644 index 00000000000..4fa8ff2d04b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt @@ -0,0 +1,401 @@ +package ai.kilocode.client.ui.md + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Color +import java.awt.Font + +/** + * Tests for [MdView] created via [MdView.html]. + * + * Uses [BasePlatformTestCase] to get a real IntelliJ Application so that + * JBHtmlPane initialisation works correctly. + */ +@Suppress("UnstableApiUsage") +class MdViewTest : BasePlatformTestCase() { + + private lateinit var view: MdView + + override fun setUp() { + super.setUp() + view = MdView.html() + } + + // ---- set ---- + + fun `test set stores source`() { + view.set("hello **world**") + assertEquals("hello **world**", view.markdown()) + } + + fun `test set replaces previous content`() { + view.set("first") + view.set("second") + assertEquals("second", view.markdown()) + } + + fun `test set renders bold`() { + view.set("hello **world**") + assertTrue(view.html().contains("")) + assertTrue(view.html().contains("world")) + } + + fun `test set renders italic`() { + view.set("hello *world*") + assertTrue(view.html().contains("")) + } + + fun `test set renders inline code`() { + view.set("use `foo()` here") + assertTrue(view.html().contains("")) + assertTrue(view.html().contains("foo()")) + } + + fun `test set renders fenced code block`() { + view.set("```kotlin\nval x = 1\n```") + assertTrue(view.html().contains("
"))
+        assertTrue(view.html().contains(""))
+    }
+
+    fun `test set renders unordered list`() {
+        view.set("- one\n- two\n- three")
+        assertTrue(view.html().contains("
    ")) + assertTrue(view.html().contains("
  • ")) + } + + fun `test set renders ordered list`() { + view.set("1. one\n2. two\n3. three") + assertTrue(view.html().contains("
      ")) + } + + fun `test set renders blockquote`() { + view.set("> quoted text") + assertTrue(view.html().contains("
      ")) + } + + fun `test set renders strikethrough`() { + view.set("~~deleted~~") + assertTrue(view.html().contains("")) + } + + fun `test set renders table`() { + view.set("| a | b |\n|---|---|\n| 1 | 2 |") + assertTrue(view.html().contains("")) + assertTrue(view.html().contains("
      ")) + assertTrue(view.html().contains("")) + } + + fun `test set renders autolink`() { + view.set("Visit https://example.com for details") + assertTrue(view.html().contains("")) + } + + fun `test append after set extends content`() { + view.set("first ") + view.append("second") + assertEquals("first second", view.markdown()) + } + + // ---- clear ---- + + fun `test clear resets source`() { + view.set("some content") + view.clear() + assertEquals("", view.markdown()) + } + + fun `test clear resets rendered html`() { + view.set("some content") + view.clear() + assertFalse(view.html().contains("some content")) + } + + // ---- link listener ---- + + fun `test link listener receives event on activation`() { + val received = mutableListOf() + view.addLinkListener { received.add(it) } + view.set("[click](https://example.com)") + view.simulateLink("https://example.com") + assertEquals(1, received.size) + assertEquals("https://example.com", received[0].href) + } + + fun `test multiple link listeners all receive event`() { + val first = mutableListOf() + val second = mutableListOf() + view.addLinkListener { first.add(it) } + view.addLinkListener { second.add(it) } + view.simulateLink("https://a.com") + assertEquals(1, first.size) + assertEquals(1, second.size) + } + + fun `test removed listener does not receive events`() { + val received = mutableListOf() + val listener = MdView.LinkListener { received.add(it) } + view.addLinkListener(listener) + view.removeLinkListener(listener) + view.simulateLink("https://example.com") + assertTrue(received.isEmpty()) + } + + // ---- component ---- + + fun `test component is not null`() { + assertNotNull(view.component) + } + + // ---- complex markdown ---- + + fun `test complex markdown with mixed elements`() { + val md = """ + # Heading + + Some **bold** and *italic* text with `code`. + + - item one + - item two + + ``` + code block + ``` + + > blockquote + + [link](https://example.com) + """.trimIndent() + + view.set(md) + val html = view.html() + assertTrue(html.contains("

      ")) + assertTrue(html.contains("")) + assertTrue(html.contains("")) + assertTrue(html.contains("")) + assertTrue(html.contains("
        ")) + assertTrue(html.contains("
        "))
        +        assertTrue(html.contains("
        ")) + assertTrue(html.contains("")) + assertTrue(view.html().contains("Done.")) + } + + // ---- style overrides (empty by default) ---- + + fun `test no overrides produces empty override sheet`() { + assertEquals("", view.overrideSheet()) + } + + // ---- style overrides appear in override sheet when set ---- + + fun `test foreground override appears in override sheet`() { + view.foreground = Color(0xAA, 0xBB, 0xCC) + view.set("text") + assertTrue(view.overrideSheet().contains("#aabbcc")) + } + + fun `test link color override appears in override sheet`() { + view.linkColor = Color(0xFF, 0x00, 0x77) + view.set("[a](https://x.com)") + assertTrue(view.overrideSheet().contains("#ff0077")) + } + + fun `test code bg override appears in override sheet`() { + view.codeBg = Color(0x10, 0x20, 0x30) + view.set("`code`") + assertTrue(view.overrideSheet().contains("#102030")) + } + + fun `test pre bg and fg overrides appear in override sheet`() { + view.preBg = Color(0x0A, 0x0B, 0x0C) + view.preFg = Color(0xD0, 0xE0, 0xF0) + view.set("```\ncode\n```") + val sheet = view.overrideSheet() + assertTrue(sheet.contains("#0a0b0c")) + assertTrue(sheet.contains("#d0e0f0")) + } + + fun `test code font override appears in override sheet`() { + view.codeFont = "Fira Code" + view.set("`x`") + assertTrue(view.overrideSheet().contains("Fira Code")) + } + + fun `test blockquote color overrides appear in override sheet`() { + view.quoteBorder = Color(0xAA, 0x00, 0x00) + view.quoteFg = Color(0x00, 0xBB, 0x00) + view.set("> quote") + val sheet = view.overrideSheet() + assertTrue(sheet.contains("#aa0000")) + assertTrue(sheet.contains("#00bb00")) + } + + fun `test table border override appears in override sheet`() { + view.tableBorder = Color(0x12, 0x34, 0x56) + view.set("| a |\n|---|\n| 1 |") + assertTrue(view.overrideSheet().contains("#123456")) + } + + fun `test font family override appears in override sheet`() { + view.font = Font("Courier New", Font.PLAIN, 14) + view.set("text") + assertTrue(view.overrideSheet().contains("Courier New")) + } + + fun `test font size override appears in override sheet`() { + view.font = Font("Arial", Font.PLAIN, 18) + view.set("text") + assertTrue(view.overrideSheet().contains("18pt")) + } + + fun `test style change re-renders and override sheet reflects change`() { + view.set("hello") + view.foreground = Color(0xDE, 0xAD, 0x00) + assertTrue(view.overrideSheet().contains("#dead00")) + assertTrue(view.html().contains("hello")) + } + + fun `test style change without content does not crash`() { + view.foreground = Color.RED + view.linkColor = Color.BLUE + view.codeFont = "Monospaced" + assertEquals("", view.markdown()) + } + + // ---- default codeFont uses editor font placeholder ---- + + fun `test default codeFont is editor font placeholder`() { + // When no codeFont override is set, the getter returns the editor font placeholder + assertTrue(view.codeFont.contains("_Editor")) + } + + fun `test default override sheet is empty before any set`() { + // Only overrides appear in the sheet; editor defaults are handled by JBHtmlPane + assertEquals("", view.overrideSheet()) + } + + // ---- background sets component background ---- + + fun `test background override sets component background`() { + view.background = Color(0x11, 0x22, 0x33) + assertEquals(Color(0x11, 0x22, 0x33), view.component.background) + } + + fun `test background override does not appear in override sheet`() { + // background is applied to the Swing component, not via CSS override rule + view.background = Color(0x11, 0x22, 0x33) + view.set("text") + assertFalse(view.overrideSheet().contains("#112233")) + } + + // ---- opaque / transparent ---- + + fun `test opaque true sets component opaque`() { + view.opaque = true + assertTrue(view.component.isOpaque) + } + + fun `test opaque false sets component non-opaque`() { + view.opaque = false + assertFalse(view.component.isOpaque) + } + + fun `test opaque false adds transparent background to override sheet`() { + view.opaque = false + view.set("text") + assertTrue(view.overrideSheet().contains("transparent")) + } + + fun `test opaque true does not add transparent rule`() { + view.opaque = true + view.set("text") + assertFalse(view.overrideSheet().contains("transparent")) + } + + fun `test opaque false does not affect pre background override`() { + view.opaque = false + view.preBg = Color(0x0A, 0x0B, 0x0C) + view.set("```\ncode\n```") + assertTrue(view.overrideSheet().contains("#0a0b0c")) + } + + fun `test background override applied to component when opaque is true`() { + view.background = Color(0xFE, 0xFE, 0xFE) + view.opaque = true + assertEquals(Color(0xFE, 0xFE, 0xFE), view.component.background) + } + + fun `test opaque toggle updates component opacity`() { + view.opaque = false + assertFalse(view.component.isOpaque) + view.opaque = true + assertTrue(view.component.isOpaque) + } + + // ---- resetStyles ---- + + fun `test resetStyles clears foreground override`() { + view.foreground = Color.RED + view.resetStyles() + assertEquals("", view.overrideSheet()) + } + + fun `test resetStyles clears all overrides`() { + view.foreground = Color.RED + view.linkColor = Color.BLUE + view.codeBg = Color.GREEN + view.preBg = Color.ORANGE + view.preFg = Color.CYAN + view.codeFont = "Monospaced" + view.quoteBorder = Color.PINK + view.quoteFg = Color.MAGENTA + view.tableBorder = Color.YELLOW + view.font = Font("Arial", Font.PLAIN, 18) + view.resetStyles() + assertEquals("", view.overrideSheet()) + } + + fun `test resetStyles restores opaque to true`() { + view.opaque = false + view.resetStyles() + assertTrue(view.component.isOpaque) + } + + fun `test resetStyles after set still renders content`() { + view.set("hello **world**") + view.foreground = Color.RED + view.resetStyles() + assertTrue(view.html().contains("")) + } +} diff --git a/packages/kilo-jetbrains/gradle/libs.versions.toml b/packages/kilo-jetbrains/gradle/libs.versions.toml index 9d81facbccf..fd3098941a7 100644 --- a/packages/kilo-jetbrains/gradle/libs.versions.toml +++ b/packages/kilo-jetbrains/gradle/libs.versions.toml @@ -8,8 +8,13 @@ kotlin-serialization = "1.11.0" okhttp = "4.12.0" openapi-generator = "7.21.0" detekt = "1.23.8" +commonmark = "0.28.0" [libraries] +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" } 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" }