fix(jetbrains): expand markdown code blocks

This commit is contained in:
kirillk
2026-06-01 17:20:11 -04:00
parent 9d0b9d9ebc
commit 50f4d28e07
4 changed files with 224 additions and 10 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Show JetBrains markdown code blocks as multiline boxed editors that expand to their full height.
@@ -79,6 +79,20 @@ object SessionUiStyle {
const val USER_BORDER_HORIZONTAL_PADDING = 12
}
/** Markdown code block geometry inside assistant messages. */
object Code {
const val BLOCK_GAP = 6
const val MIN_ROWS = 1
const val BORDER_WIDTH = 1
const val VIEWPORT_TOP_PADDING = 6
const val VIEWPORT_HORIZONTAL_PADDING = 8
const val VIEWPORT_BOTTOM_PADDING = 0
const val SCROLLBAR_HEIGHT = 12
const val WIDTH_PADDING = 16
fun topPadding(): Int = VIEWPORT_TOP_PADDING + SCROLLBAR_HEIGHT
}
/** Permission card command preview limits. */
object Permission {
const val COMMAND_LINES = 3
@@ -1,7 +1,9 @@
package ai.kilocode.client.ui.md
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.log.KiloLog
import com.intellij.openapi.editor.EditorFactory
import com.intellij.openapi.fileTypes.FileType
import com.intellij.openapi.fileTypes.FileTypeManager
import com.intellij.openapi.fileTypes.PlainTextFileType
@@ -24,6 +26,7 @@ import org.commonmark.node.Node
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
import java.awt.Color
import java.awt.Dimension
import java.awt.Font
import javax.swing.Box
import javax.swing.BoxLayout
@@ -265,7 +268,7 @@ internal class MdViewHybrid(
private fun addGap() {
if (root.componentCount == 0) return
root.add(Box.createVerticalStrut(JBUI.scale(6)))
root.add(Box.createVerticalStrut(JBUI.scale(SessionUiStyle.View.Code.BLOCK_GAP)))
}
private fun addBlock(component: JComponent) {
@@ -301,20 +304,70 @@ internal class MdViewHybrid(
private fun codeBlock(text: String, lang: String?): JComponent {
val opts = opts()
val value = text.trimEnd('\n')
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()
val height = codeHeight(field, value)
val width = codeWidth(field, value)
field.preferredSize = Dimension(width, height)
field.minimumSize = Dimension(0, height)
field.maximumSize = Dimension(Int.MAX_VALUE, height)
return object : JBScrollPane(field) {
override fun doLayout() {
super.doLayout()
val view = viewport.view ?: return
val size = viewport.extentSize
if (size.height <= 0 || view.height == size.height) return
view.setSize(view.width.coerceAtLeast(size.width), size.height)
}
}.apply {
border = JBUI.Borders.customLine(opts.tableBorder, SessionUiStyle.View.Code.BORDER_WIDTH)
viewportBorder = JBUI.Borders.empty(
SessionUiStyle.View.Code.topPadding(),
SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING,
SessionUiStyle.View.Code.VIEWPORT_BOTTOM_PADDING,
SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING,
)
isOpaque = opts.opaque
background = opts.preBg
viewport.background = opts.preBg
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER
isWheelScrollingEnabled = true
setOverlappingScrollBar(false)
horizontalScrollBar.preferredSize = Dimension(0, JBUI.scale(SessionUiStyle.View.Code.SCROLLBAR_HEIGHT))
horizontalScrollBar.isOpaque = true
verticalScrollBar.preferredSize = JBUI.emptySize()
val pad = viewportBorder.getBorderInsets(this)
val size = height + insets.top + insets.bottom + pad.top + pad.bottom + horizontalScrollBar.preferredSize.height
preferredSize = Dimension(0, size)
minimumSize = Dimension(0, size)
maximumSize = Dimension(Int.MAX_VALUE, size)
}
}
private fun codeWidth(component: JComponent, text: String): Int {
val metrics = component.getFontMetrics(component.font)
val width = text.lineSequence().maxOfOrNull { metrics.stringWidth(it) } ?: 0
return width + JBUI.scale(SessionUiStyle.View.Code.WIDTH_PADDING)
}
private fun codeHeight(component: JComponent, text: String): Int {
val count = text.lineSequence().count()
val rows = count.coerceAtLeast(SessionUiStyle.View.Code.MIN_ROWS)
val field = component as? CodeField
if (field != null) {
field.ensureWillComputePreferredSize()
val ed = field.getEditor(false)
val line = ed?.lineHeight ?: component.getFontMetrics(component.font).height
return maxOf(field.preferredSize.height, line * rows)
}
val line = component.getFontMetrics(component.font).height
return line * rows
}
private fun textArea(text: String, opts: MdStyle) = JBTextArea(text.trimEnd('\n')).apply {
isEditable = false
lineWrap = false
@@ -322,16 +375,23 @@ internal class MdViewHybrid(
background = opts.preBg
foreground = opts.preFg
font = Font(opts.codeFont, Font.PLAIN, style.editorSize)
border = JBUI.Borders.empty(6, 8)
border = JBUI.Borders.empty(
SessionUiStyle.View.Code.VIEWPORT_TOP_PADDING,
SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING,
)
}
private inner class CodeField(file: FileType, opts: MdStyle, value: String) :
com.intellij.ui.EditorTextField(ProjectManager.getInstance().defaultProject, file) {
com.intellij.ui.EditorTextField(
EditorFactory.getInstance().createDocument(value.trimEnd('\n')),
ProjectManager.getInstance().defaultProject,
file,
true,
false,
) {
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())
@@ -342,7 +402,8 @@ internal class MdViewHybrid(
ed.scrollPane.viewport.background = opts.preBg
ed.settings.isUseSoftWraps = false
ed.settings.isAdditionalPageAtBottom = false
ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED
ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER
}
}
}
@@ -1,8 +1,11 @@
package ai.kilocode.client.ui.md
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.EditorTextField
import com.intellij.ui.components.JBScrollPane
import java.awt.BorderLayout
import javax.swing.JPanel
import javax.swing.ScrollPaneConstants
@@ -27,11 +30,131 @@ class MdViewHybridTest : BasePlatformTestCase() {
assertTrue(view.html().contains("<strong>"))
}
fun `test fenced code block creates horizontal scroll pane`() {
fun `test fenced code block shows horizontal scrollbar as needed`() {
view.set("```kotlin\nval value = 1\n```")
val pane = scrolls().single()
assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, pane.horizontalScrollBarPolicy)
assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, pane.verticalScrollBarPolicy)
assertTrue(pane.isWheelScrollingEnabled)
assertTrue(pane.horizontalScrollBar.preferredSize.height > 0)
assertTrue(pane.horizontalScrollBar.isOpaque)
assertFalse(pane.isOverlappingScrollBar)
assertEquals(0, pane.verticalScrollBar.preferredSize.width)
}
fun `test fenced code block preserves multiline editor text and height`() {
view.set("```kotlin\nval one = 1\nval two = 2\nval three = 3\n```")
val pane = scrolls().single()
val editor = editors().single()
val line = editor.getFontMetrics(editor.font).height
val ins = pane.insets
val pad = pane.viewportBorder.getBorderInsets(pane)
val bar = pane.horizontalScrollBar.preferredSize.height
assertEquals("val one = 1\nval two = 2\nval three = 3", editor.text)
assertEquals(editor.preferredSize.height + ins.top + ins.bottom + pad.top + pad.bottom + bar, pane.preferredSize.height)
assertTrue(pane.preferredSize.height >= line * 3)
}
fun `test fenced code block horizontal scrollbar has no bottom padding`() {
view.set("```kotlin\n${"x".repeat(500)}\n```")
val pane = scrolls().single()
val pad = pane.viewportBorder.getBorderInsets(pane)
assertEquals(SessionUiStyle.View.Code.VIEWPORT_BOTTOM_PADDING, pad.bottom)
assertTrue(pane.horizontalScrollBar.preferredSize.height > 0)
}
fun `test short code block top padding balances hidden scrollbar space`() {
view.set("```text\n[ALICE, ANNA]\n```")
val pane = scrolls().single()
val pad = pane.viewportBorder.getBorderInsets(pane)
assertTrue(pad.top > pane.horizontalScrollBar.preferredSize.height)
assertEquals(SessionUiStyle.View.Code.VIEWPORT_BOTTOM_PADDING, pad.bottom)
}
fun `test fenced code block height is not capped`() {
val code = (1..24).joinToString("\n") { "val value$it = $it" }
view.set("```kotlin\n$code\n```")
val pane = scrolls().single()
val editor = editors().single()
val line = editor.getFontMetrics(editor.font).height
assertTrue(pane.preferredSize.height >= line * 24)
}
fun `test fenced code block lays out to full editor height`() {
val code = (1..30).joinToString("\n") { "val value$it = $it" }
view.set("```kotlin\n$code\n```")
val pane = scrolls().single()
val editor = editors().single()
layout(width = 420)
assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, pane.verticalScrollBarPolicy)
assertTrue("scroll pane should use its full preferred height", pane.height >= pane.preferredSize.height)
assertTrue("editor should not be clipped vertically", editor.height >= editor.preferredSize.height)
}
fun `test streaming fenced code block grows to full height`() {
view.append("```java\nclass Example {\n")
val initial = scrolls().single().preferredSize.height
view.append((1..20).joinToString("\n", postfix = "\n") { "void method$it() {}" })
view.append("}\n```")
val pane = scrolls().single()
val editor = editors().single()
layout(width = 420)
assertTrue("code block should grow after streamed lines", pane.preferredSize.height > initial)
assertTrue("streamed editor should not be clipped vertically", editor.height >= editor.preferredSize.height)
}
fun `test java code block with blank lines fits vertically`() {
val code = """
import java.util.List;
import java.util.stream.Collectors;
public class StreamsExample {
public static void main(String[] args) {
List<String> names = List.of("Alice", "Bob", "Charlie", "David", "Anna");
List<String> result = names.stream()
.filter(name -> name.startsWith("A"))
.map(String::toUpperCase)
.collect(Collectors.toList());
System.out.println(result);
}
}
""".trimIndent()
view.set("```java\n$code\n```")
val pane = scrolls().single()
val editor = editors().single()
layout(width = 420)
val line = editor.getFontMetrics(editor.font).height
val rows = editor.text.lineSequence().count()
assertTrue("java editor should reserve every document line", editor.preferredSize.height >= line * rows)
assertTrue("java editor should not be clipped vertically", editor.height >= editor.preferredSize.height)
assertTrue("java code block should not clip vertically", pane.height >= pane.preferredSize.height)
}
fun `test fenced code block width is bounded and boxed`() {
view.set("```kotlin\n${"x".repeat(500)}\n```")
val pane = scrolls().single()
val editor = editors().single()
val ins = pane.border.getBorderInsets(pane)
assertEquals(0, pane.preferredSize.width)
assertTrue(editor.preferredSize.width > pane.preferredSize.width)
assertTrue(pane.maximumSize.width > 1000)
assertTrue(ins.top > 0)
assertTrue(ins.left > 0)
assertEquals(pane.background, pane.viewport.background)
}
fun `test clear resets source and components`() {
@@ -74,4 +197,15 @@ class MdViewHybridTest : BasePlatformTestCase() {
}
private fun scrolls(): List<JBScrollPane> = (view.component as JPanel).components.filterIsInstance<JBScrollPane>()
private fun editors(): List<EditorTextField> = scrolls().mapNotNull { it.viewport.view as? EditorTextField }
private fun layout(width: Int) {
val host = JPanel(BorderLayout())
host.add(view.component, BorderLayout.CENTER)
host.setSize(width, view.component.preferredSize.height)
host.doLayout()
view.component.doLayout()
scrolls().forEach { it.doLayout() }
}
}