diff --git a/.changeset/quiet-code-boxes.md b/.changeset/quiet-code-boxes.md new file mode 100644 index 00000000000..e88a80fa4e3 --- /dev/null +++ b/.changeset/quiet-code-boxes.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show JetBrains markdown code blocks as multiline boxed editors that expand to their full height. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt index 433ad686227..da11b8c78c1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt @@ -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 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 index fe899a40e80..b8f42a8dabc 100644 --- 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 @@ -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 } } } 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 da92e839eeb..0e9670737c5 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 @@ -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("")) } - 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 names = List.of("Alice", "Bob", "Charlie", "David", "Anna"); + + List 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 = (view.component as JPanel).components.filterIsInstance() + + private fun editors(): List = 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() } + } }