diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt index 1caf26b538d..22ee56dc73b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt @@ -3,7 +3,7 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.update.SessionController import ai.kilocode.client.ui.UiStyle -import ai.kilocode.client.ui.md.MdView +import ai.kilocode.client.ui.md.MdViewFactory import ai.kilocode.rpc.dto.SessionDto import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer @@ -84,7 +84,7 @@ class EmptySessionPanel( } }) } - private val md = MdView.html().apply { + private val md = MdViewFactory.html(style).apply { // MdView uses an HTML component; transparency keeps the centered panel seamless. opaque = false foreground = UIUtil.getContextHelpForeground() @@ -242,7 +242,9 @@ class EmptySessionPanel( override fun applyStyle(style: SessionStyle) { this.style = style + md.applyStyle(style) md.font = style.uiFont + md.foreground = UIUtil.getContextHelpForeground() recentTitle.font = style.smallUiFont revalidate() repaint() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt index 4e2f143e1ae..15805832c75 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt @@ -8,6 +8,7 @@ import ai.kilocode.client.session.model.Reasoning import ai.kilocode.client.session.ui.SessionStyle import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.md.MdView +import ai.kilocode.client.ui.md.MdViewFactory import com.intellij.icons.AllIcons import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane @@ -28,7 +29,7 @@ class ReasoningView(reasoning: Reasoning) : PartView() { override val contentId: String = reasoning.id - val md: MdView = MdView.html() + val md: MdView = MdViewFactory.create(SessionStyle.current()) private val arrow = JBLabel() private val body = TrackPanel().apply { @@ -153,6 +154,7 @@ class ReasoningView(reasoning: Reasoning) : PartView() { title.font = style.smallEditorFont changed = true } + md.applyStyle(style) changed = apply(md) || changed if (changed) refresh() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt index 1954aaa8a48..4f3a3131ab5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Text import ai.kilocode.client.session.ui.SessionStyle import ai.kilocode.client.ui.md.MdView +import ai.kilocode.client.ui.md.MdViewFactory import java.awt.BorderLayout /** @@ -15,7 +16,7 @@ class TextView(text: Text) : PartView() { override val contentId: String = text.id - val md: MdView = MdView.html() + val md: MdView = MdViewFactory.create(SessionStyle.current()) init { layout = BorderLayout() @@ -42,6 +43,7 @@ class TextView(text: Text) : PartView() { override fun applyStyle(style: SessionStyle) { val changed = md.font != style.transcriptFont || md.codeFont != style.editorFamily + md.applyStyle(style) if (md.font != style.transcriptFont) md.font = style.transcriptFont if (md.codeFont != style.editorFamily) md.codeFont = style.editorFamily if (!changed) return 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 new file mode 100644 index 00000000000..5cd76747ded --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt @@ -0,0 +1,77 @@ +package ai.kilocode.client.ui.md + +import ai.kilocode.client.session.ui.SessionStyle +import java.awt.Color + +internal object MdCommon { + 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", + ) + + fun hex(c: Color): String = String.format("#%02x%02x%02x", c.red, c.green, c.blue) + + fun css(text: String): String = text + .replace("\\", "\\\\") + .replace("'", "\\'") + .replace("\n", " ") + .replace("\r", " ") + + fun rules(opts: MdStyle): String { + val rules = StringBuilder() + + val text = mutableListOf() + text.add("color: ${hex(opts.foreground)}") + text.add("font-family: '${css(opts.font.name)}', sans-serif") + text.add("font-size: ${opts.font.size}pt") + if (opts.font.isItalic) text.add("font-style: italic") + if (opts.font.isBold) text.add("font-weight: bold") + val rule = text.joinToString("; ") + for (tag in tags) rules.append("$tag { $rule } ") + + val body = mutableListOf() + if (!opts.opaque) body.add("background: transparent") + if (body.isNotEmpty()) rules.append("body { ${body.joinToString("; ")} } ") + + rules.append("a { color: ${hex(opts.linkColor)} } ") + rules.append("tt, code, samp, pre { font-family: '${css(opts.codeFont)}', monospace } ") + rules.append("pre { background: ${hex(opts.preBg)} } ") + rules.append("pre { color: ${hex(opts.preFg)} } ") + rules.append("code { background: ${hex(opts.codeBg)} } ") + rules.append("blockquote { border-left-color: ${hex(opts.quoteBorder)} } ") + rules.append("blockquote { color: ${hex(opts.quoteFg)} } ") + rules.append("th, td { border-color: ${hex(opts.tableBorder)} } ") + + return rules.toString().trim() + } + + fun defaults(style: SessionStyle) = MdStyle( + font = style.transcriptFont, + foreground = com.intellij.util.ui.UIUtil.getLabelForeground(), + background = style.editorScheme.defaultBackground, + linkColor = com.intellij.util.ui.JBUI.CurrentTheme.Link.Foreground.ENABLED, + codeBg = style.editorScheme.defaultBackground, + preBg = style.editorScheme.defaultBackground, + preFg = style.editorScheme.defaultForeground, + codeFont = style.editorFamily, + quoteBorder = com.intellij.ui.JBColor.border(), + quoteFg = com.intellij.util.ui.UIUtil.getContextHelpForeground(), + tableBorder = com.intellij.ui.JBColor.border(), + opaque = true, + ) +} + +internal data class MdStyle( + val font: java.awt.Font, + val foreground: Color, + val background: Color, + val linkColor: Color, + val codeBg: Color, + val preBg: Color, + val preFg: Color, + val codeFont: String, + val quoteBorder: Color, + val quoteFg: Color, + val tableBorder: Color, + val opaque: Boolean, +) 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 index 2f299325292..141a1f27ec3 100644 --- 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 @@ -1,61 +1,35 @@ package ai.kilocode.client.ui.md -import ai.kilocode.log.KiloLog -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 ai.kilocode.client.session.ui.SessionStyle 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 +/** Markdown rendering component. All public methods must be called on the EDT. */ +interface MdView { + val component: JComponent + + fun set(text: String) + fun append(delta: String) + fun clear() + fun applyStyle(style: SessionStyle) + fun resetStyles() + fun addLinkListener(listener: LinkListener) + fun removeLinkListener(listener: LinkListener) + + var font: Font + var foreground: Color + var background: Color + var linkColor: Color + var codeBg: Color + var preBg: Color + var preFg: Color + var codeFont: String + var quoteBorder: Color + var quoteFg: Color + var tableBorder: Color + var opaque: Boolean data class LinkEvent( val href: String, @@ -66,303 +40,8 @@ abstract class MdView private constructor() { 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() { - companion object { - private val LOG = KiloLog.create(HtmlImpl::class.java) - private 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", - ) - - private fun hex(c: Color): String = String.format("#%02x%02x%02x", c.red, c.green, c.blue) - - private fun css(text: String): String = text - .replace("\\", "\\\\") - .replace("'", "\\'") - .replace("\n", " ") - .replace("\r", " ") - } - - 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) { - if (fontOverride == value) return - fontOverride = value - markDirty() - } - - override var foreground: Color - get() = foregroundOverride ?: UIUtil.getLabelForeground() - set(value) { - if (foregroundOverride == value) return - foregroundOverride = value - markDirty() - } - - override var background: Color - get() = backgroundOverride ?: pane.background - set(value) { - if (backgroundOverride == value) return - backgroundOverride = value - if (opaqueState) pane.background = value - markDirty() - } - - override var linkColor: Color - get() = linkColorOverride ?: Color(0x58, 0x9D, 0xF6) - set(value) { - if (linkColorOverride == value) return - linkColorOverride = value - markDirty() - } - - override var codeBg: Color - get() = codeBgOverride ?: Color(0x3C, 0x3F, 0x41) - set(value) { - if (codeBgOverride == value) return - codeBgOverride = value - markDirty() - } - - override var preBg: Color - get() = preBgOverride ?: Color(0x2B, 0x2B, 0x2B) - set(value) { - if (preBgOverride == value) return - preBgOverride = value - markDirty() - } - - override var preFg: Color - get() = preFgOverride ?: Color(0xA9, 0xB7, 0xC6) - set(value) { - if (preFgOverride == value) return - preFgOverride = value - markDirty() - } - - override var codeFont: String - // _EditorFontNoLigatures_ is resolved by EditorCssFontResolver to the global editor font - get() = codeFontOverride ?: "_EditorFontNoLigatures_" - set(value) { - if (codeFontOverride == value) return - codeFontOverride = value - markDirty() - } - - override var quoteBorder: Color - get() = quoteBorderOverride ?: Color(0x55, 0x55, 0x55) - set(value) { - if (quoteBorderOverride == value) return - quoteBorderOverride = value - markDirty() - } - - override var quoteFg: Color - get() = quoteFgOverride ?: Color(0x99, 0x99, 0x99) - set(value) { - if (quoteFgOverride == value) return - quoteFgOverride = value - markDirty() - } - - override var tableBorder: Color - get() = tableBorderOverride ?: Color(0x55, 0x55, 0x55) - set(value) { - if (tableBorderOverride == value) return - tableBorderOverride = value - markDirty() - } - - override var opaque: Boolean - get() = opaqueState - set(value) { - if (opaqueState == value) return - 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) { - if (source.toString() == text) return - source.clear() - source.append(text) - syncHtml() - } - - override fun append(delta: String) { - if (delta.isEmpty()) return - source.append(delta) - syncHtml() - } - - override fun clear() { - if (source.isEmpty() && rendered.isEmpty() && pane.text.isEmpty()) return - 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()) syncHtml() - } - - private fun syncHtml() { - val body = renderer.render(parser.parse(source.toString())) - if (rendered == body && pane.text == "$body") return - 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 (err: Exception) { - LOG.warn("kind=markdown css=true failed message=${err.message} rules=$rules", err) - } - } - return sheet - } - - private fun buildOverrideRulesString(): String { - val rules = StringBuilder() - - val text = mutableListOf() - foregroundOverride?.let { text.add("color: ${hex(it)}") } - fontOverride?.let { - text.add("font-family: '${css(it.name)}', sans-serif") - text.add("font-size: ${it.size}pt") - if (it.isItalic) text.add("font-style: italic") - if (it.isBold) text.add("font-weight: bold") - } - if (text.isNotEmpty()) { - val rule = text.joinToString("; ") - for (tag in TAGS) rules.append("$tag { $rule } ") - } - - val body = mutableListOf() - if (!opaqueState) body.add("background: transparent") - 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: '${css(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() - } - } + fun markdown(): String + fun html(): String + fun overrideSheet(): String + fun simulateLink(href: String) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt new file mode 100644 index 00000000000..8646b317560 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt @@ -0,0 +1,11 @@ +package ai.kilocode.client.ui.md + +import ai.kilocode.client.session.ui.SessionStyle + +object MdViewFactory { + fun create(style: SessionStyle = SessionStyle.current()): MdView = hybrid(style) + + fun hybrid(style: SessionStyle = SessionStyle.current()): MdView = MdViewHybrid(style) + + fun html(style: SessionStyle = SessionStyle.current()): MdView = MdViewHtmlPane(style) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt new file mode 100644 index 00000000000..02dcbcf3f80 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt @@ -0,0 +1,290 @@ +package ai.kilocode.client.ui.md + +import ai.kilocode.client.session.ui.SessionStyle +import ai.kilocode.log.KiloLog +import com.intellij.ui.components.JBHtmlPane +import com.intellij.ui.components.JBHtmlPaneConfiguration +import com.intellij.ui.components.JBHtmlPaneStyleConfiguration +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 javax.swing.JComponent +import javax.swing.event.HyperlinkEvent +import javax.swing.text.html.StyleSheet + +@Suppress("UnstableApiUsage") +internal class MdViewHtmlPane( + style: SessionStyle = SessionStyle.current(), +) : MdView { + companion object { + private val LOG = KiloLog.create(MdViewHtmlPane::class.java) + } + + private val listeners = mutableListOf() + private val source = StringBuilder() + private var rendered = "" + private var style = style + + 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() + + 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 { + enableInlineCodeBackground = true + enableCodeBlocksBackground = true + }, + JBHtmlPaneConfiguration { + customStyleSheetProvider { buildOverrideStyleSheet() } + }, + ).apply { + isEditable = false + isOpaque = true + background = opts().background + + addHyperlinkListener { e -> + if (e.eventType != HyperlinkEvent.EventType.ACTIVATED) return@addHyperlinkListener + val href = e.description ?: return@addHyperlinkListener + val pt = (e.inputEvent as? java.awt.event.MouseEvent)?.point + dispatch(MdView.LinkEvent(href, pt)) + } + } + + override val component: JComponent get() = pane + + override var font: Font + get() = fontOverride ?: opts().font + set(value) { + if (fontOverride == value) return + fontOverride = value + markDirty() + } + + override var foreground: Color + get() = foregroundOverride ?: opts().foreground + set(value) { + if (foregroundOverride == value) return + foregroundOverride = value + markDirty() + } + + override var background: Color + get() = backgroundOverride ?: opts().background + set(value) { + if (backgroundOverride == value) return + backgroundOverride = value + if (opaqueState) pane.background = value + markDirty() + } + + override var linkColor: Color + get() = linkColorOverride ?: opts().linkColor + set(value) { + if (linkColorOverride == value) return + linkColorOverride = value + markDirty() + } + + override var codeBg: Color + get() = codeBgOverride ?: opts().codeBg + set(value) { + if (codeBgOverride == value) return + codeBgOverride = value + markDirty() + } + + override var preBg: Color + get() = preBgOverride ?: opts().preBg + set(value) { + if (preBgOverride == value) return + preBgOverride = value + markDirty() + } + + override var preFg: Color + get() = preFgOverride ?: opts().preFg + set(value) { + if (preFgOverride == value) return + preFgOverride = value + markDirty() + } + + override var codeFont: String + get() = codeFontOverride ?: opts().codeFont + set(value) { + if (codeFontOverride == value) return + codeFontOverride = value + markDirty() + } + + override var quoteBorder: Color + get() = quoteBorderOverride ?: opts().quoteBorder + set(value) { + if (quoteBorderOverride == value) return + quoteBorderOverride = value + markDirty() + } + + override var quoteFg: Color + get() = quoteFgOverride ?: opts().quoteFg + set(value) { + if (quoteFgOverride == value) return + quoteFgOverride = value + markDirty() + } + + override var tableBorder: Color + get() = tableBorderOverride ?: opts().tableBorder + set(value) { + if (tableBorderOverride == value) return + tableBorderOverride = value + markDirty() + } + + override var opaque: Boolean + get() = opaqueState + set(value) { + if (opaqueState == value) return + opaqueState = value + pane.isOpaque = value + if (value) pane.background = background + markDirty() + } + + override fun applyStyle(style: SessionStyle) { + if (this.style == style) return + this.style = style + if (opaqueState) pane.background = background + 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 = background + markDirty() + } + + override fun set(text: String) { + if (source.toString() == text) return + source.clear() + source.append(text) + syncHtml() + } + + override fun append(delta: String) { + if (delta.isEmpty()) return + source.append(delta) + syncHtml() + } + + override fun clear() { + if (source.isEmpty() && rendered.isEmpty() && pane.text.isEmpty()) return + source.clear() + rendered = "" + pane.text = "" + } + + override fun addLinkListener(listener: MdView.LinkListener) { + listeners.add(listener) + } + + override fun removeLinkListener(listener: MdView.LinkListener) { + listeners.remove(listener) + } + + override fun markdown(): String = source.toString() + + override fun html(): String = rendered + + override fun overrideSheet(): String = MdCommon.rules(opts()) + + override fun simulateLink(href: String) { + dispatch(MdView.LinkEvent(href)) + } + + private fun dispatch(event: MdView.LinkEvent) { + for (l in listeners) l.onLink(event) + } + + private fun markDirty() { + pane.reloadCssStylesheets() + if (source.isNotEmpty()) syncHtml() + } + + private fun syncHtml() { + val body = renderer.render(parser.parse(source.toString())) + if (rendered == body && pane.text == "$body") return + rendered = body + pane.text = "$body" + pane.caretPosition = 0 + } + + private fun buildOverrideStyleSheet(): StyleSheet { + val sheet = StyleSheet() + val rules = overrideSheet() + if (rules.isEmpty()) return sheet + try { + sheet.addRule(rules) + } catch (err: Exception) { + LOG.warn("kind=markdown css=true failed message=${err.message} rules=$rules", err) + } + return sheet + } + + private fun opts(): MdStyle { + val base = MdCommon.defaults(style) + return base.copy( + font = fontOverride ?: base.font, + foreground = foregroundOverride ?: base.foreground, + background = backgroundOverride ?: base.background, + linkColor = linkColorOverride ?: base.linkColor, + codeBg = codeBgOverride ?: base.codeBg, + preBg = preBgOverride ?: base.preBg, + preFg = preFgOverride ?: base.preFg, + codeFont = codeFontOverride ?: base.codeFont, + quoteBorder = quoteBorderOverride ?: base.quoteBorder, + quoteFg = quoteFgOverride ?: base.quoteFg, + tableBorder = tableBorderOverride ?: base.tableBorder, + opaque = opaqueState, + ) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt new file mode 100644 index 00000000000..88f8ef711e6 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt @@ -0,0 +1,428 @@ +package ai.kilocode.client.ui.md + +import ai.kilocode.client.session.ui.SessionStyle +import ai.kilocode.log.KiloLog +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.fileTypes.FileTypeManager +import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.openapi.project.ProjectManager +import com.intellij.ui.components.JBTextArea +import com.intellij.ui.components.JBHtmlPane +import com.intellij.ui.components.JBHtmlPaneConfiguration +import com.intellij.ui.components.JBHtmlPaneStyleConfiguration +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.JBUI +import org.commonmark.ext.autolink.AutolinkExtension +import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension +import org.commonmark.ext.gfm.tables.TablesExtension +import org.commonmark.node.AbstractVisitor +import org.commonmark.node.Block +import org.commonmark.node.Document +import org.commonmark.node.FencedCodeBlock +import org.commonmark.node.IndentedCodeBlock +import org.commonmark.node.Node +import org.commonmark.parser.Parser +import org.commonmark.renderer.html.HtmlRenderer +import java.awt.Color +import java.awt.Font +import javax.swing.Box +import javax.swing.BoxLayout +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.ScrollPaneConstants +import javax.swing.event.HyperlinkEvent +import javax.swing.text.html.StyleSheet + +@Suppress("UnstableApiUsage") +internal class MdViewHybrid( + style: SessionStyle = SessionStyle.current(), +) : MdView { + companion object { + private val LOG = KiloLog.create(MdViewHybrid::class.java) + } + + private val listeners = mutableListOf() + private val source = StringBuilder() + private var style = style + 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() + + 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 root = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = true + background = opts().background + } + + override val component: JComponent get() = root + + override var font: Font + get() = fontOverride ?: opts().font + set(value) { + if (fontOverride == value) return + fontOverride = value + syncStyle() + } + + override var foreground: Color + get() = foregroundOverride ?: opts().foreground + set(value) { + if (foregroundOverride == value) return + foregroundOverride = value + syncStyle() + } + + override var background: Color + get() = backgroundOverride ?: opts().background + set(value) { + if (backgroundOverride == value) return + backgroundOverride = value + syncStyle() + } + + override var linkColor: Color + get() = linkColorOverride ?: opts().linkColor + set(value) { + if (linkColorOverride == value) return + linkColorOverride = value + syncStyle() + } + + override var codeBg: Color + get() = codeBgOverride ?: opts().codeBg + set(value) { + if (codeBgOverride == value) return + codeBgOverride = value + syncStyle() + } + + override var preBg: Color + get() = preBgOverride ?: opts().preBg + set(value) { + if (preBgOverride == value) return + preBgOverride = value + syncStyle() + } + + override var preFg: Color + get() = preFgOverride ?: opts().preFg + set(value) { + if (preFgOverride == value) return + preFgOverride = value + syncStyle() + } + + override var codeFont: String + get() = codeFontOverride ?: opts().codeFont + set(value) { + if (codeFontOverride == value) return + codeFontOverride = value + syncStyle() + } + + override var quoteBorder: Color + get() = quoteBorderOverride ?: opts().quoteBorder + set(value) { + if (quoteBorderOverride == value) return + quoteBorderOverride = value + syncStyle() + } + + override var quoteFg: Color + get() = quoteFgOverride ?: opts().quoteFg + set(value) { + if (quoteFgOverride == value) return + quoteFgOverride = value + syncStyle() + } + + override var tableBorder: Color + get() = tableBorderOverride ?: opts().tableBorder + set(value) { + if (tableBorderOverride == value) return + tableBorderOverride = value + syncStyle() + } + + override var opaque: Boolean + get() = opaqueState + set(value) { + if (opaqueState == value) return + opaqueState = value + syncStyle() + } + + override fun applyStyle(style: SessionStyle) { + if (this.style == style) return + this.style = style + syncStyle() + } + + 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 + syncStyle() + } + + override fun set(text: String) { + if (source.toString() == text) return + source.clear() + source.append(text) + syncBlocks() + } + + override fun append(delta: String) { + if (delta.isEmpty()) return + source.append(delta) + syncBlocks() + } + + override fun clear() { + if (source.isEmpty() && rendered.isEmpty() && root.componentCount == 0) return + source.clear() + rendered = "" + root.removeAll() + root.revalidate() + root.repaint() + } + + override fun addLinkListener(listener: MdView.LinkListener) { + listeners.add(listener) + } + + override fun removeLinkListener(listener: MdView.LinkListener) { + listeners.remove(listener) + } + + override fun markdown(): String = source.toString() + + override fun html(): String = rendered + + override fun overrideSheet(): String = MdCommon.rules(opts()) + + override fun simulateLink(href: String) { + dispatch(MdView.LinkEvent(href)) + } + + private fun syncStyle() { + val opts = opts() + root.isOpaque = opts.opaque + if (opts.opaque) root.background = opts.background + syncBlocks() + } + + private fun syncBlocks() { + val text = source.toString() + val doc = parser.parse(text) + val body = renderer.render(doc) + rendered = body + root.removeAll() + if (text.isEmpty()) { + root.revalidate() + root.repaint() + return + } + val visitor = Visitor() + doc.accept(visitor) + root.revalidate() + root.repaint() + } + + private fun addGap() { + if (root.componentCount == 0) return + root.add(Box.createVerticalStrut(JBUI.scale(6))) + } + + private fun addBlock(component: JComponent) { + addGap() + component.alignmentX = JComponent.LEFT_ALIGNMENT + root.add(component) + } + + private fun htmlBlock(node: Node): JComponent { + val opts = opts() + val body = renderer.render(node) + return JBHtmlPane( + JBHtmlPaneStyleConfiguration { + enableInlineCodeBackground = true + enableCodeBlocksBackground = true + }, + JBHtmlPaneConfiguration { + customStyleSheetProvider { sheet() } + }, + ).apply { + isEditable = false + isOpaque = opts.opaque + background = opts.background + text = "$body" + addHyperlinkListener { e -> + if (e.eventType != HyperlinkEvent.EventType.ACTIVATED) return@addHyperlinkListener + val href = e.description ?: return@addHyperlinkListener + val pt = (e.inputEvent as? java.awt.event.MouseEvent)?.point + dispatch(MdView.LinkEvent(href, pt)) + } + } + } + + private fun codeBlock(text: String, lang: String?): JComponent { + val opts = opts() + val field = runCatching { CodeField(file(lang), opts, text) }.getOrElse { err -> + LOG.warn("kind=markdown codeEditor=true failed message=${err.message}", err) + textArea(text, opts) + } + return JBScrollPane(field).apply { + border = JBUI.Borders.empty() + isOpaque = opts.opaque + background = opts.preBg + viewport.background = opts.preBg + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + } + } + + private fun textArea(text: String, opts: MdStyle) = JBTextArea(text.trimEnd('\n')).apply { + isEditable = false + lineWrap = false + isOpaque = opts.opaque + background = opts.preBg + foreground = opts.preFg + font = Font(opts.codeFont, Font.PLAIN, style.editorSize) + border = JBUI.Borders.empty(6, 8) + } + + private inner class CodeField(file: FileType, opts: MdStyle, value: String) : + com.intellij.ui.EditorTextField(ProjectManager.getInstance().defaultProject, file) { + init { + setFontInheritedFromLAF(false) + font = Font(opts.codeFont, Font.PLAIN, style.editorSize) + text = value.trimEnd('\n') + isViewer = true + addSettingsProvider { ed -> + style.applyToEditor(ed) + ed.setBorder(JBUI.Borders.empty()) + ed.scrollPane.border = JBUI.Borders.empty() + ed.scrollPane.viewportBorder = JBUI.Borders.empty() + ed.backgroundColor = opts.preBg + ed.scrollPane.background = opts.preBg + ed.scrollPane.viewport.background = opts.preBg + ed.settings.isUseSoftWraps = false + ed.settings.isAdditionalPageAtBottom = false + ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED + } + } + } + + private fun file(lang: String?): FileType { + val key = lang?.trim()?.substringBefore(' ')?.lowercase().orEmpty() + val ext = when (key) { + "kt", "kotlin" -> "kt" + "js", "javascript" -> "js" + "ts", "typescript" -> "ts" + "tsx" -> "tsx" + "java" -> "java" + "py", "python" -> "py" + "sh", "bash", "shell" -> "sh" + "json" -> "json" + "xml" -> "xml" + "html" -> "html" + "css" -> "css" + "md", "markdown" -> "md" + else -> "" + } + if (ext.isEmpty()) return PlainTextFileType.INSTANCE + return FileTypeManager.getInstance().getFileTypeByExtension(ext) + } + + private fun dispatch(event: MdView.LinkEvent) { + for (l in listeners) l.onLink(event) + } + + private fun sheet(): StyleSheet { + val sheet = StyleSheet() + val rules = overrideSheet() + if (rules.isEmpty()) return sheet + try { + sheet.addRule(rules) + } catch (err: Exception) { + LOG.warn("kind=markdown css=true failed message=${err.message} rules=$rules", err) + } + return sheet + } + + private fun opts(): MdStyle { + val base = MdCommon.defaults(style) + return base.copy( + font = fontOverride ?: base.font, + foreground = foregroundOverride ?: base.foreground, + background = backgroundOverride ?: base.background, + linkColor = linkColorOverride ?: base.linkColor, + codeBg = codeBgOverride ?: base.codeBg, + preBg = preBgOverride ?: base.preBg, + preFg = preFgOverride ?: base.preFg, + codeFont = codeFontOverride ?: base.codeFont, + quoteBorder = quoteBorderOverride ?: base.quoteBorder, + quoteFg = quoteFgOverride ?: base.quoteFg, + tableBorder = tableBorderOverride ?: base.tableBorder, + opaque = opaqueState, + ) + } + + private inner class Visitor : AbstractVisitor() { + override fun visit(document: Document) { + visitChildren(document) + } + + override fun visit(code: FencedCodeBlock) { + addBlock(codeBlock(code.literal, code.info)) + } + + override fun visit(code: IndentedCodeBlock) { + addBlock(codeBlock(code.literal, null)) + } + + public override fun visitChildren(parent: Node) { + var child = parent.firstChild + while (child != null) { + val next = child.next + if (child is FencedCodeBlock || child is IndentedCodeBlock) child.accept(this) + if (child is Block && child !is FencedCodeBlock && child !is IndentedCodeBlock) addBlock(htmlBlock(child)) + child = next + } + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionRecoveryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionRecoveryTest.kt index e407fb61b49..edf175d53dc 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionRecoveryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionRecoveryTest.kt @@ -1,6 +1,9 @@ package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionState +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.QuestionInfoDto import ai.kilocode.rpc.dto.QuestionRequestDto @@ -18,6 +21,7 @@ class SessionRecoveryTest : SessionControllerTestBase() { super.setUp() // Set a pre-existing session in the fake API rpc.session = rpc.session.copy(id = "ses_test") + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) } fun `test pending permission is recovered on history load`() { @@ -30,7 +34,6 @@ class SessionRecoveryTest : SessionControllerTestBase() { ) ) - appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val m = controller("ses_test") flush() @@ -50,7 +53,6 @@ class SessionRecoveryTest : SessionControllerTestBase() { ) ) - appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val m = controller("ses_test") flush() @@ -70,7 +72,6 @@ class SessionRecoveryTest : SessionControllerTestBase() { ) ) - appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val m = controller("ses_test") flush() @@ -96,7 +97,6 @@ class SessionRecoveryTest : SessionControllerTestBase() { ) ) - appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val m = controller("ses_test") flush() @@ -110,7 +110,6 @@ class SessionRecoveryTest : SessionControllerTestBase() { fun `test busy status is seeded from statuses map`() { rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("busy")) - appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val m = controller("ses_test") flush() @@ -131,7 +130,6 @@ class SessionRecoveryTest : SessionControllerTestBase() { next = 5000L, )) - appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val m = controller("ses_test") flush() @@ -154,7 +152,6 @@ class SessionRecoveryTest : SessionControllerTestBase() { requestID = "req_xyz", )) - appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val m = controller("ses_test") flush() @@ -171,7 +168,6 @@ class SessionRecoveryTest : SessionControllerTestBase() { fun `test idle status in map leaves controller in Idle`() { rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("idle")) - appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val m = controller("ses_test") flush() @@ -187,7 +183,6 @@ class SessionRecoveryTest : SessionControllerTestBase() { fun `test missing status entry leaves controller in Idle`() { rpc.statuses.value = emptyMap() - appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val m = controller("ses_test") flush() @@ -211,7 +206,6 @@ class SessionRecoveryTest : SessionControllerTestBase() { ) ) - appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val m = controller("ses_test") flush() @@ -243,7 +237,6 @@ class SessionRecoveryTest : SessionControllerTestBase() { ) ) - appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val m = controller("ses_test") flush() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewFactoryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewFactoryTest.kt new file mode 100644 index 00000000000..51d5d68f165 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewFactoryTest.kt @@ -0,0 +1,27 @@ +package ai.kilocode.client.ui.md + +import ai.kilocode.client.session.ui.SessionStyle +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class MdViewFactoryTest : BasePlatformTestCase() { + fun `test create returns hybrid renderer`() { + assertInstanceOf(MdViewFactory.create(), MdViewHybrid::class.java) + } + + fun `test hybrid returns hybrid renderer`() { + assertInstanceOf(MdViewFactory.hybrid(), MdViewHybrid::class.java) + } + + fun `test html returns html pane renderer`() { + assertInstanceOf(MdViewFactory.html(), MdViewHtmlPane::class.java) + } + + fun `test create applies supplied session style`() { + val style = SessionStyle.create(family = "Courier New", size = 22) + val view = MdViewFactory.create(style) + + assertEquals("Courier New", view.font.name) + assertEquals(22, view.font.size) + assertEquals("Courier New", view.codeFont) + } +} 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 new file mode 100644 index 00000000000..15a14ed852b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt @@ -0,0 +1,77 @@ +package ai.kilocode.client.ui.md + +import ai.kilocode.client.session.ui.SessionStyle +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBScrollPane +import javax.swing.JPanel +import javax.swing.ScrollPaneConstants + +@Suppress("UnstableApiUsage") +class MdViewHybridTest : BasePlatformTestCase() { + private lateinit var view: MdView + + override fun setUp() { + super.setUp() + view = MdViewFactory.hybrid() + } + + fun `test set stores source`() { + view.set("hello **world**") + assertEquals("hello **world**", view.markdown()) + } + + fun `test append renders accumulated source`() { + view.append("hello ") + view.append("**world**") + assertEquals("hello **world**", view.markdown()) + assertTrue(view.html().contains("")) + } + + fun `test fenced code block creates horizontal scroll pane`() { + view.set("```kotlin\nval value = 1\n```") + val pane = scrolls().single() + + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, pane.horizontalScrollBarPolicy) + } + + fun `test clear resets source and components`() { + view.set("```\ncode\n```") + view.clear() + + assertEquals("", view.markdown()) + assertTrue(scrolls().isEmpty()) + } + + fun `test applyStyle updates current and future blocks`() { + val style = SessionStyle.create(family = "Courier New", size = 21) + + view.applyStyle(style) + view.set("hello") + + assertEquals("Courier New", view.font.name) + assertEquals(21, view.font.size) + assertTrue(view.overrideSheet().contains("Courier New")) + assertTrue(view.overrideSheet().contains("21pt")) + } + + fun `test resetStyles keeps content rendered`() { + view.set("hello **world**") + view.font = view.font.deriveFont(25f) + + view.resetStyles() + + assertEquals("hello **world**", view.markdown()) + assertTrue(view.html().contains("")) + } + + fun `test link listener receives simulated link`() { + val received = mutableListOf() + view.addLinkListener { received.add(it) } + + view.simulateLink("https://example.com") + + assertEquals("https://example.com", received.single().href) + } + + private fun scrolls(): List = (view.component as JPanel).components.filterIsInstance() +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewLoggingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewLoggingTest.kt index 5eaac3e666f..f5d698cf10e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewLoggingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewLoggingTest.kt @@ -6,7 +6,7 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase class MdViewLoggingTest : BasePlatformTestCase() { fun `test invalid font family does not throw while building override sheet`() { - val view = MdView.html() + val view = MdViewFactory.html() view.codeFont = "broken'font" view.set("`x`") 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 025fb5787c7..3ca087bb135 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 @@ -1,11 +1,12 @@ package ai.kilocode.client.ui.md +import ai.kilocode.client.session.ui.SessionStyle import com.intellij.testFramework.fixtures.BasePlatformTestCase import java.awt.Color import java.awt.Font /** - * Tests for [MdView] created via [MdView.html]. + * Tests for the fallback HTML [MdView]. * * Uses [BasePlatformTestCase] to get a real IntelliJ Application so that * JBHtmlPane initialisation works correctly. @@ -17,7 +18,7 @@ class MdViewTest : BasePlatformTestCase() { override fun setUp() { super.setUp() - view = MdView.html() + view = MdViewFactory.html() } // ---- set ---- @@ -211,10 +212,13 @@ class MdViewTest : BasePlatformTestCase() { assertTrue(view.html().contains("Done.")) } - // ---- style overrides (empty by default) ---- + // ---- style overrides ---- - fun `test no overrides produces empty override sheet`() { - assertEquals("", view.overrideSheet()) + fun `test override sheet includes session style defaults`() { + val style = SessionStyle.current() + + assertTrue(view.overrideSheet().contains(style.editorFamily)) + assertTrue(view.overrideSheet().contains("${style.editorSize}pt")) } // ---- style overrides appear in override sheet when set ---- @@ -309,16 +313,16 @@ class MdViewTest : BasePlatformTestCase() { assertEquals("", view.markdown()) } - // ---- default codeFont uses editor font placeholder ---- + // ---- default codeFont uses session style ---- + + fun `test default codeFont is session editor font`() { + val style = SessionStyle.current() - 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")) + assertEquals(style.editorFamily, view.codeFont) } - 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()) + fun `test default override sheet includes session style before any set`() { + assertTrue(view.overrideSheet().contains(SessionStyle.current().editorFamily)) } // ---- background sets component background ---- @@ -384,7 +388,7 @@ class MdViewTest : BasePlatformTestCase() { fun `test resetStyles clears foreground override`() { view.foreground = Color.RED view.resetStyles() - assertEquals("", view.overrideSheet()) + assertFalse(view.overrideSheet().contains("#ff0000")) } fun `test resetStyles clears all overrides`() { @@ -399,7 +403,9 @@ class MdViewTest : BasePlatformTestCase() { view.tableBorder = Color.YELLOW view.font = Font("Arial", Font.PLAIN, 18) view.resetStyles() - assertEquals("", view.overrideSheet()) + val sheet = view.overrideSheet() + assertFalse(sheet.contains("Arial")) + assertTrue(sheet.contains(SessionStyle.current().editorFamily)) } fun `test resetStyles restores opaque to true`() {