From 7749efdbf2b7d2f93c06bee8687a4c749351fcc2 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 10 Jun 2026 18:48:33 -0400 Subject: [PATCH 01/16] fix(jetbrains): order editor disposal fallback --- .../kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt | 2 +- .../src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt index 81744bfb98b..a78246d0605 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt @@ -209,10 +209,10 @@ class ToolBody private constructor( val disposable = Disposer.newDisposable("Tool body") val body = runCatching { val field = ToolField(preview(tool), SessionEditorStyle.current()).also { ed -> - ed.setDisposedWith(disposable) Disposer.register(disposable) { ed.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) } + ed.setDisposedWith(disposable) } ToolBody(null, field, pane(field, true), disposable) }.getOrElse { err -> 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 cd03b91b5c1..a245c1e0100 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 @@ -498,10 +498,10 @@ internal class MdViewHybrid( val value = text.trimEnd('\n') val field = runCatching { CodeField(file, opts, text).also { ed -> - ed.setDisposedWith(disposable) Disposer.register(disposable) { ed.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) } + ed.setDisposedWith(disposable) selection?.register(ed, disposable) } }.getOrElse { err -> From bb31723e7353c0649b9854812f9f803e04d92156 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 10 Jun 2026 20:21:39 -0400 Subject: [PATCH 02/16] fix(jetbrains): polish session header controls --- .changeset/jetbrains-session-icons.md | 5 ++ .../client/session/ui/header/RotatedIcon.kt | 27 ------- .../session/ui/header/SessionHeaderPanel.kt | 77 ++++++++++++++----- .../client/session/views/ReasoningView.kt | 2 +- .../client/session/views/SessionViewIcons.kt | 5 +- .../views/base/AbstractSessionPartView.kt | 2 +- .../views/question/QuestionResultView.kt | 2 +- .../ai/kilocode/client/ui/FilledBadgeIcon.kt | 5 +- .../resources/messages/KiloBundle.properties | 1 + .../ui/header/SessionHeaderPanelTest.kt | 32 +++++--- .../session/views/QuestionResultViewTest.kt | 31 ++++++++ .../client/session/views/ReasoningViewTest.kt | 21 +++++ .../views/base/AbstractSessionPartViewTest.kt | 19 +++++ 13 files changed, 167 insertions(+), 62 deletions(-) create mode 100644 .changeset/jetbrains-session-icons.md delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/RotatedIcon.kt diff --git a/.changeset/jetbrains-session-icons.md b/.changeset/jetbrains-session-icons.md new file mode 100644 index 00000000000..7787da11995 --- /dev/null +++ b/.changeset/jetbrains-session-icons.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Polish session header controls and align session view icons. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/RotatedIcon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/RotatedIcon.kt deleted file mode 100644 index e4f37d69334..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/RotatedIcon.kt +++ /dev/null @@ -1,27 +0,0 @@ -package ai.kilocode.client.session.ui.header - -import java.awt.Component -import java.awt.Graphics -import java.awt.Graphics2D -import java.awt.geom.AffineTransform -import javax.swing.Icon - -internal class RotatedIcon(private val base: Icon) : Icon { - override fun getIconWidth(): Int = base.iconWidth - - override fun getIconHeight(): Int = base.iconHeight - - override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) { - val g2 = g.create() as Graphics2D - try { - val tx = AffineTransform() - tx.translate((x + iconWidth / 2.0), (y + iconHeight / 2.0)) - tx.rotate(Math.PI) - tx.translate((-iconWidth / 2.0), (-iconHeight / 2.0)) - g2.transform(tx) - base.paintIcon(c, g2, 0, 0) - } finally { - g2.dispose() - } - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt index c92b1a255b0..c4cce829697 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt @@ -8,8 +8,10 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.controller.SessionController import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.todo.TodoListPanel +import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack import ai.kilocode.rpc.dto.TodoDto import ai.kilocode.rpc.dto.TokensDto import com.intellij.icons.AllIcons @@ -42,8 +44,6 @@ class SessionHeaderPanel( companion object { private val COMPRESS_ICON: Icon = IconLoader.getIcon("/icons/compress.svg", SessionHeaderPanel::class.java) - private val CHEVRON_ICON: Icon = IconLoader.getIcon("/icons/chevron-down.svg", SessionHeaderPanel::class.java) - private val CHEVRON_UP_ICON: Icon = RotatedIcon(CHEVRON_ICON) private val UP_ICON: Icon = IconLoader.getIcon("/icons/arrow-up.svg", SessionHeaderPanel::class.java) private val DOWN_ICON: Icon = IconLoader.getIcon("/icons/arrow-down-to-line.svg", SessionHeaderPanel::class.java) private const val TOUCH_BEGIN = 2 @@ -60,15 +60,20 @@ class SessionHeaderPanel( private val todoList = TodoListPanel() private val compact = HoverIcon().apply { icon = COMPRESS_ICON + cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR) toolTipText = KiloBundle.message("session.header.compact.description") accessibleContext.accessibleName = KiloBundle.message("session.header.compact") addActionListener { controller.compact() } } - private val expand = HoverIcon().apply { - icon = CHEVRON_ICON + private val expand = JBLabel().apply { + cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR) toolTipText = KiloBundle.message("session.header.expand") accessibleContext.accessibleName = KiloBundle.message("session.header.expand") - addActionListener { toggle() } + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(event: MouseEvent) { + toggle() + } + }) } private val timeline = TimelinePanel() private val viewport = JViewport().apply { @@ -97,16 +102,18 @@ class SessionHeaderPanel( iconTextGap = UiStyle.Gap.xs() } private val top = BorderLayoutPanel() - private val right = JPanel(FlowLayout(FlowLayout.RIGHT, UiStyle.Gap.md(), 0)).apply { - isOpaque = false - add(cost) - add(context) - add(compact) - add(expand) - } + private val center = BorderLayoutPanel().apply { + border = JBUI.Borders.empty(0, UiStyle.Gap.md(), 0, 0) + } + private val right = Stack.horizontal() + .next(cost) + .gap(UiStyle.Gap.xl()) + .next(context) + .gap(UiStyle.Gap.sm()) + .next(compact) private val tokens = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { isOpaque = false - border = JBUI.Borders.empty(UiStyle.Gap.sm(), 0, 0, 0) + border = JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.pad(), 0, UiStyle.Gap.pad()) add(tokenTitle) add(Box.createHorizontalStrut(UiStyle.Gap.md())) add(input) @@ -141,13 +148,16 @@ class SessionHeaderPanel( add(todoBox) } private var style = SessionEditorStyle.current() + private var costValue = "" init { isOpaque = true updateUI() - top.add(title, BorderLayout.CENTER) - top.add(right, BorderLayout.EAST) + center.add(title, BorderLayout.CENTER) + center.add(right, BorderLayout.EAST) + top.add(expand, BorderLayout.WEST) + top.add(center, BorderLayout.CENTER) add(top, BorderLayout.NORTH) timeline.addMouseListener(object : MouseAdapter() { override fun mousePressed(event: MouseEvent) { @@ -209,7 +219,7 @@ class SessionHeaderPanel( super.updateUI() border = JBUI.Borders.compound( JBUI.Borders.customLine(JBUI.CurrentTheme.ToolWindow.borderColor(), 1, 0, 1, 0), - JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad(), UiStyle.Gap.sm(), UiStyle.Gap.pad()), + JBUI.Borders.empty(), ) } @@ -227,7 +237,7 @@ class SessionHeaderPanel( syncExpanded(expanded()) - set(cost, money(header.cost)) + setCost(money(header.cost)) set(context, contextText(header.context)) context.toolTipText = contextTip(header.context) setTokens(header.tokens) @@ -247,6 +257,10 @@ class SessionHeaderPanel( background = style.editorBackground foreground = style.editorForeground top.background = style.editorBackground + top.isOpaque = true + top.border = JBUI.Borders.empty(UiStyle.Gap.md(), UiStyle.Gap.sm(), UiStyle.Gap.md(), UiStyle.Gap.sm()) + center.background = style.editorBackground + center.isOpaque = true right.background = style.editorBackground tokens.background = style.editorBackground todoRow.background = style.editorBackground @@ -257,6 +271,9 @@ class SessionHeaderPanel( title.foreground = style.editorForeground cost.font = style.regularFont cost.foreground = style.editorForeground + cost.icon = costValue.takeIf { it.isNotBlank() }?.let { + FilledBadgeIcon(it, style.editorForeground, style.editorBackground, style.regularFont) + } context.font = style.regularFont context.foreground = style.editorForeground todos.font = style.smallFont @@ -279,7 +296,11 @@ class SessionHeaderPanel( internal fun titleText(): String = title.text - internal fun costText(): String = cost.text + internal fun costText(): String = costValue + + internal fun costTip() = cost.toolTipText + + internal fun costIcon() = cost.icon internal fun contextText(): String = context.text @@ -392,6 +413,18 @@ class SessionHeaderPanel( tokens.isVisible = total > 0 } + private fun setCost(value: String?) { + costValue = value.orEmpty() + cost.text = "" + cost.icon = costValue.takeIf { it.isNotBlank() }?.let { + FilledBadgeIcon(it, style.editorForeground, style.editorBackground, style.regularFont) + } + val tip = costValue.takeIf { it.isNotBlank() }?.let { KiloBundle.message("session.header.cost.tooltip", it) } + cost.toolTipText = tip + cost.accessibleContext.accessibleName = tip + cost.isVisible = costValue.isNotBlank() + } + private fun syncTodos(items: List) { val total = items.size val done = items.count { it.status == "completed" } @@ -447,7 +480,10 @@ class SessionHeaderPanel( private fun collapse(): Boolean { val attached = body.parent === this - if (!attached) return false + if (!attached) { + setExpand(false) + return false + } remove(body) setExpand(false) return attached @@ -455,7 +491,8 @@ class SessionHeaderPanel( private fun setExpand(expanded: Boolean) { val key = if (expanded) "session.header.collapse" else "session.header.expand" - expand.icon = if (expanded) CHEVRON_UP_ICON else CHEVRON_ICON + expand.text = "" + expand.icon = if (expanded) AllIcons.General.ChevronDown else AllIcons.General.ChevronRight expand.toolTipText = KiloBundle.message(key) expand.accessibleContext.accessibleName = KiloBundle.message(key) } 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 21afb1fcab7..796e18bddbb 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 @@ -317,7 +317,7 @@ class ReasoningBody( private fun reasoningParts(selection: SessionSelection? = null): ReasoningParts { val title = JBLabel(KiloBundle.message("session.part.reasoning")).apply { foreground = UiStyle.Colors.weak() } - val icon = JBLabel(SessionViewIcons.eye).apply { foreground = UiStyle.Colors.weak() } + val icon = JBLabel(SessionViewIcons.brain).apply { foreground = UiStyle.Colors.weak() } val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { isOpaque = false add(icon, BorderLayout.WEST) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt index c59f5d371fb..4ce4d3b423d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt @@ -1,15 +1,18 @@ package ai.kilocode.client.session.views import com.intellij.openapi.util.IconLoader +import javax.swing.Icon object SessionViewIcons { val brain = icon("brain") val bubble = icon("bubble-5") val bulletList = icon("bullet-list") val checklist = icon("checklist") - val chevronDown = icon("chevron-down") + val chevronDown: Icon = icon("chevron-down") val chevronLeft = icon("chevron-left") val chevronRight = icon("chevron-right") + val chevronCollapsed: Icon = chevronRight + val chevronExpanded: Icon = chevronDown val code = icon("code") val codeLines = icon("code-lines") val console = icon("console") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt index ac93b08e834..df67cbbca67 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt @@ -157,7 +157,7 @@ abstract class AbstractSessionPartView( } private fun syncArrow(): Boolean { - val icon = if (isExpanded()) SessionViewIcons.chevronDown else SessionViewIcons.chevronRight + val icon = if (isExpanded()) SessionViewIcons.chevronExpanded else SessionViewIcons.chevronCollapsed if (arrow.icon === icon) return false arrow.icon = icon return true diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt index f1c241141f8..0b50d798269 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt @@ -285,7 +285,7 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = } private fun syncArrow() { - arrow.icon = if (isExpanded()) SessionViewIcons.chevronDown else SessionViewIcons.chevronRight + arrow.icon = if (isExpanded()) SessionViewIcons.chevronExpanded else SessionViewIcons.chevronCollapsed } override fun setHovered(value: Boolean) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/FilledBadgeIcon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/FilledBadgeIcon.kt index 11e0e961a6d..8987154fa24 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/FilledBadgeIcon.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/FilledBadgeIcon.kt @@ -4,6 +4,7 @@ import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI import java.awt.Color import java.awt.Component +import java.awt.Font import java.awt.Graphics import java.awt.Graphics2D import java.awt.RenderingHints @@ -14,9 +15,9 @@ internal class FilledBadgeIcon( internal val text: String, private val bg: Color, private val fg: Color, + private val font: Font = JBFont.small(), ) : Icon { override fun getIconWidth(): Int { - val font = JBFont.small() val width = font.getStringBounds(text, FontRenderContext(null, true, true)).width.toInt() return width + UiStyle.Gap.lg() * 2 } @@ -31,7 +32,7 @@ internal class FilledBadgeIcon( g2.color = bg g2.fillRoundRect(0, 0, iconWidth, iconHeight, iconHeight, iconHeight) g2.color = fg - g2.font = JBFont.small() + g2.font = font val fm = g2.fontMetrics val base = (iconHeight + fm.ascent - fm.descent) / 2 g2.drawString(text, UiStyle.Gap.lg(), base) diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 9141c1b9a82..80e5e0ed527 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -119,6 +119,7 @@ session.header.compact=Compact session session.header.compact.description=Summarize the session to reduce context size session.header.expand=Show session metrics session.header.collapse=Hide session metrics +session.header.cost.tooltip={0} spent in this session session.header.todos.progress={0}/{1} todos complete session.header.todos.done=All {0} todos complete session.header.todos.toggle=Toggle to-dos diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt index 599f2ebed09..960a0c9b90f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt @@ -17,7 +17,9 @@ import ai.kilocode.rpc.dto.PartTimeDto import ai.kilocode.rpc.dto.ProviderDto import ai.kilocode.rpc.dto.TodoDto import ai.kilocode.rpc.dto.TokensDto +import com.intellij.icons.AllIcons import com.intellij.ide.util.PropertiesComponent +import java.awt.Cursor import java.awt.Color import java.awt.Point import java.awt.event.MouseEvent @@ -59,6 +61,7 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { assertFalse(panel.isExpanded()) assertEquals("Generated title", panel.titleText()) assertEquals("$0.07", panel.costText()) + assertEquals("$0.07 spent in this session", panel.costTip()) assertEquals("1%", panel.contextText()) assertEquals("Tokens 13.7K 2.5K cache write 25 cache read 75", panel.tokenText()) assertEquals("Tokens used by the latest assistant response: input, output, cache writes, and cache reads.", panel.tokenTip()) @@ -73,7 +76,11 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { List(panel.foregrounds().size) { style.editorForeground }, panel.foregrounds(), ) - assertNotNull(panel.expandButton().icon) + assertEquals("", panel.expandButton().text) + assertSame(AllIcons.General.ChevronRight, panel.expandButton().icon) + assertEquals(Cursor.HAND_CURSOR, panel.expandButton().cursor.type) + assertNotNull(panel.costIcon()) + assertNotSame(panel.compactButton().parent, panel.expandButton().parent) } fun `test compact button follows eligibility and invokes controller`() { @@ -96,7 +103,7 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { val c = promptedHeader() val panel = SessionHeaderPanel(c, parent) - panel.expandButton().doClick() + click(panel.expandButton()) assertTrue(panel.isExpanded()) assertTrue(panel.todoVisible()) assertFalse(panel.todoListVisible()) @@ -184,7 +191,7 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { val bar = panel.contextBar() assertFalse(panel.isExpanded()) - panel.expandButton().doClick() + click(panel.expandButton()) assertTrue(panel.isExpanded()) assertSame(body, panel.bodyPanel()) @@ -261,7 +268,7 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { assertNull(panel.timelineToolTip()) assertEquals(-1, panel.timelineHover()) - panel.expandButton().doClick() + click(panel.expandButton()) assertFalse(panel.isExpanded()) assertSame(body, panel.bodyPanel()) @@ -297,26 +304,33 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { assertFalse(panel.isExpanded()) assertEquals("Show session metrics", panel.expandTip()) + assertEquals("", panel.expandButton().text) + assertSame(AllIcons.General.ChevronRight, panel.expandButton().icon) + assertNotSame(panel.compactButton().parent, panel.expandButton().parent) - panel.expandButton().doClick() + click(panel.expandButton()) emit(ChatEventDto.SessionUpdated("ses_test", session("ses_test", title = "New title"))) assertTrue(panel.isExpanded()) assertEquals("Hide session metrics", panel.expandTip()) + assertEquals("", panel.expandButton().text) + assertSame(AllIcons.General.ChevronDown, panel.expandButton().icon) - panel.expandButton().doClick() + click(panel.expandButton()) emit(ChatEventDto.MessageUpdated("ses_test", assistant(cost = 0.2))) assertFalse(panel.isExpanded()) assertEquals("Show session metrics", panel.expandTip()) + assertEquals("", panel.expandButton().text) + assertSame(AllIcons.General.ChevronRight, panel.expandButton().icon) } fun `test collapse persists and new header starts collapsed`() { val c = promptedHeader() val panel = SessionHeaderPanel(c, parent) - panel.expandButton().doClick() - panel.expandButton().doClick() + click(panel.expandButton()) + click(panel.expandButton()) assertFalse(panel.isExpanded()) assertFalse(PropertiesComponent.getInstance().getBoolean(SessionHeaderPanel.EXPANDED_KEY, true)) @@ -334,7 +348,7 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { assertFalse(panel.isExpanded()) - panel.expandButton().doClick() + click(panel.expandButton()) assertTrue(panel.isExpanded()) assertTrue(PropertiesComponent.getInstance().getBoolean(SessionHeaderPanel.EXPANDED_KEY, false)) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt index 5c4bb2947a9..868ba5a4cdc 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt @@ -13,6 +13,8 @@ import java.awt.Component import java.awt.Container import java.awt.event.MouseEvent import java.awt.image.BufferedImage +import javax.swing.Icon +import javax.swing.JLabel import javax.swing.JPanel import javax.swing.border.Border @@ -131,6 +133,24 @@ class QuestionResultViewTest : BasePlatformTestCase() { assertFalse("Should be collapsed after second toggle", view.isExpanded()) } + fun `test toggle uses right and down chevron icons`() { + val view = QuestionResultView(completedTool( + input = mapOf("questions" to """[{"question":"Q1"}]"""), + metadata = mapOf("answers" to """[["A1"]]"""), + )) + + assertTrue(icons(view).contains(SessionViewIcons.chevronCollapsed)) + assertTrue(icons(view).contains(SessionViewIcons.chevronRight)) + val closed = SessionViewIcons.chevronCollapsed + + view.toggle() + + assertTrue(icons(view).contains(SessionViewIcons.chevronExpanded)) + assertTrue(icons(view).contains(SessionViewIcons.chevronDown)) + assertEquals(closed.iconWidth, SessionViewIcons.chevronExpanded.iconWidth) + assertEquals(closed.iconHeight, SessionViewIcons.chevronExpanded.iconHeight) + } + fun `test hover only changes header background`() { val view = QuestionResultView(completedTool( input = mapOf("questions" to """[{"question":"Q1"}]"""), @@ -310,4 +330,15 @@ class QuestionResultViewTest : BasePlatformTestCase() { assertEquals(rgb, Color(image.getRGB(2, 4), true).rgb) } + private fun icons(component: Component): List { + val found = mutableListOf() + collect(component, found) + return found + } + + private fun collect(component: Component, found: MutableList) { + if (component is JLabel) component.icon?.let(found::add) + if (component is Container) component.components.forEach { collect(it, found) } + } + } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt index c09062ec808..e718bdcc8b6 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt @@ -10,6 +10,8 @@ import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import java.awt.Component import java.awt.Container +import javax.swing.Icon +import javax.swing.JLabel import javax.swing.JPanel import javax.swing.ScrollPaneConstants @@ -177,6 +179,14 @@ class ReasoningViewTest : BasePlatformTestCase() { assertTrue(font.size < style.editorSize) } + fun `test reasoning header uses brain icon`() { + val view = ReasoningView(reasoning("p1", done = true, text = "one")) + val icons = icons(view) + + assertTrue(icons.contains(SessionViewIcons.brain)) + assertFalse(icons.contains(SessionViewIcons.eye)) + } + fun `test applyStyle updates reasoning in place`() { val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) val component = view.md.component @@ -285,4 +295,15 @@ class ReasoningViewTest : BasePlatformTestCase() { } error("scroll not found") } + + private fun icons(component: Component): List { + val found = mutableListOf() + collect(component, found) + return found + } + + private fun collect(component: Component, found: MutableList) { + if (component is JLabel) component.icon?.let(found::add) + if (component is Container) component.components.forEach { collect(it, found) } + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt index bc8e1e9ed18..6fb2cde06f5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt @@ -2,11 +2,13 @@ package ai.kilocode.client.session.views.base import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.SessionViewIcons import com.intellij.testFramework.fixtures.BasePlatformTestCase import java.awt.Color import java.awt.Component import java.awt.event.MouseEvent import java.awt.image.BufferedImage +import javax.swing.Icon import javax.swing.JLabel import javax.swing.JPanel import javax.swing.border.Border @@ -43,6 +45,22 @@ class AbstractSessionPartViewTest : BasePlatformTestCase() { assertSame(view, content.parent) } + fun `test toggle uses right and down chevron icons`() { + val view = TestView(content = JLabel("body")) + + assertSame(SessionViewIcons.chevronCollapsed, view.arrowIcon()) + assertSame(SessionViewIcons.chevronRight, view.arrowIcon()) + val closed = view.arrowIcon() + + view.toggle() + + assertSame(SessionViewIcons.chevronExpanded, view.arrowIcon()) + assertSame(SessionViewIcons.chevronDown, view.arrowIcon()) + assertNotSame(closed, view.arrowIcon()) + assertEquals(closed.iconWidth, view.arrowIcon().iconWidth) + assertEquals(closed.iconHeight, view.arrowIcon().iconHeight) + } + fun `test non expandable hides content`() { val content = JLabel("body") val view = TestView(content = content, expanded = true) @@ -96,6 +114,7 @@ class AbstractSessionPartViewTest : BasePlatformTestCase() { override val contentId = "test" override fun update(content: Content) {} fun arrowVisible() = arrow.isVisible + fun arrowIcon(): Icon = arrow.icon } private fun TestView.component(index: Int): Component = components[index] From e06aa64d8dc6da335a5f6d76b69f855a472dba28 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 11 Jun 2026 12:52:56 -0400 Subject: [PATCH 03/16] fix(jetbrains): polish session header spacing --- .../session/ui/header/SessionHeaderPanel.kt | 25 ++++++++++--------- .../client/session/ui/style/SessionUiStyle.kt | 2 +- .../ai/kilocode/client/ui/md/MdViewHybrid.kt | 1 + .../ui/header/SessionHeaderPanelTest.kt | 25 ++++++++++++++++++- .../kilocode/client/ui/md/MdViewHybridTest.kt | 8 ++++++ 5 files changed, 47 insertions(+), 14 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt index c4cce829697..113ceef15c6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt @@ -8,7 +8,6 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.controller.SessionController import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.todo.TodoListPanel -import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack @@ -18,6 +17,7 @@ import com.intellij.icons.AllIcons import com.intellij.ide.util.PropertiesComponent import com.intellij.openapi.Disposable import com.intellij.openapi.util.IconLoader +import com.intellij.ui.JBColor import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel @@ -141,7 +141,12 @@ class SessionHeaderPanel( private val body = JPanel().apply { isOpaque = false layout = BoxLayout(this, BoxLayout.Y_AXIS) - border = JBUI.Borders.empty(UiStyle.Gap.sm(), 0, 0, 0) + border = JBUI.Borders.empty( + UiStyle.Gap.sm(), + UiStyle.Gap.md(), + UiStyle.Gap.md(), + UiStyle.Gap.md(), + ) add(viewport) add(tokens) add(bar) @@ -218,11 +223,13 @@ class SessionHeaderPanel( override fun updateUI() { super.updateUI() border = JBUI.Borders.compound( - JBUI.Borders.customLine(JBUI.CurrentTheme.ToolWindow.borderColor(), 1, 0, 1, 0), + JBUI.Borders.customLine(separator(), 0, 0, 1, 0), JBUI.Borders.empty(), ) } + private fun separator() = JBColor.namedColor("EditorTabs.underTabsBorderColor", JBUI.CurrentTheme.EditorTabs.borderColor()) + fun update(header: SessionHeaderSnapshot) { val before = isVisible title.text = header.title @@ -271,9 +278,7 @@ class SessionHeaderPanel( title.foreground = style.editorForeground cost.font = style.regularFont cost.foreground = style.editorForeground - cost.icon = costValue.takeIf { it.isNotBlank() }?.let { - FilledBadgeIcon(it, style.editorForeground, style.editorBackground, style.regularFont) - } + cost.icon = null context.font = style.regularFont context.foreground = style.editorForeground todos.font = style.smallFont @@ -300,8 +305,6 @@ class SessionHeaderPanel( internal fun costTip() = cost.toolTipText - internal fun costIcon() = cost.icon - internal fun contextText(): String = context.text internal fun foregrounds() = listOf(title, cost, context, todos, tokenTitle, input, output, cacheWrite, cacheRead) @@ -415,10 +418,8 @@ class SessionHeaderPanel( private fun setCost(value: String?) { costValue = value.orEmpty() - cost.text = "" - cost.icon = costValue.takeIf { it.isNotBlank() }?.let { - FilledBadgeIcon(it, style.editorForeground, style.editorBackground, style.regularFont) - } + cost.text = costValue + cost.icon = null val tip = costValue.takeIf { it.isNotBlank() }?.let { KiloBundle.message("session.header.cost.tooltip", it) } cost.toolTipText = tip cost.accessibleContext.accessibleName = tip 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 9ffd77482dd..e6f854a6d43 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 @@ -16,7 +16,7 @@ object SessionUiStyle { /** Geometry for the transcript list and its scroll behavior. */ object SessionLayout { const val GAP = 3 - const val TRANSCRIPT_PADDING = 12 + const val TRANSCRIPT_PADDING = GAP const val TRANSCRIPT_SCROLLBAR_PADDING = 10 const val USER_PROMPT_INDENT = 100 const val SCROLL_INCREMENT = 48 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 a245c1e0100..59c9992dae2 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 @@ -163,6 +163,7 @@ internal class MdViewHybrid( layout = BoxLayout(this, BoxLayout.Y_AXIS) isOpaque = true background = opts().background + border = JBUI.Borders.emptyLeft(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING)) } override val component: JComponent get() = root diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt index 960a0c9b90f..2709b6bbbe1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt @@ -24,6 +24,7 @@ import java.awt.Color import java.awt.Point import java.awt.event.MouseEvent import java.awt.event.MouseWheelEvent +import java.awt.image.BufferedImage import javax.swing.UIManager class SessionHeaderPanelTest : SessionControllerTestBase() { @@ -79,10 +80,32 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { assertEquals("", panel.expandButton().text) assertSame(AllIcons.General.ChevronRight, panel.expandButton().icon) assertEquals(Cursor.HAND_CURSOR, panel.expandButton().cursor.type) - assertNotNull(panel.costIcon()) assertNotSame(panel.compactButton().parent, panel.expandButton().parent) } + fun `test header has editor tab bottom separator`() { + val old = UIManager.getColor("EditorTabs.underTabsBorderColor") + val color = Color(12, 34, 56) + + try { + UIManager.put("EditorTabs.underTabsBorderColor", color) + val c = promptedHeader() + val panel = SessionHeaderPanel(c, parent) + val ins = panel.border.getBorderInsets(panel) + val img = BufferedImage(10, 10, BufferedImage.TYPE_INT_ARGB) + val g = img.createGraphics() + + panel.border.paintBorder(panel, g, 0, 0, img.width, img.height) + g.dispose() + + assertEquals(0, ins.top) + assertEquals(1, ins.bottom) + assertEquals(color.rgb, img.getRGB(5, img.height - 1)) + } finally { + UIManager.put("EditorTabs.underTabsBorderColor", old) + } + } + fun `test compact button follows eligibility and invokes controller`() { val c = promptedHeader() val panel = SessionHeaderPanel(c, parent) 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 104b5ab88d3..bd957d4ee49 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 @@ -11,6 +11,7 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.EditorTextField import com.intellij.ui.components.JBHtmlPane import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import java.awt.BorderLayout import java.awt.Color @@ -42,6 +43,13 @@ class MdViewHybridTest : BasePlatformTestCase() { assertEquals("hello **world**", view.markdown()) } + fun `test root has small left inset`() { + val ins = view.component.border.getBorderInsets(view.component) + + assertEquals(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), ins.left) + assertEquals(0, ins.right) + } + fun `test append renders accumulated source`() { view.append("hello ") view.append("**world**") From c47cb365ce19253f1c3c8208227ad2790e9c0f7c Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 11 Jun 2026 13:09:21 -0400 Subject: [PATCH 04/16] fix(jetbrains): simplify prompt input chrome --- .../client/session/ui/prompt/PromptPanel.kt | 61 ++++++++----------- 1 file changed, 26 insertions(+), 35 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index 329c895e4c3..a16663c2355 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -10,7 +10,6 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.ui.mode.ModePicker import ai.kilocode.client.session.ui.model.ModelPicker import ai.kilocode.client.ui.HoverIcon -import ai.kilocode.client.ui.RoundedContentPanel import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.iconButton import ai.kilocode.log.ChatLogSummary @@ -33,9 +32,9 @@ import com.intellij.openapi.keymap.KeymapManagerListener import com.intellij.openapi.keymap.KeymapUtil import com.intellij.openapi.project.Project import com.intellij.openapi.util.IconLoader +import com.intellij.ui.JBColor import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.xml.util.XmlStringUtil -import com.intellij.util.ui.JBValue import com.intellij.util.ui.JBDimension import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil @@ -58,8 +57,8 @@ import javax.swing.JComponent import javax.swing.ScrollPaneConstants /** - * Prompt input panel with borderless IntelliJ editor text field and - * mode/model controls grouped inside one rounded editor-background shell. + * Prompt input panel with a borderless IntelliJ editor text field and + * mode/model controls in the full bottom session area. */ class PromptPanel( private val project: Project, @@ -84,7 +83,15 @@ class PromptPanel( var onChange: () -> Unit = {} var onAutoApproveToggle: (Boolean) -> Unit = {} private var style = SessionEditorStyle.current() - private val shell = PromptShell() + private val shell = BorderLayoutPanel().apply { + isOpaque = true + border = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), + ) + } private var bus: MessageBusConnection? = null private var autoApprove = false @@ -108,11 +115,11 @@ class PromptPanel( ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER ed.contentComponent.addFocusListener(object : FocusAdapter() { override fun focusGained(e: FocusEvent) { - shell.repaint() + repaint() } override fun focusLost(e: FocusEvent) { - shell.repaint() + repaint() } }) } @@ -157,16 +164,6 @@ class PromptPanel( get() = busy init { - border = JBUI.Borders.compound( - JBUI.Borders.customLineTop(JBUI.CurrentTheme.ToolWindow.borderColor()), - JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.Prompt.PANEL_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.Prompt.PANEL_HORIZONTAL_PADDING), - JBUI.scale(SessionUiStyle.View.Prompt.PANEL_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.Prompt.PANEL_HORIZONTAL_PADDING), - ), - ) - applyStyle(style) editor.text = "" editor.addDocumentListener(object : DocumentListener { @@ -199,6 +196,14 @@ class PromptPanel( syncAutoApprove() } + override fun updateUI() { + super.updateUI() + border = JBUI.Borders.compound( + JBUI.Borders.customLineTop(separator()), + JBUI.Borders.empty(), + ) + } + @RequiresEdt fun setReady(value: Boolean) { ready = value @@ -254,6 +259,8 @@ class PromptPanel( @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { this.style = style + background = style.editorScheme.defaultBackground + shell.background = style.editorScheme.defaultBackground editor.font = style.editorFont editor.getEditor(false)?.let(style::applyToEditor) editor.background = style.editorScheme.defaultBackground @@ -363,6 +370,8 @@ class PromptPanel( return KiloBundle.message("prompt.placeholder") } + private fun separator() = JBColor.namedColor("EditorTabs.underTabsBorderColor", JBUI.CurrentTheme.EditorTabs.borderColor()) + @RequiresEdt private fun syncEditorHeight() { val count = ApplicationManager.getApplication().runReadAction { editor.document.lineCount } @@ -480,22 +489,4 @@ class PromptPanel( } } - private inner class PromptShell : RoundedContentPanel( - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), - ) { - private val focus = JBValue.UIInteger("Component.focusWidth", SessionUiStyle.View.Prompt.FOCUS_WIDTH) - - override fun contentColor() = style.editorScheme.defaultBackground - - override fun outlineColor() = if (UIUtil.isFocusAncestor(editor)) { - JBUI.CurrentTheme.Focus.focusColor() - } else { - SessionUiStyle.View.Outline.brightColor() - } - - override fun outlineWidth() = if (UIUtil.isFocusAncestor(editor)) focus.get() else SessionUiStyle.View.Outline.width() - - override fun cornerArc() = JBUI.scale(JBUI.getInt("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC)) - } } From abacee309994afcd15bd5b1974fca58afa51d0da Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 11 Jun 2026 16:40:35 -0400 Subject: [PATCH 05/16] fix(jetbrains): adjust session inner padding --- .../kilocode/client/session/ui/SessionMessageListPanel.kt | 8 ++++---- .../ai/kilocode/client/session/ui/style/SessionUiStyle.kt | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index 18c7d7410c9..cefb694629d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -58,10 +58,10 @@ class SessionMessageListPanel( ) : SessionLayoutPanel( JBUI.scale(SessionUiStyle.SessionLayout.GAP), JBUI.insets( - SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING, - SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING, - SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING, - SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING + SessionUiStyle.SessionLayout.TRANSCRIPT_SCROLLBAR_PADDING, + SessionUiStyle.SessionLayout.InnerInsets.top, + SessionUiStyle.SessionLayout.InnerInsets.left, + SessionUiStyle.SessionLayout.InnerInsets.bottom, + SessionUiStyle.SessionLayout.InnerInsets.right + SessionUiStyle.SessionLayout.TRANSCRIPT_SCROLLBAR_PADDING, ), ), Disposable, SessionEditorStyleTarget { 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 e6f854a6d43..0defd9a2adc 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 @@ -5,6 +5,7 @@ import com.intellij.ui.JBColor import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import java.awt.Color +import java.awt.Insets import javax.swing.border.Border /** Static style tokens owned by the chat session UI. */ @@ -16,7 +17,7 @@ object SessionUiStyle { /** Geometry for the transcript list and its scroll behavior. */ object SessionLayout { const val GAP = 3 - const val TRANSCRIPT_PADDING = GAP + val InnerInsets = Insets(UiStyle.Gap.md(), UiStyle.Gap.sm(), UiStyle.Gap.sm(), UiStyle.Gap.sm()) const val TRANSCRIPT_SCROLLBAR_PADDING = 10 const val USER_PROMPT_INDENT = 100 const val SCROLL_INCREMENT = 48 From 50580501679ea0900c2102c0509575e89f15a48e Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 11 Jun 2026 18:37:56 -0400 Subject: [PATCH 06/16] fix(jetbrains): polish session markdown styling --- .changeset/improve-jetbrains-markdown.md | 5 ++ .../jetbrains-mdview-editor-theme-colors.md | 67 ++++++++++++++ .../plans/jetbrains-mdview-vscode-styling.md | 69 ++++++++++++++ .../jetbrains-session-ui-icons-header.md | 63 +++++++++++++ .../session/ui/header/SessionHeaderPanel.kt | 4 +- .../client/session/views/MessageView.kt | 2 +- .../client/session/views/PlanExitView.kt | 1 + .../kilocode/client/session/views/TextView.kt | 3 +- .../ai/kilocode/client/ui/md/MdCommon.kt | 89 +++++++++++++++---- .../ai/kilocode/client/ui/md/MdViewHybrid.kt | 8 +- .../client/session/views/PlanExitViewTest.kt | 28 ++++++ .../client/session/views/TurnViewTest.kt | 11 +++ .../kilocode/client/ui/md/MdViewHybridTest.kt | 52 +++++++++++ .../ai/kilocode/client/ui/md/MdViewTest.kt | 72 +++++++++++++++ 14 files changed, 449 insertions(+), 25 deletions(-) create mode 100644 .changeset/improve-jetbrains-markdown.md create mode 100644 .kilo/plans/jetbrains-mdview-editor-theme-colors.md create mode 100644 .kilo/plans/jetbrains-mdview-vscode-styling.md create mode 100644 .kilo/plans/jetbrains-session-ui-icons-header.md diff --git a/.changeset/improve-jetbrains-markdown.md b/.changeset/improve-jetbrains-markdown.md new file mode 100644 index 00000000000..ff1a1845e0b --- /dev/null +++ b/.changeset/improve-jetbrains-markdown.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve markdown readability in JetBrains chat transcripts. diff --git a/.kilo/plans/jetbrains-mdview-editor-theme-colors.md b/.kilo/plans/jetbrains-mdview-editor-theme-colors.md new file mode 100644 index 00000000000..20e85b546f8 --- /dev/null +++ b/.kilo/plans/jetbrains-mdview-editor-theme-colors.md @@ -0,0 +1,67 @@ +# JetBrains MdView Editor Theme Colors Plan + +## Goal + +Keep JetBrains markdown layout compact while making markdown colors and backgrounds come from the active editor color scheme, and ensure existing theme/editor-setting listeners refresh all existing `MdView` instances after changes. + +## Findings + +- `SessionUi` already subscribes to `EditorColorsManager.TOPIC` and `LafManagerListener.TOPIC`, then calls `applyStyle(SessionEditorStyle.current())` on the session tree. +- Most markdown consumers already propagate that style through `MdView.applyStyle(style)` via `TextView`, `ReasoningView`, message lists, and session panels. +- `PlanExitView.applyStyle()` sets only font/code font/foreground and does not call `md.applyStyle(style)`, so internal markdown role colors can stay tied to the initial style. +- `MdViewHybrid.applyStyle()` restyles retained HTML panes and code block containers, but retained `CodeField` editors need explicit reapplication of `SessionEditorStyle.applyToEditor()` after creation so syntax highlighting and editor colors follow scheme changes. +- `MdCommon.defaults()` currently mixes editor colors, UI theme colors, and one literal inline-code color fallback. The literal fallback should be removed, and markdown roles should derive from `EditorColorsScheme`/syntax attributes wherever possible. +- `TextView` currently overrides markdown background with `SessionUiStyle.Transcript.bgColor()` (`UiStyle.Colors.bg()`), while prompt markdown already uses `style.editorBackground`. If markdown surfaces should consistently use editor background, normal text views need to use `style.editorBackground` too. + +## Implementation Plan + +1. Keep compact CSS unchanged. + - Do not reintroduce line-height, margin, padding, heading sizing, or other geometry rules. + - Keep the current role color/background selectors only. + +2. Derive markdown role defaults from editor settings. + - Update `MdCommon.defaults(style)` to use `style.editorForeground` and `style.editorBackground` for primary text/background. + - Add small local helper functions for editor attributes, for example foreground/background from `style.editorScheme.getAttributes(key)` and color keys from `style.editorScheme.getColor(key)`. + - Use public IntelliJ editor keys for role colors: + - Links: `CodeInsightColors.HYPERLINK_ATTRIBUTES.foregroundColor`, fallback to platform link color if absent. + - Inline code foreground/background: `DefaultLanguageHighlighterColors.DOC_CODE_INLINE`, fallback to `STRING`, then editor foreground/background. + - Code block foreground/background: `DefaultLanguageHighlighterColors.DOC_CODE_BLOCK`, fallback to editor foreground/background. + - Quote/emphasis/list weak text: comment/doc-comment attributes, fallback to editor foreground or `UIUtil.getContextHelpForeground()` only when the scheme has no useful value. + - Borders/HR/table/code border: editor preview/border color keys such as `EditorColors.PREVIEW_BORDER_COLOR`, fallback to `UiStyle.Colors.contentBorder()`. + - Remove `JBColor(0x...)` or other literal runtime color fallbacks from `MdCommon`. + - Keep public `MdView` API unchanged; role colors stay internal unless a concrete external override need appears. + +3. Ensure style propagation reaches every markdown instance. + - Update `PlanExitView.applyStyle(style)` to call `md.applyStyle(style)` before applying its explicit font/code-font/foreground overrides. + - Audit existing markdown callers after the change; keep using the existing `SessionEditorStyleTarget` propagation path rather than adding per-`MdView` theme listeners. + - Keep explicit foreground overrides in `TextView`, `ReasoningView`, and `PlanExitView` where they intentionally set body text role, but let internal markdown role colors refresh from the new style snapshot. + +4. Ensure retained hybrid code blocks update after editor setting changes. + - In `MdViewHybrid.CodeView.style(opts)`, for retained `CodeField` blocks, reapply `style.applyToEditor(editor)` to the underlying editor if it exists. + - Reapply code editor background/scroll pane/viewport backgrounds from `opts.preBg` in the same path. + - Preserve retained component/editor reuse: do not rebuild code block panes just to update style. + +5. Align markdown backgrounds with editor settings. + - Keep `MdCommon` default background as `style.editorBackground`. + - Change normal `TextView` markdown background to `style.editorBackground` if the intent is that all markdown surfaces use editor background, matching `PromptView` and session root behavior. + - Preserve `transparent` handling: when `md.opaque = false`, background should still be the editor-derived value for child/code surfaces, but the Swing component should remain non-opaque. + +6. Update tests. + - Extend `MdViewTest` to verify markdown role CSS changes when applying a `SessionEditorStyle` backed by a customized editor scheme, especially inline code, code block, link, and border colors. + - Extend `MdViewHybridTest` to assert `applyStyle()` updates retained HTML panes and retained code editors without replacing them, including editor scheme/background changes. + - Add or extend `PlanExitViewTest` so `applyStyle()` refreshes the nested markdown style, not just foreground/font overrides. + - Keep existing compactness expectations: tests should not assert new spacing, padding, margin, line-height, or size rules. + +## Verification + +- From `packages/kilo-jetbrains/`, run `./gradlew frontend:test --tests '*MdView*'`. +- From `packages/kilo-jetbrains/`, run `./gradlew frontend:test --tests '*PlanExitViewTest*'` if the focused test is not covered by the MdView filter. +- From `packages/kilo-jetbrains/`, run `bun run typecheck`. + +## Constraints + +- Do not introduce JCEF, Compose, or Kotlin UI DSL. +- Do not add new theme/editor listeners in `MdView`; use the existing `SessionUi` listener and `SessionEditorStyleTarget` propagation path. +- Avoid hardcoded runtime colors in markdown styling; prefer editor scheme attributes/color keys, then platform/theme APIs as non-literal fallbacks. +- Preserve retained Swing behavior and `MdViewHybrid.sync()` component reuse. +- Keep the public `MdView` override API stable unless implementation proves a new external override is required. diff --git a/.kilo/plans/jetbrains-mdview-vscode-styling.md b/.kilo/plans/jetbrains-mdview-vscode-styling.md new file mode 100644 index 00000000000..89e816d0235 --- /dev/null +++ b/.kilo/plans/jetbrains-mdview-vscode-styling.md @@ -0,0 +1,69 @@ +# JetBrains MdView VS Code Styling Plan + +## Goal + +Improve JetBrains markdown output so assistant/user transcript markdown visually matches the VS Code webview markdown style while keeping the existing Swing/JBHtmlPane + editor-backed code block architecture. + +## Findings + +- VS Code markdown styling is split across `packages/ui/src/components/markdown.css`, `packages/kilo-ui/src/components/markdown.css`, `packages/kilo-ui/src/styles/vscode-bridge.css`, and message-part overrides. +- Base VS Code markdown uses 14px sans text, 160% line height, tight first/last margins, same-size medium headings, 12px paragraph spacing, link-colored anchors, compact lists, weak list markers, weak blockquotes with a 2px left border, invisible HR spacing, bordered/padded code blocks, green inline code, and lightly bordered tables. +- The VS Code theme bridge maps markdown roles to editor/theme tokens: heading/link/list/image use `textLinkForeground`, text/strong/code-block use editor foreground, inline code uses charts/syntax green, quote/emphasis use description foreground, HR uses panel border. +- JetBrains markdown is rendered by `MdViewHybrid` and `MdViewHtmlPane`, with shared CSS from `MdCommon.rules()` and defaults from `MdCommon.defaults()`. +- JetBrains currently styles only broad tag font/color, links, code/pre colors, blockquote border/text color, and table border. It lacks VS Code-equivalent spacing, heading/strong/emphasis/list marker/table cell rules, inline-code foreground, blockquote geometry, HR spacing, and code block surface polish. +- JetBrains fenced code blocks are already stronger than VS Code in one respect: they use `EditorTextField` with real IDE syntax highlighting and streaming retention. Preserve this instead of switching to web/JCEF rendering. + +## Implementation Plan + +1. Expand JetBrains markdown style tokens. + - Add internal fields to `MdStyle` for heading, strong, emphasis, inline code foreground, list marker, HR, table/header, and code block border colors. + - Keep the public `MdView` override API stable unless a new external override is clearly needed. + - Compute defaults in `MdCommon.defaults(style)` from IntelliJ/editor theme sources and centralized Kilo semantic colors where no platform key matches. + - Use `JBColor.namedColor("Kilo.Markdown.*", fallback)` for Kilo-specific markdown palette fallbacks, so themes can override them and runtime code avoids scattered hardcoded colors. + +2. Mirror VS Code markdown CSS in `MdCommon.rules()`. + - Add root/body wrapping rules: max width behavior, break-word wrapping, base line-height, and first/last-child margin trimming where supported by `JBHtmlPane` CSS. + - Add heading rules: same base size, medium/bold weight, role-specific color, line height, and bottom spacing. + - Add paragraph, list, list item, nested list, and marker rules. If Swing HTML does not support `::marker`, fall back to `li { color: ... }` plus child text color reset only if supported; otherwise keep list text normal and document the limitation in tests. + - Add strong/emphasis colors matching VS Code token roles. + - Add anchor styling matching VS Code: themed link color, no forced background, and underline behavior where `JBHtmlPane` supports it. + - Add blockquote geometry: 2px left border, 24px vertical margin, 8px left padding, weak text, and normal style. + - Add table layout rules: collapse borders, full width where possible, 24px vertical margin, 12px cell padding, weak row borders, stronger header text. + - Keep HRs visually hidden but spaced consistently with VS Code if the renderer includes them. `MdViewHybrid` currently filters thematic breaks, so this mainly benefits `MdViewHtmlPane` and future reuse. + - Add inline-code foreground and medium font weight. Avoid inline code backgrounds unless the current `JBHtmlPane` configuration already draws them acceptably. + +3. Polish JetBrains code block containers without losing IDE highlighting. + - Keep `EditorTextField` for fenced/indented blocks and `JBTextArea` fallback. + - Style `JBScrollPane` code blocks to match VS Code’s `markdown-code` wrapper feel: subtle background, subtle border, rounded-ish platform arc if feasible, 12px-ish padding, and thin horizontal scrollbar behavior. + - Use `SessionUiStyle.View.Code` for geometry constants. Add only minimal new constants there if current values cannot represent the VS Code spacing. + - Separate code block border color from table border internally so table styling can change without affecting code boxes. + - Continue applying `SessionEditorStyle.applyToEditor(ed)` so code blocks follow IDE syntax highlighting and editor font changes. + +4. Add file/path affordance parity where safe. + - For markdown links whose `href` looks like a relative file path, keep existing link dispatch so the current caller can open files/URLs appropriately. + - Consider decorating inline code that looks like a path with a `file-link` class only when an `openFile` callback is available through the existing usage path. If the current `MdView` abstraction only has `openUrl`, do not widen it unless the call sites can pass file opening cleanly. + - At minimum, make inline code/path-looking content visually closer to VS Code by using the inline-code foreground and dotted underline for explicit file links where generated HTML contains link/code classes. + +5. Preserve retained Swing behavior. + - Keep `MdViewHybrid.sync()` prefix reuse logic unchanged unless necessary. + - Ensure style updates call `reloadCssStylesheets()` and reassign text only for retained `JBHtmlPane` blocks, not by rebuilding all blocks. + - Keep streaming fenced-code fast path and editor disposal behavior intact. + +6. Add focused tests. + - Extend `MdViewTest` and/or `MdViewHybridTest` to assert `overrideSheet()` contains the new VS Code-equivalent rules for headings, strong/emphasis, links, inline code foreground, list/table/blockquote spacing, HR, and code block/table border separation. + - Add component tests for code block pane styling: background, viewport background, border color, padding, scrollbar policy, and retained editor instance after `applyStyle()`. + - Keep existing stress/leak tests green. Add a small stress assertion only if the implementation changes style application semantics. + - Add a changeset: `@kilocode/kilo-jetbrains` patch with user-facing wording such as `Improve markdown readability in JetBrains chat transcripts.` + +## Verification + +- Run targeted JetBrains markdown tests first from `packages/kilo-jetbrains/`: `./gradlew frontend:test --tests '*MdView*'` if the Gradle module supports it; otherwise run the closest supported targeted Gradle test command. +- Run `bun run typecheck` from `packages/kilo-jetbrains/`. +- If targeted Gradle filtering is unreliable, run `./gradlew test` from `packages/kilo-jetbrains/`. + +## Constraints + +- Do not introduce JCEF, Compose, or Kotlin UI DSL. +- Keep changes inside `packages/kilo-jetbrains/` and `.changeset/` unless a shared Kilo UI source of truth is explicitly required. +- No `kilocode_change` markers are needed for JetBrains or Kilo UI paths. +- Prefer IntelliJ theme APIs and centralized semantic tokens over scattered literal colors. diff --git a/.kilo/plans/jetbrains-session-ui-icons-header.md b/.kilo/plans/jetbrains-session-ui-icons-header.md new file mode 100644 index 00000000000..7ed4ae52502 --- /dev/null +++ b/.kilo/plans/jetbrains-session-ui-icons-header.md @@ -0,0 +1,63 @@ +# JetBrains Session UI Icons And Header Layout Plan + +## Goal +Improve JetBrains session UI icon consistency and reduce accidental header interactions: +- Use Kilo/VS Code-aligned session icons in session views. +- Fix the reasoning header icon. +- Normalize session part collapse/expand chevrons so collapsed/expanded states do not jump between differently sized glyphs. +- Move the session-details toggle away from compaction and place it before the session title. + +## Findings +- Session view icons are centralized in `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt` and loaded from `frontend/src/main/resources/icons/views/*.svg`. +- The JetBrains `views` SVGs already mirror the shared VS Code/UI icon paths from `packages/ui/src/components/icon.tsx` for the audited names, including `brain`, `chevron-down`, `chevron-right`, `checklist`, `console`, `warning`, etc. +- The reasoning view currently renders `SessionViewIcons.eye` in `ReasoningView.kt`; VS Code/shared UI uses the `brain` icon for reasoning/thinking surfaces, and `SessionViewIcons.brain` already exists. +- Standard collapsible session parts use `SessionViewIcons.chevronDown` when expanded and `SessionViewIcons.chevronRight` when collapsed in `AbstractSessionPartView.kt`; `QuestionResultView.kt` repeats this pattern manually. The down/right SVG paths have different visual extents. +- The session header currently places the details toggle next to the compact button in the right-side controls in `SessionHeaderPanel.kt`, making the two actions easy to confuse. + +## Implementation Steps +1. **Keep icon sources aligned with VS Code/shared UI** + - Treat `packages/ui/src/components/icon.tsx` as the source for Kilo web/session glyph shapes. + - Re-check `SessionViewIcons.kt` entries against available JetBrains assets; only update or add SVGs if a session view uses a Kilo icon missing from `frontend/src/main/resources/icons/views/`. + - Preserve JetBrains SVG theming rules: no `currentColor`; use literal palette colors and dark variants where assets are added or changed. + +2. **Fix reasoning icon** + - In `ReasoningView.kt`, change the reasoning header glyph from `SessionViewIcons.eye` to `SessionViewIcons.brain`. + - Add or update test coverage in `ReasoningViewTest.kt` by inspecting the rendered Swing label tree and asserting the reasoning icon is `SessionViewIcons.brain`. + +3. **Normalize collapse/expand chevrons for session parts** + - Stop using the mixed `chevronRight`/`chevronDown` pair for collapsible session content. + - Use a single base Kilo chevron glyph for both states, matching the current custom chevron used by the session header (`/icons/chevron-down.svg` / equivalent `SessionViewIcons.chevronDown`). + - Add a shared rotated icon for the opposite state instead of switching to a differently sized right-facing asset. Prefer a small reusable helper or centralized icon field rather than importing header-specific UI into session views. + - Update `AbstractSessionPartView.kt` and `QuestionResultView.kt` to use the normalized chevron pair. + - Leave `QuestionView.kt` navigation chevrons alone unless auditing shows they are being used for collapse/expand; those are previous/next controls, not expand/collapse controls. + +4. **Relocate and change header show/hide details toggle** + - In `SessionHeaderPanel.kt`, replace the custom header details chevron with platform `AllIcons` arrows/chevrons, e.g. collapsed = `AllIcons.General.ArrowRight`, expanded = `AllIcons.General.ArrowDown`. + - Move the details toggle out of the right-side controls and into `BorderLayout.WEST` of the header row. + - Rebuild the top header as: + - outer border layout + - west: details toggle button + - center: inner border layout + - inner center: session title + - inner east: horizontal stack/row with price/context and compact button + - Remove the details toggle from the right-side row so compaction remains visually separate from show/hide details. + - Keep existing tooltip/accessibility strings and expansion persistence behavior unchanged. + +5. **Tests** + - Update `SessionHeaderPanelTest.kt` to assert: + - collapsed/expanded header details icons use the selected `AllIcons` constants; + - the details toggle persists expansion state as before; + - the details toggle is parented/laid out separately from the compact button. + - Update `AbstractSessionPartViewTest.kt` to assert collapsible parts keep the same icon dimensions across collapsed/expanded states and no longer use the mismatched right/down pair. + - Update `QuestionResultViewTest.kt` similarly because it has its own chevron implementation. + - Update `ReasoningViewTest.kt` for the brain icon. + +6. **Verification** + - Run the smallest relevant JetBrains checks from `packages/kilo-jetbrains/`: + - `./gradlew test --tests "ai.kilocode.client.session.views.ReasoningViewTest" --tests "ai.kilocode.client.session.views.base.AbstractSessionPartViewTest" --tests "ai.kilocode.client.session.views.QuestionResultViewTest" --tests "ai.kilocode.client.session.ui.header.SessionHeaderPanelTest"` + - `./gradlew typecheck` + - If the filtered Gradle test syntax is not accepted by the project, run `./gradlew test` from `packages/kilo-jetbrains/` instead. + +## Notes +- No shared upstream `opencode` files are involved; changes stay under `packages/kilo-jetbrains/`. +- A changeset may be needed because this is user-facing JetBrains UI polish; confirm existing changeset policy for the private JetBrains package during implementation. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt index 113ceef15c6..02562e013bf 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt @@ -143,9 +143,9 @@ class SessionHeaderPanel( layout = BoxLayout(this, BoxLayout.Y_AXIS) border = JBUI.Borders.empty( UiStyle.Gap.sm(), + UiStyle.Gap.xl(), UiStyle.Gap.md(), - UiStyle.Gap.md(), - UiStyle.Gap.md(), + UiStyle.Gap.xl(), ) add(viewport) add(tokens) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index d0b5e06b445..00360eeba80 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -288,7 +288,7 @@ class MessageView( val arc = JBUI.scale(JBUI.getInt("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC)) g2.color = style.editorScheme.defaultBackground g2.fillRoundRect(0, 0, width, height, arc, arc) - g2.color = SessionUiStyle.View.Outline.brightColor() + g2.color = SessionUiStyle.View.Outline.color() val w = width - 1 val h = height - 1 if (w > 0 && h > 0) g2.drawRoundRect(0, 0, w, h, arc, arc) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt index a64af5f93db..421b19f551d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt @@ -40,6 +40,7 @@ class PlanExitView(tool: Tool, openFile: (String) -> Unit, selection: SessionSel } override fun applyStyle(style: SessionEditorStyle) { + md.applyStyle(style) md.font = style.transcriptFont md.codeFont = style.editorFamily md.foreground = style.editorForeground 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 fa56f6466c2..abbd2a5ab9a 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,7 +4,6 @@ import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Text import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.selection.SessionSelection -import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.ui.md.MdView import ai.kilocode.client.ui.md.MdViewFactory @@ -73,7 +72,7 @@ open class TextView( protected open fun styleFont(style: SessionEditorStyle) = style.transcriptFont - protected open fun styleBackground(style: SessionEditorStyle) = SessionUiStyle.Transcript.bgColor() + protected open fun styleBackground(style: SessionEditorStyle) = style.editorBackground private fun refresh() { revalidate() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt index 76e815e71b0..7230ff2eda5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt @@ -1,6 +1,15 @@ package ai.kilocode.client.ui.md import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.ui.UiStyle +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors +import com.intellij.openapi.editor.HighlighterColors +import com.intellij.openapi.editor.colors.CodeInsightColors +import com.intellij.openapi.editor.colors.ColorKey +import com.intellij.openapi.editor.colors.EditorColors +import com.intellij.openapi.editor.colors.TextAttributesKey +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil import java.awt.Color internal object MdCommon { @@ -33,32 +42,66 @@ internal object MdCommon { if (!opts.opaque) body.add("background: transparent") if (body.isNotEmpty()) rules.append("body { ${body.joinToString("; ")} } ") + rules.append("h1, h2, h3, h4, h5, h6 { color: ${hex(opts.headingFg)} } ") + rules.append("strong, b { color: ${hex(opts.strongFg)} } ") + rules.append("em, i { color: ${hex(opts.emphasisFg)} } ") rules.append("a { color: ${hex(opts.linkColor)} } ") + rules.append("ul, ol { color: ${hex(opts.listMarkerFg)} } ") + rules.append("li { color: ${hex(opts.foreground)} } ") 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("code { background: ${hex(opts.codeBg)}; color: ${hex(opts.inlineCodeFg)} } ") + rules.append("pre { background: ${hex(opts.preBg)}; color: ${hex(opts.preFg)}; border-color: ${hex(opts.codeBorder)} } ") + rules.append("pre code { background: ${hex(opts.preBg)}; color: ${hex(opts.preFg)} } ") + rules.append("blockquote { border-left-color: ${hex(opts.quoteBorder)}; color: ${hex(opts.quoteFg)} } ") + rules.append("blockquote p { color: ${hex(opts.quoteFg)} } ") rules.append("th, td { border-color: ${hex(opts.tableBorder)} } ") + rules.append("th { color: ${hex(opts.tableHeaderFg)} } ") + rules.append("hr { border-color: ${hex(opts.hrColor)} } ") return rules.toString().trim() } - fun defaults(style: SessionEditorStyle) = 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, - ) + fun defaults(style: SessionEditorStyle): MdStyle { + val weak = fg(style, DefaultLanguageHighlighterColors.DOC_COMMENT) + ?: fg(style, DefaultLanguageHighlighterColors.LINE_COMMENT) + ?: UIUtil.getContextHelpForeground() + val border = color(style, EditorColors.PREVIEW_BORDER_COLOR) ?: UiStyle.Colors.contentBorder() + val blockBg = bg(style, DefaultLanguageHighlighterColors.DOC_CODE_BLOCK) ?: style.editorBackground + return MdStyle( + font = style.transcriptFont, + foreground = style.editorForeground, + background = style.editorBackground, + linkColor = fg(style, CodeInsightColors.HYPERLINK_ATTRIBUTES) ?: JBUI.CurrentTheme.Link.Foreground.ENABLED, + codeBg = bg(style, DefaultLanguageHighlighterColors.DOC_CODE_INLINE) + ?: bg(style, DefaultLanguageHighlighterColors.STRING) + ?: style.editorBackground, + preBg = blockBg, + preFg = fg(style, DefaultLanguageHighlighterColors.DOC_CODE_BLOCK) ?: style.editorForeground, + codeFont = style.editorFamily, + quoteBorder = border, + quoteFg = weak, + tableBorder = border, + headingFg = fg(style, CodeInsightColors.HYPERLINK_ATTRIBUTES) ?: style.editorForeground, + strongFg = fg(style, HighlighterColors.TEXT) ?: style.editorForeground, + emphasisFg = weak, + inlineCodeFg = fg(style, DefaultLanguageHighlighterColors.DOC_CODE_INLINE) + ?: fg(style, DefaultLanguageHighlighterColors.STRING) + ?: style.editorForeground, + listMarkerFg = weak, + hrColor = border, + tableHeaderFg = fg(style, HighlighterColors.TEXT) ?: style.editorForeground, + codeBorder = border, + opaque = true, + ) + } + + private fun fg(style: SessionEditorStyle, key: TextAttributesKey): Color? = + style.editorScheme.getAttributes(key)?.foregroundColor + + private fun bg(style: SessionEditorStyle, key: TextAttributesKey): Color? = + style.editorScheme.getAttributes(key)?.backgroundColor + + private fun color(style: SessionEditorStyle, key: ColorKey): Color? = style.editorScheme.getColor(key) } internal data class MdStyle( @@ -73,5 +116,13 @@ internal data class MdStyle( val quoteBorder: Color, val quoteFg: Color, val tableBorder: Color, + val headingFg: Color, + val strongFg: Color, + val emphasisFg: Color, + val inlineCodeFg: Color, + val listMarkerFg: Color, + val hrColor: Color, + val tableHeaderFg: Color, + val codeBorder: Color, val opaque: Boolean, ) 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 59c9992dae2..3d797f359a2 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 @@ -526,7 +526,7 @@ internal class MdViewHybrid( private fun styleCodePane(pane: JBScrollPane, opts: MdStyle) { pane.apply { - border = JBUI.Borders.customLine(opts.tableBorder, SessionUiStyle.View.Code.BORDER_WIDTH) + border = JBUI.Borders.customLine(opts.codeBorder, SessionUiStyle.View.Code.BORDER_WIDTH) viewportBorder = JBUI.Borders.empty( SessionUiStyle.View.Code.topPadding(), SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING, @@ -890,6 +890,12 @@ internal class MdViewHybrid( is CodeField -> { view.font = style.editorFont view.background = opts.preBg + view.getEditor(false)?.let { ed -> + style.applyToEditor(ed) + ed.backgroundColor = opts.preBg + ed.scrollPane.background = opts.preBg + ed.scrollPane.viewport.background = opts.preBg + } } is JBTextArea -> styleTextArea(view, opts) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt index 93a3d2c173a..9e6845f53c7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt @@ -3,8 +3,16 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind +import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.views.tool.ToolView +import ai.kilocode.client.ui.md.MdView +import com.intellij.openapi.editor.colors.CodeInsightColors +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.editor.colors.EditorColorsScheme +import com.intellij.openapi.editor.markup.TextAttributes import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Color +import java.awt.Font @Suppress("UnstableApiUsage") class PlanExitViewTest : BasePlatformTestCase() { @@ -43,8 +51,28 @@ class PlanExitViewTest : BasePlatformTestCase() { assertEquals(listOf(".kilo/plans/my%20plan.md"), opened) } + fun `test applyStyle refreshes nested markdown role colors`() { + val view = PlanExitView(tool(ToolExecState.COMPLETED)) {} + val scheme = EditorColorsManager.getInstance().globalScheme.clone() as EditorColorsScheme + scheme.setAttributes( + CodeInsightColors.HYPERLINK_ATTRIBUTES, + TextAttributes(Color(0x77, 0x88, 0x99), null, null, null, Font.PLAIN), + ) + val style = SessionEditorStyle.create(scheme = scheme) + + view.applyStyle(style) + + assertTrue(md(view).overrideSheet().contains("a { color: #778899")) + } + private fun tool(state: ToolExecState) = Tool("prt_plan", "plan_exit", toolKind("plan_exit")).apply { this.state = state output = "Plan is ready at .kilo/plans/x.md. Ending planning turn." } + + private fun md(view: PlanExitView): MdView { + val field = PlanExitView::class.java.getDeclaredField("md") + field.isAccessible = true + return field.get(view) as MdView + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt index 7a03cb52083..976b94a748f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt @@ -11,6 +11,7 @@ import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.util.ui.JBUI +import java.awt.image.BufferedImage import javax.swing.JComponent import javax.swing.JPanel import javax.swing.RepaintManager @@ -110,6 +111,16 @@ class TurnViewTest : BasePlatformTestCase() { assertFalse(mv.isOpaque) } + fun `test user message uses standard outline color`() { + val mv = MessageView(msg("u1", "user"), openFile) + mv.setSize(120, 48) + val image = BufferedImage(120, 48, BufferedImage.TYPE_INT_ARGB) + + mv.paint(image.createGraphics()) + + assertEquals(SessionUiStyle.View.Outline.color().rgb, image.getRGB(60, 0)) + } + fun `test assistant message remains borderless`() { val mv = MessageView(msg("a1", "assistant"), openFile) val ins = mv.border.getBorderInsets(mv) 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 bd957d4ee49..254f01f6600 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 @@ -2,10 +2,17 @@ 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.openapi.editor.DefaultLanguageHighlighterColors +import com.intellij.openapi.editor.HighlighterColors +import com.intellij.openapi.editor.colors.CodeInsightColors +import com.intellij.openapi.editor.colors.EditorColors +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.editor.colors.EditorColorsScheme import com.intellij.openapi.fileTypes.FileType import com.intellij.openapi.fileTypes.FileTypeRegistry import com.intellij.openapi.fileTypes.PlainTextFileType import com.intellij.openapi.fileTypes.UnknownFileType +import com.intellij.openapi.editor.markup.TextAttributes import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.EditorTextField @@ -15,6 +22,7 @@ import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import java.awt.BorderLayout import java.awt.Color +import java.awt.Font import javax.swing.Box import javax.swing.JPanel import javax.swing.ScrollPaneConstants @@ -525,6 +533,28 @@ class MdViewHybridTest : BasePlatformTestCase() { assertTrue(pane.text.contains("hello")) } + fun `test applyStyle updates retained code editor scheme and background`() { + view.set("```kotlin\nval value = 1\n```") + val pane = scrolls().single() + val field = editors().single() + val editor = field.getEditor(true)!! + val style = customStyle() + + view.applyStyle(style) + + assertSame(pane, scrolls().single()) + assertSame(field, editors().single()) + assertEquals( + Color(0xDD, 0xEE, 0xFF).rgb, + editor.colorsScheme.getAttributes(DefaultLanguageHighlighterColors.DOC_CODE_BLOCK).foregroundColor.rgb, + ) + assertEquals(Color(0x44, 0x55, 0x66).rgb, editor.backgroundColor.rgb) + assertEquals(Color(0x44, 0x55, 0x66).rgb, pane.background.rgb) + assertEquals(Color(0x44, 0x55, 0x66).rgb, pane.viewport.background.rgb) + assertEquals(Color(0x44, 0x55, 0x66).rgb, editor.scrollPane.background.rgb) + assertEquals(Color(0x44, 0x55, 0x66).rgb, editor.scrollPane.viewport.background.rgb) + } + fun `test resetStyles keeps content rendered`() { view.set("hello **world**") view.font = view.font.deriveFont(25f) @@ -570,4 +600,26 @@ class MdViewHybridTest : BasePlatformTestCase() { private fun drainEdt() { UIUtil.dispatchAllInvocationEvents() } + + private fun customStyle(): SessionEditorStyle { + val scheme = EditorColorsManager.getInstance().globalScheme.clone() as EditorColorsScheme + scheme.setAttributes( + HighlighterColors.TEXT, + TextAttributes(Color(0x10, 0x20, 0x30), Color(0x01, 0x02, 0x03), null, null, Font.PLAIN), + ) + scheme.setAttributes( + DefaultLanguageHighlighterColors.DOC_CODE_INLINE, + TextAttributes(Color(0xAA, 0xBB, 0xCC), Color(0x11, 0x22, 0x33), null, null, Font.PLAIN), + ) + scheme.setAttributes( + DefaultLanguageHighlighterColors.DOC_CODE_BLOCK, + TextAttributes(Color(0xDD, 0xEE, 0xFF), Color(0x44, 0x55, 0x66), null, null, Font.PLAIN), + ) + scheme.setAttributes( + CodeInsightColors.HYPERLINK_ATTRIBUTES, + TextAttributes(Color(0x77, 0x88, 0x99), null, null, null, Font.PLAIN), + ) + scheme.setColor(EditorColors.PREVIEW_BORDER_COLOR, Color(0x22, 0x33, 0x44)) + return SessionEditorStyle.create(scheme = scheme, family = "Courier New", size = 21) + } } 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 9a2a736b21c..8a0168b9125 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,6 +1,13 @@ package ai.kilocode.client.ui.md import ai.kilocode.client.session.ui.style.SessionEditorStyle +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors +import com.intellij.openapi.editor.HighlighterColors +import com.intellij.openapi.editor.colors.CodeInsightColors +import com.intellij.openapi.editor.colors.EditorColors +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.editor.colors.EditorColorsScheme +import com.intellij.openapi.editor.markup.TextAttributes import com.intellij.testFramework.fixtures.BasePlatformTestCase import java.awt.Color import java.awt.Font @@ -221,6 +228,45 @@ class MdViewTest : BasePlatformTestCase() { assertTrue(view.overrideSheet().contains("${style.editorSize}pt")) } + fun `test override sheet includes markdown role color rules`() { + val sheet = view.overrideSheet() + + assertTrue(sheet.contains("h1, h2, h3, h4, h5, h6")) + assertTrue(sheet.contains("strong, b")) + assertTrue(sheet.contains("em, i")) + assertTrue(sheet.contains("ul, ol")) + assertTrue(sheet.contains("li { color:")) + assertTrue(sheet.contains("blockquote")) + assertTrue(sheet.contains("th, td")) + assertTrue(sheet.contains("th { color:")) + assertTrue(sheet.contains("hr {")) + assertTrue(sheet.contains("pre code")) + } + + fun `test override sheet separates table and code block borders`() { + view.tableBorder = Color(0x12, 0x34, 0x56) + val sheet = view.overrideSheet() + val pre = sheet.substringAfter("pre {").substringBefore("} pre code") + val cells = sheet.substringAfter("th, td {").substringBefore("}") + + assertTrue(cells.contains("#123456")) + assertFalse(pre.contains("#123456")) + assertTrue(pre.contains("border-color:")) + } + + fun `test applyStyle derives markdown colors from editor scheme`() { + val style = customStyle() + + view.applyStyle(style) + val sheet = view.overrideSheet() + + assertTrue(sheet.contains("a { color: #778899")) + assertTrue(sheet.contains("code { background: #112233; color: #aabbcc")) + assertTrue(sheet.contains("pre { background: #445566; color: #ddeeff; border-color: #223344")) + assertTrue(sheet.contains("blockquote { border-left-color: #223344; color: #334455")) + assertTrue(sheet.contains("th, td { border-color: #223344")) + } + // ---- style overrides appear in override sheet when set ---- fun `test foreground override appears in override sheet`() { @@ -433,4 +479,30 @@ class MdViewTest : BasePlatformTestCase() { assertTrue(view.html().contains("")) } + private fun customStyle(): SessionEditorStyle { + val scheme = EditorColorsManager.getInstance().globalScheme.clone() as EditorColorsScheme + scheme.setAttributes( + HighlighterColors.TEXT, + TextAttributes(Color(0x10, 0x20, 0x30), Color(0x01, 0x02, 0x03), null, null, Font.PLAIN), + ) + scheme.setAttributes( + DefaultLanguageHighlighterColors.DOC_COMMENT, + TextAttributes(Color(0x33, 0x44, 0x55), null, null, null, Font.PLAIN), + ) + scheme.setAttributes( + DefaultLanguageHighlighterColors.DOC_CODE_INLINE, + TextAttributes(Color(0xAA, 0xBB, 0xCC), Color(0x11, 0x22, 0x33), null, null, Font.PLAIN), + ) + scheme.setAttributes( + DefaultLanguageHighlighterColors.DOC_CODE_BLOCK, + TextAttributes(Color(0xDD, 0xEE, 0xFF), Color(0x44, 0x55, 0x66), null, null, Font.PLAIN), + ) + scheme.setAttributes( + CodeInsightColors.HYPERLINK_ATTRIBUTES, + TextAttributes(Color(0x77, 0x88, 0x99), null, null, null, Font.PLAIN), + ) + scheme.setColor(EditorColors.PREVIEW_BORDER_COLOR, Color(0x22, 0x33, 0x44)) + return SessionEditorStyle.create(scheme = scheme, family = "Courier New", size = 21) + } + } From a73ee5329cf4455d33d8c8fd363ccf83b46a3cdb Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 12 Jun 2026 11:36:16 -0400 Subject: [PATCH 07/16] feat(jetbrains): render shell output as markdown --- .changeset/render-jetbrains-shell-markdown.md | 5 + .../kilocode/client/session/views/TextView.kt | 2 +- .../client/session/views/ViewFactory.kt | 4 + .../session/views/tool/ShellToolView.kt | 321 ++++++++++++++++++ .../ai/kilocode/client/ui/md/MdViewFactory.kt | 27 ++ .../ai/kilocode/client/ui/md/MdViewHybrid.kt | 74 +++- .../resources/messages/KiloBundle.properties | 3 + .../client/session/views/ShellToolViewTest.kt | 305 +++++++++++++++++ .../session/views/ToolBodyStressTest.kt | 24 +- .../client/session/views/TurnViewTest.kt | 4 +- .../kilocode/client/ui/md/MdViewHybridTest.kt | 54 ++- 11 files changed, 799 insertions(+), 24 deletions(-) create mode 100644 .changeset/render-jetbrains-shell-markdown.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt diff --git a/.changeset/render-jetbrains-shell-markdown.md b/.changeset/render-jetbrains-shell-markdown.md new file mode 100644 index 00000000000..267e7bd8059 --- /dev/null +++ b/.changeset/render-jetbrains-shell-markdown.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Render JetBrains shell tool output with markdown code blocks. 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 abbd2a5ab9a..8b5c30eee0f 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 @@ -17,7 +17,7 @@ import java.awt.BorderLayout */ open class TextView( text: Text, - transparent: Boolean = false, + transparent: Boolean = true, openUrl: (String) -> Unit = {}, selection: SessionSelection? = null, ) : PartView() { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt index a13dde6e1ce..578582be3f6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.session.views.question.QuestionResultView import ai.kilocode.client.session.views.tool.GlobToolView import ai.kilocode.client.session.views.tool.ReadToolView import ai.kilocode.client.session.views.tool.SearchToolView +import ai.kilocode.client.session.views.tool.ShellToolView import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.model.Compaction @@ -50,6 +51,7 @@ object ViewFactory { TodoWriteView.canRender(content) -> TodoWriteView(content) PlanExitView.canRender(content) -> PlanExitView(content, openFile, selection) QuestionResultView.canRender(content) -> QuestionResultView(content, selection) + ShellToolView.canRender(content) -> ShellToolView(content, selection = selection) GlobToolView.canRender(content) -> GlobToolView(content, selection = selection, repo = repo) SearchToolView.canRender(content) -> SearchToolView(content, selection = selection, repo = repo) ReadToolView.canRender(content) -> ReadToolView(content, openFile, selection = selection) @@ -94,6 +96,8 @@ object ViewFactory { if (view is PlanExitView) return !PlanExitView.canRender(content) if (view !is PlanExitView && PlanExitView.canRender(content)) return true if (view is QuestionResultView) return !QuestionResultView.canRender(content) + if (view is ShellToolView) return !ShellToolView.canRender(content) || QuestionResultView.canRender(content) + if (view !is ShellToolView && ShellToolView.canRender(content)) return true if (view is GlobToolView) return !GlobToolView.canRender(content) || QuestionResultView.canRender(content) if (view !is GlobToolView && GlobToolView.canRender(content)) return true if (view is SearchToolView) return !SearchToolView.canRender(content) || QuestionResultView.canRender(content) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt new file mode 100644 index 00000000000..cec374701fd --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt @@ -0,0 +1,321 @@ +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.base.SecondarySessionPartView +import ai.kilocode.client.ui.md.MdCodeBlockBorder +import ai.kilocode.client.ui.md.MdCodeBlockFactory +import ai.kilocode.client.ui.md.MdCodeBlockOptions +import ai.kilocode.client.ui.md.MdViewFactory +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBHtmlPane +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.Dimension +import javax.swing.JPanel +import javax.swing.ScrollPaneConstants + +class ShellToolView( + tool: Tool, + selection: SessionSelection? = null, + private val parts: ToolParts = toolParts(tool), + private val holder: ShellHolder = ShellHolder(tool, selection), +) : SecondarySessionPartView(parts.header, { holder.body().panel }) { + + override val contentId: String = tool.id + + private var item = tool + private var style = SessionEditorStyle.current() + + init { + holder.parent = this + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) + applyStyle(style) + sync() + } + + @RequiresEdt + override fun expand(): Boolean { + val changed = super.expand() + if (!changed) return false + syncBody() + holder.shell?.applyStyle(style) + return true + } + + @RequiresEdt + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + if (!bodyVisible()) return size + val height = row.preferredSize.height + (holder.shell?.panel?.preferredSize?.height ?: 0) + return Dimension(size.width, minOf(size.height, height)) + } + + @RequiresEdt + override fun update(content: Content) { + if (content !is Tool) return + val was = item.name + item = content + var changed = false + if (was != content.name || !canExpand(content)) changed = collapse() || changed + changed = sync() || changed + changed = syncBody() || changed + if (changed) refresh() + } + + @RequiresEdt + fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text) + .filter { it.isNotBlank() } + .joinToString(" ") + + @RequiresEdt + fun commandText(): String = command(item) + + @RequiresEdt + fun outputText(): String = clean(output(item)) + + @RequiresEdt + fun errorText(): String = clean(item.error.orEmpty()) + + @RequiresEdt + fun bodyText(): String = ShellContent(item).body + + @RequiresEdt + fun hasToggle(): Boolean = arrow.isVisible + + @RequiresEdt + internal fun bodyCreated() = holder.shell != null + + @RequiresEdt + internal fun bodyVisible() = holder.shell?.panel?.parent === this + + @RequiresEdt + internal fun markdown() = holder.shell?.markdown() ?: ShellContent(item).markdown + + @RequiresEdt + internal fun codeEditors(): List = holder.shell?.codeEditors() ?: emptyList() + + @RequiresEdt + internal fun commandFont() = codeEditors().firstOrNull()?.font ?: style.editorFont + + @RequiresEdt + internal fun titleFont() = parts.title.font + + @RequiresEdt + internal fun subtitleFont() = parts.sub.font + + @RequiresEdt + internal fun stateFont() = parts.state.font + + @RequiresEdt + internal fun controlCount() = if (arrow.isVisible) 1 else 0 + + @RequiresEdt + internal fun mdComponent() = holder.shell?.mdComponent() + + @RequiresEdt + internal fun horizontalPolicy() = holder.shell?.scrolls()?.firstOrNull()?.horizontalScrollBarPolicy + ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + + @RequiresEdt + override fun applyStyle(style: SessionEditorStyle) { + this.style = style + var changed = false + changed = setFont(parts.title, style.boldEditorFont) || changed + changed = setFont(parts.sub, style.smallEditorFont) || changed + changed = setFont(parts.link, style.smallEditorFont) || changed + changed = setFont(parts.state, style.smallEditorFont) || changed + holder.shell?.let { changed = it.applyStyle(style) || changed } + if (changed) refresh() + } + + private fun sync(): Boolean { + val expand = canExpand(item) + var changed = false + changed = syncExpandable(expand) || changed + changed = setVisible(parts.state, !expand) || changed + changed = setIcon(parts.glyph, icon(item)) || changed + changed = setForeground(parts.glyph, color(item)) || changed + changed = setText(parts.title, title(item)) || changed + changed = setText(parts.sub, subtitle(item)) || changed + changed = setForeground(parts.title, titleColor(item)) || changed + changed = setText(parts.state, stateText(item)) || changed + changed = setForeground(parts.state, color(item)) || changed + return changed + } + + private fun syncBody(): Boolean { + val body = holder.shell ?: return false + return body.update(item) + } + + override fun dumpLabel() = "ShellToolView#$contentId(${labelText()})" + + companion object { + fun canRender(tool: Tool) = tool.name == "bash" + } +} + +class ShellHolder( + private val tool: Tool, + private val selection: SessionSelection?, +) { + var parent: Disposable? = null + var shell: ShellBody? = null + + @RequiresEdt + fun body(): ShellBody { + val current = shell + if (current != null) return current + val owner = parent ?: error("Shell holder has no parent") + return ShellBody(tool, selection, owner).also { + shell = it + Disposer.register(owner, it) + } + } +} + +class ShellBody( + tool: Tool, + selection: SessionSelection?, + parent: Disposable, +) : Disposable { + private val md = MdViewFactory.create( + SessionEditorStyle.current(), + selection, + MdCodeBlockFactory.default( + MdCodeBlockOptions( + border = MdCodeBlockBorder.Horizontal, + maxLines = 15, + verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, + editorOnly = true, + ), + ), + ) + val panel = md.component + + init { + Disposer.register(parent, md) + applyStyle(SessionEditorStyle.current()) + update(tool) + } + + @RequiresEdt + fun update(tool: Tool): Boolean { + val content = ShellContent(tool) + if (md.markdown() == content.markdown) return false + md.set(content.markdown) + styleShell() + return true + } + + @RequiresEdt + fun applyStyle(style: SessionEditorStyle): Boolean { + val before = md.font + md.applyStyle(style) + md.font = style.transcriptFont + md.foreground = style.editorForeground + md.background = style.editorBackground + md.preBg = style.editorBackground + md.codeFont = style.editorFamily + md.component.border = JBUI.Borders.empty() + styleShell() + return before != md.font + } + + @RequiresEdt + private fun styleShell() { + val root = md.component as? JPanel ?: return + root.components.filterIsInstance().forEach { + it.border = JBUI.Borders.emptyLeft(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING)) + } + } + + @RequiresEdt + fun markdown() = md.markdown() + + @RequiresEdt + fun mdComponent() = md.component + + @RequiresEdt + fun scrolls(): List = (md.component as? JPanel)?.components?.filterIsInstance() ?: emptyList() + + @RequiresEdt + fun codeEditors(): List = scrolls().mapNotNull { it.viewport.view as? EditorTextField } + + override fun dispose() = Unit +} + +private data class ShellContent(val command: String, val output: String, val error: String) { + constructor(tool: Tool) : this(command(tool), clean(output(tool)), clean(tool.error.orEmpty())) + + val body: String = listOf(command, output, error).filter { it.isNotBlank() }.joinToString("\n\n") + + val markdown: String = buildString { + section(KiloBundle.message("session.part.tool.shell.command"), command) + section(KiloBundle.message("session.part.tool.shell.output"), output) + section(KiloBundle.message("session.part.tool.shell.error"), error) + } +} + +private fun StringBuilder.section(title: String, text: String) { + if (text.isBlank()) return + if (isNotEmpty()) append("\n\n") + val fence = fence(text) + append("### ").append(title).append("\n\n") + append(fence).append("shell\n") + append(text) + if (!text.endsWith('\n')) append('\n') + append(fence) +} + +private fun fence(text: String): String { + val size = Regex("`+").findAll(text).maxOfOrNull { it.value.length } ?: 0 + return "`".repeat(maxOf(3, size + 1)) +} + +private fun clean(text: String): String = normalize(strip(text)) + +private fun strip(text: String): String = ANSI.replace(text, "") + +private fun normalize(text: String): String = lines(text.replace("\r\n", "\n")).joinToString("\n") { line -> + if ('\r' !in line) return@joinToString line + frames(line).lastOrNull { it.isNotEmpty() } ?: "" +} + +private fun lines(text: String): List { + val list = mutableListOf() + var start = 0 + while (true) { + val index = text.indexOf('\n', start) + if (index < 0) { + list.add(text.substring(start)) + return list + } + list.add(text.substring(start, index)) + start = index + 1 + } +} + +private fun frames(text: String): List { + val list = mutableListOf() + var start = 0 + while (true) { + val index = text.indexOf('\r', start) + if (index < 0) { + list.add(text.substring(start)) + return list + } + list.add(text.substring(start, index)) + start = index + 1 + } +} + +private val ANSI = Regex("\\u001B\\[[0-?]*[ -/]*[@-~]") 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 index 259247aeb68..dc224de2e59 100644 --- 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 @@ -2,14 +2,41 @@ package ai.kilocode.client.ui.md import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.selection.SessionSelection +import javax.swing.ScrollPaneConstants object MdViewFactory { fun create(style: SessionEditorStyle = SessionEditorStyle.current(), selection: SessionSelection? = null): MdView = hybrid(style, selection) + fun create(style: SessionEditorStyle, selection: SessionSelection?, code: MdCodeBlockFactory): MdView = + hybrid(style, selection, code) + fun hybrid(style: SessionEditorStyle = SessionEditorStyle.current(), selection: SessionSelection? = null): MdView = MdViewHybrid(style, selection) + fun hybrid( + style: SessionEditorStyle, + selection: SessionSelection?, + code: MdCodeBlockFactory, + ): MdView = MdViewHybrid(style, selection, code) + + fun hybrid(code: MdCodeBlockFactory): MdView = hybrid(SessionEditorStyle.current(), null, code) + fun html(style: SessionEditorStyle = SessionEditorStyle.current(), selection: SessionSelection? = null): MdView = MdViewHtmlPane(style, selection) } + +data class MdCodeBlockOptions( + val border: MdCodeBlockBorder = MdCodeBlockBorder.All, + val maxLines: Int? = null, + val verticalPolicy: Int = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, + val editorOnly: Boolean = false, +) + +enum class MdCodeBlockBorder { All, Horizontal } + +data class MdCodeBlockFactory(val opts: MdCodeBlockOptions = MdCodeBlockOptions()) { + companion object { + fun default(opts: MdCodeBlockOptions = MdCodeBlockOptions()) = MdCodeBlockFactory(opts) + } +} 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 3d797f359a2..a9a5a6bea1b 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 @@ -45,6 +45,7 @@ import javax.swing.text.html.StyleSheet internal class MdViewHybrid( style: SessionEditorStyle = SessionEditorStyle.current(), private var selection: SessionSelection? = null, + private val code: MdCodeBlockFactory = MdCodeBlockFactory.default(), ) : MdView { companion object { private val LOG = KiloLog.create(MdViewHybrid::class.java) @@ -64,6 +65,8 @@ internal class MdViewHybrid( "sh" to "sh", "bash" to "sh", "shell" to "sh", + "zsh" to "sh", + "shellscript" to "sh", "json" to "json", "xml" to "xml", "html" to "html", @@ -163,7 +166,6 @@ internal class MdViewHybrid( layout = BoxLayout(this, BoxLayout.Y_AXIS) isOpaque = true background = opts().background - border = JBUI.Borders.emptyLeft(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING)) } override val component: JComponent get() = root @@ -497,22 +499,31 @@ internal class MdViewHybrid( private fun codeBlock(text: String, file: FileType, disposable: Disposable): JBScrollPane { val opts = opts() val value = text.trimEnd('\n') - val field = runCatching { - CodeField(file, opts, text).also { ed -> - Disposer.register(disposable) { - ed.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) - } - ed.setDisposedWith(disposable) - selection?.register(ed, disposable) + fun editor(type: FileType) = CodeField(type, opts, text).also { ed -> + Disposer.register(disposable) { + ed.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) } + ed.setDisposedWith(disposable) + selection?.register(ed, disposable) + } + val field = runCatching { + editor(file) }.getOrElse { err -> LOG.warn("kind=markdown codeEditor=true failed message=${err.message}", err) - textArea(text, opts, disposable) + if (code.opts.editorOnly) runCatching { + editor(PlainTextFileType.INSTANCE) + }.getOrElse { fallback -> + LOG.warn("kind=markdown codeEditor=true fallback=plain failed message=${fallback.message}", fallback) + throw fallback + } else { + textArea(text, opts, disposable) + } } sizeCodeField(field, value) val pane = object : JBScrollPane(field) { override fun doLayout() { super.doLayout() + if (code.opts.verticalPolicy != ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER) return val view = viewport.view ?: return val size = viewport.extentSize if (size.height <= 0 || view.height == size.height) return @@ -526,7 +537,11 @@ internal class MdViewHybrid( private fun styleCodePane(pane: JBScrollPane, opts: MdStyle) { pane.apply { - border = JBUI.Borders.customLine(opts.codeBorder, SessionUiStyle.View.Code.BORDER_WIDTH) + val width = SessionUiStyle.View.Code.BORDER_WIDTH + border = when (code.opts.border) { + MdCodeBlockBorder.All -> JBUI.Borders.customLine(opts.codeBorder, width) + MdCodeBlockBorder.Horizontal -> JBUI.Borders.customLine(opts.codeBorder, width, 0, width, 0) + } viewportBorder = JBUI.Borders.empty( SessionUiStyle.View.Code.topPadding(), SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING, @@ -537,12 +552,14 @@ internal class MdViewHybrid( background = opts.preBg viewport.background = opts.preBg horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED - verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + verticalScrollBarPolicy = code.opts.verticalPolicy isWheelScrollingEnabled = true setOverlappingScrollBar(false) horizontalScrollBar.preferredSize = Dimension(0, JBUI.scale(SessionUiStyle.View.Code.SCROLLBAR_HEIGHT)) horizontalScrollBar.isOpaque = true - verticalScrollBar.preferredSize = JBUI.emptySize() + if (code.opts.verticalPolicy == ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER) { + verticalScrollBar.preferredSize = JBUI.emptySize() + } } } @@ -556,7 +573,13 @@ internal class MdViewHybrid( private fun sizeCodePane(pane: JBScrollPane, component: JComponent) { val pad = pane.viewportBorder.getBorderInsets(pane) - val height = component.preferredSize.height + pane.insets.top + pane.insets.bottom + + val text = when (component) { + is CodeField -> component.text + is JBTextArea -> component.text + else -> "" + } + val content = visibleCodeHeight(component, text) + val height = content + pane.insets.top + pane.insets.bottom + pad.top + pad.bottom + pane.horizontalScrollBar.preferredSize.height pane.preferredSize = Dimension(0, height) pane.minimumSize = Dimension(0, height) @@ -583,6 +606,21 @@ internal class MdViewHybrid( return line * rows } + private fun visibleCodeHeight(component: JComponent, text: String): Int { + val max = code.opts.maxLines ?: return component.preferredSize.height + val count = text.lineSequence().count() + val rows = count.coerceAtLeast(SessionUiStyle.View.Code.MIN_ROWS).coerceAtMost(max) + 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 line * rows + } + val line = component.getFontMetrics(component.font).height + return line * rows + } + private fun textArea(text: String, opts: MdStyle, disposable: Disposable) = JBTextArea(text.trimEnd('\n')).apply { isEditable = false lineWrap = false @@ -629,8 +667,14 @@ internal class MdViewHybrid( } private fun file(lang: String?): FileType { - val key = lang?.trim()?.split(Regex("\\s+"))?.firstOrNull()?.lowercase().orEmpty() - val ext = FILES[key] ?: return PlainTextFileType.INSTANCE + val key = lang?.trim()?.split(Regex("\\s+"))?.take(2)?.joinToString(" ")?.lowercase().orEmpty() + if (key == "shell script") return type("sh") + val single = key.substringBefore(' ') + val ext = FILES[key] ?: FILES[single] ?: return PlainTextFileType.INSTANCE + return type(ext) + } + + private fun type(ext: String): FileType { val type = FileTypeRegistry.getInstance().getFileTypeByExtension(ext) if (type == UnknownFileType.INSTANCE) return PlainTextFileType.INSTANCE return type diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 80e5e0ed527..7437100221e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -92,6 +92,9 @@ session.part.tool.glob=Glob session.part.tool.search=Search session.part.tool.running=Running session.part.tool.shell=Shell +session.part.tool.shell.command=Command +session.part.tool.shell.error=Error +session.part.tool.shell.output=Output session.part.tool.truncated=Output truncated in preview. Full output remains in session data. session.part.plan.ready=Plan is ready session.part.todo.title=To-dos diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt new file mode 100644 index 00000000000..282acd4546b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt @@ -0,0 +1,305 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.model.toolKind +import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.tool.ShellToolView +import ai.kilocode.client.session.views.tool.ToolView +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBHtmlPane +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import javax.swing.ScrollPaneConstants + +@Suppress("UnstableApiUsage") +class ShellToolViewTest : BasePlatformTestCase() { + private val views = mutableListOf() + + override fun tearDown() { + try { + views.forEach(Disposer::dispose) + views.clear() + } finally { + super.tearDown() + } + } + + fun `test command only shell renders markdown`() { + val view = track(ShellToolView(tool().also { it.input = mapOf("command" to "pwd") })) + + assertTrue(view.hasToggle()) + assertFalse(view.bodyCreated()) + assertEquals("pwd", view.bodyText()) + view.toggle() + + assertEquals("### Command\n\n```shell\npwd\n```", view.markdown()) + assertEquals(listOf("pwd"), view.codeTexts()) + } + + fun `test output only shell renders markdown`() { + val view = track(ShellToolView(tool().also { it.output = "done" })) + + assertEquals("done", view.outputText()) + view.toggle() + + assertEquals("### Output\n\n```shell\ndone\n```", view.markdown()) + assertEquals(listOf("done"), view.codeTexts()) + } + + fun `test command and output render sections in order`() { + val view = track(ShellToolView(tool().also { + it.input = mapOf("command" to "git status", "description" to "Check status") + it.output = "clean" + })) + + assertTrue(view.labelText().contains("Shell")) + assertTrue(view.labelText().contains("Check status")) + assertEquals("git status", view.commandText()) + assertEquals("clean", view.outputText()) + assertEquals("git status\n\nclean", view.bodyText()) + view.toggle() + + assertEquals( + "### Command\n\n```shell\ngit status\n```\n\n### Output\n\n```shell\nclean\n```", + view.markdown(), + ) + assertEquals(listOf("git status", "clean"), view.codeTexts()) + } + + fun `test ansi escapes are stripped from output`() { + val view = track(ShellToolView(tool().also { it.output = "\u001B[32mgreen\u001B[0m line" })) + + assertEquals("green line", view.outputText()) + view.toggle() + + assertEquals(listOf("green line"), view.codeTexts()) + } + + fun `test carriage return frames keep last non-empty value`() { + val view = track(ShellToolView(tool().also { + it.output = "progress 1\rprogress 2\rprogress done\nstdout line\n" + })) + + assertEquals("progress done\nstdout line\n", view.outputText()) + view.toggle() + + assertEquals(listOf("progress done\nstdout line"), view.codeTexts()) + } + + fun `test output backticks use longer markdown fence`() { + val view = track(ShellToolView(tool().also { it.output = "before\n```\nafter" })) + + view.toggle() + + assertTrue(view.markdown().contains("````shell\nbefore\n```\nafter\n````")) + assertEquals(listOf("before\n```\nafter"), view.codeTexts()) + } + + fun `test error section uses shell error text`() { + val view = track(ShellToolView(tool(ToolExecState.ERROR).also { + it.input = mapOf("command" to "fail") + it.error = "boom" + })) + + assertEquals("boom", view.errorText()) + assertTrue(view.labelText().contains("Error")) + view.toggle() + + assertEquals( + "### Command\n\n```shell\nfail\n```\n\n### Error\n\n```shell\nboom\n```", + view.markdown(), + ) + assertEquals(listOf("fail", "boom"), view.codeTexts()) + } + + fun `test body is created lazily and reused`() { + val view = track(ShellToolView(tool().also { + it.input = mapOf("command" to "pwd") + it.output = "/tmp" + })) + + assertFalse(view.bodyCreated()) + view.toggle() + val body = view.mdComponent() + val cmd = view.codeEditors().first() + val out = view.codeEditors().last() + view.toggle() + view.toggle() + + assertSame(body, view.mdComponent()) + assertSame(cmd, view.codeEditors().first()) + assertSame(out, view.codeEditors().last()) + assertTrue(view.bodyVisible()) + } + + fun `test collapsed update does not create body`() { + val view = track(ShellToolView(tool(ToolExecState.RUNNING).also { + it.input = mapOf("command" to "pwd") + it.output = "/tmp" + })) + + view.update(tool().also { + it.input = mapOf("command" to "pwd") + it.output = "/home" + }) + + assertFalse(view.bodyCreated()) + assertEquals("pwd\n\n/home", view.bodyText()) + } + + fun `test update after first expand changes existing markdown body`() { + val view = track(ShellToolView(tool(ToolExecState.RUNNING).also { + it.input = mapOf("command" to "pwd") + it.output = "/tmp" + })) + + view.toggle() + view.toggle() + val body = view.mdComponent() + val cmd = view.codeEditors().first() + val out = view.codeEditors().last() + view.update(tool().also { + it.input = mapOf("command" to "pwd") + it.output = "/home" + }) + + assertTrue(view.bodyCreated()) + assertFalse(view.bodyVisible()) + assertSame(body, view.mdComponent()) + assertSame(cmd, view.codeEditors().first()) + assertSame(out, view.codeEditors().last()) + assertEquals(listOf("pwd", "/home"), view.codeTexts()) + } + + fun `test applyStyle updates fonts in place`() { + val view = track(ShellToolView(tool().also { it.output = "done" })) + val style = SessionEditorStyle.create(family = "Courier New", size = 25) + view.toggle() + val editor = view.codeEditors().single() + + view.applyStyle(style) + + assertSame(editor, view.codeEditors().single()) + assertEquals(style.editorFont.name, view.commandFont().name) + assertEquals(style.editorSize, view.commandFont().size) + assertEquals(style.transcriptFont.name, view.titleFont().name) + assertTrue(view.titleFont().isBold) + assertTrue(view.subtitleFont().size < style.editorSize) + assertTrue(view.stateFont().size < style.editorSize) + } + + fun `test selection registers shell markdown editors`() { + val selection = SessionSelection() + val view = track(ShellToolView(tool().also { it.input = mapOf("command" to "pwd") }, selection)) + view.toggle() + val editor = view.codeEditors().single().getEditor(true) + editor?.selectionModel?.setSelection(0, 3) + + assertEquals("pwd", selection.selectedText()) + Disposer.dispose(selection) + } + + fun `test shell view uses editor backed markdown code blocks`() { + val style = SessionEditorStyle.current() + val view = track(ShellToolView(tool().also { it.output = "done" })) + view.toggle() + + assertEquals(style.editorFont.name, view.commandFont().name) + assertEquals(1, view.codeEditors().size) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, view.horizontalPolicy()) + assertTrue(view.preferredSize.height > 0) + } + + fun `test shell labels are inset and code blocks use horizontal borders`() { + val view = track(ShellToolView(tool().also { + it.input = mapOf("command" to "pwd") + it.output = "/tmp" + })) + view.toggle() + val root = view.mdComponent()!! + val ins = root.border?.getBorderInsets(root) + val labels = root.components.filterIsInstance() + val panes = root.components.filterIsInstance() + + assertEquals(0, ins?.left ?: 0) + assertEquals(2, labels.size) + assertEquals(2, panes.size) + labels.forEach { + val label = it.border.getBorderInsets(it) + assertEquals(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), label.left) + assertEquals(0, label.right) + } + panes.forEach { + val pane = it.border.getBorderInsets(it) + assertEquals(SessionUiStyle.View.Code.BORDER_WIDTH, pane.top) + assertEquals(SessionUiStyle.View.Code.BORDER_WIDTH, pane.bottom) + assertEquals(0, pane.left) + assertEquals(0, pane.right) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, it.verticalScrollBarPolicy) + assertEquals(root.background.rgb, it.background.rgb) + assertEquals(root.background.rgb, it.viewport.background.rgb) + } + } + + fun `test shell code blocks are editor backed and capped to fifteen lines`() { + val output = (1..30).joinToString("\n") { "line $it" } + val view = track(ShellToolView(tool().also { it.output = output })) + view.toggle() + val root = view.mdComponent()!! + val pane = root.components.filterIsInstance().single() + val editor = view.codeEditors().single() + val chrome = pane.insets.top + pane.insets.bottom + + pane.viewportBorder.getBorderInsets(pane).top + pane.viewportBorder.getBorderInsets(pane).bottom + + pane.horizontalScrollBar.preferredSize.height + + assertEquals(output, editor.text) + assertEquals(1, view.codeEditors().size) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, pane.verticalScrollBarPolicy) + assertTrue(editor.preferredSize.height > pane.preferredSize.height - chrome) + assertTrue(pane.preferredSize.height < editor.preferredSize.height + chrome) + } + + fun `test view factory routes bash and replaces generic views`() { + val bash = tool() + val other = Tool("p1", "mystery", toolKind("mystery")).also { it.state = ToolExecState.COMPLETED } + + assertTrue(ViewFactory.create(bash, openFile = {}) is ShellToolView) + assertTrue(ViewFactory.shouldReplace(ToolView(bash), bash)) + assertTrue(ViewFactory.shouldReplace(ShellToolView(bash), other)) + assertFalse(ViewFactory.shouldReplace(ShellToolView(bash), bash)) + } + + fun `test shell editors are disposed after churn`() { + val base = EditorFactory.getInstance().allEditors.size + + repeat(40) { i -> + val view = ShellToolView(tool().also { + it.input = mapOf("command" to "log $i") + it.output = (1..20).joinToString("\n") { line -> "line $i/$line" } + }) + view.toggle() + view.codeEditors().forEach { it.getEditor(true) } + Disposer.dispose(view) + } + UIUtil.dispatchAllInvocationEvents() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + + private fun ShellToolView.codeTexts() = codeEditors().map { it.text } + + private fun tool(state: ToolExecState = ToolExecState.COMPLETED) = Tool("p1", "bash", toolKind("bash")).also { + it.state = state + } + + private fun track(view: ShellToolView): ShellToolView { + views.add(view) + return view + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolBodyStressTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolBodyStressTest.kt index 3b5171f4f3a..f2595428c43 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolBodyStressTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolBodyStressTest.kt @@ -5,6 +5,7 @@ import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind import ai.kilocode.client.session.views.tool.GlobToolView import ai.kilocode.client.session.views.tool.SearchToolView +import ai.kilocode.client.session.views.tool.ShellToolView import ai.kilocode.client.session.views.tool.ToolView import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.util.Disposer @@ -14,7 +15,7 @@ import com.intellij.util.ui.UIUtil @Suppress("UnstableApiUsage") class ToolBodyStressTest : BasePlatformTestCase() { - fun `test expanded tool body editors are disposed after churn`() { + fun `test expanded generic tool body editors are disposed after churn`() { val base = EditorFactory.getInstance().allEditors.size repeat(60) { i -> @@ -28,6 +29,20 @@ class ToolBodyStressTest : BasePlatformTestCase() { assertEquals(base, EditorFactory.getInstance().allEditors.size) } + fun `test expanded shell tool editors are disposed after churn`() { + val base = EditorFactory.getInstance().allEditors.size + + repeat(60) { i -> + val view = ShellToolView(shell(i)) + view.toggle() + view.codeEditors().forEach { it.getEditor(true) } + Disposer.dispose(view) + } + drainEdt() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + fun `test expanded search tool editors are disposed after churn`() { val base = EditorFactory.getInstance().allEditors.size @@ -47,7 +62,12 @@ class ToolBodyStressTest : BasePlatformTestCase() { assertEquals(base, EditorFactory.getInstance().allEditors.size) } - private fun tool(index: Int) = Tool("p$index", "bash", toolKind("bash")).also { + private fun tool(index: Int) = Tool("p$index", "mystery", toolKind("mystery")).also { + it.state = ToolExecState.COMPLETED + it.output = (1..20).joinToString("\n") { line -> "line $index/$line" } + } + + private fun shell(index: Int) = Tool("p$index", "bash", toolKind("bash")).also { it.state = ToolExecState.COMPLETED it.input = mapOf("command" to "log $index") it.output = (1..20).joinToString("\n") { line -> "line $index/$line" } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt index 976b94a748f..0ede3a0aa29 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt @@ -151,14 +151,14 @@ class TurnViewTest : BasePlatformTestCase() { assertFalse((mv.part("p1") as TextView).contentOpaque()) } - fun `test assistant text view remains opaque`() { + fun `test assistant text view is transparent`() { val mv = MessageView(msg("a1", "assistant"), openFile) val text = ai.kilocode.client.session.model.Text("p1") text.content.append("hello") mv.upsertPart(text) - assertTrue((mv.part("p1") as TextView).contentOpaque()) + assertFalse((mv.part("p1") as TextView).contentOpaque()) } fun `test upsertPart updates existing part rather than adding duplicate`() { 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 254f01f6600..ba5e2dd651b 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 @@ -51,11 +51,11 @@ class MdViewHybridTest : BasePlatformTestCase() { assertEquals("hello **world**", view.markdown()) } - fun `test root has small left inset`() { - val ins = view.component.border.getBorderInsets(view.component) + fun `test root has no renderer owned left inset`() { + val ins = view.component.border?.getBorderInsets(view.component) - assertEquals(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), ins.left) - assertEquals(0, ins.right) + assertEquals(0, ins?.left ?: 0) + assertEquals(0, ins?.right ?: 0) } fun `test append renders accumulated source`() { @@ -120,6 +120,36 @@ class MdViewHybridTest : BasePlatformTestCase() { assertTrue(pane.preferredSize.height >= line * 24) } + fun `test custom code block options cap editor height and enable vertical scrolling`() { + Disposer.dispose(view) + view = MdViewFactory.hybrid( + code = MdCodeBlockFactory.default( + MdCodeBlockOptions( + border = MdCodeBlockBorder.Horizontal, + maxLines = 3, + verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, + editorOnly = true, + ), + ), + ) + val code = (1..12).joinToString("\n") { "val value$it = $it" } + view.set("```kotlin\n$code\n```") + val pane = scrolls().single() + val editor = editors().single() + val ins = pane.border.getBorderInsets(pane) + val chrome = pane.insets.top + pane.insets.bottom + + pane.viewportBorder.getBorderInsets(pane).top + pane.viewportBorder.getBorderInsets(pane).bottom + + pane.horizontalScrollBar.preferredSize.height + + assertEquals(SessionUiStyle.View.Code.BORDER_WIDTH, ins.top) + assertEquals(SessionUiStyle.View.Code.BORDER_WIDTH, ins.bottom) + assertEquals(0, ins.left) + assertEquals(0, ins.right) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, pane.verticalScrollBarPolicy) + assertTrue(editor.preferredSize.height > pane.preferredSize.height - chrome) + assertTrue(pane.preferredSize.height < editor.preferredSize.height + chrome) + } + 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```") @@ -415,6 +445,22 @@ class MdViewHybridTest : BasePlatformTestCase() { assertSame(type("js"), editors().single().fileType) } + fun `test shell code fence resolves shell file type`() { + view.set("```shell\necho hi\n```") + + assertSame(type("sh"), editors().single().fileType) + } + + fun `test shell script aliases resolve shell file type`() { + view.set("```shell script\necho hi\n```") + + assertSame(type("sh"), editors().single().fileType) + + view.set("```zsh\necho hi\n```") + + assertSame(type("sh"), editors().single().fileType) + } + fun `test fenced code block ignores whitespace metadata`() { view.set("```json title=\"sample.json\"\n{\"value\":1}\n```") From 370ddce5ae888561a336c25bb4c433635ff1bc34 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 12 Jun 2026 12:02:31 -0400 Subject: [PATCH 08/16] fix(jetbrains): refine tool header spacing --- .../client/session/views/todo/TodoWriteView.kt | 2 +- .../client/session/views/tool/ShellToolView.kt | 9 +++++++-- .../client/session/views/tool/ToolSupport.kt | 4 ++-- .../ai/kilocode/client/ui/md/MdViewFactory.kt | 2 +- .../ai/kilocode/client/ui/md/MdViewHybrid.kt | 1 + .../client/session/views/SearchToolViewTest.kt | 10 ++++++++++ .../client/session/views/ShellToolViewTest.kt | 10 +++++++--- .../client/session/views/ToolViewTest.kt | 15 +++++++++++++++ .../session/views/todo/TodoWriteViewTest.kt | 18 ++++++++++++++++++ 9 files changed, 62 insertions(+), 9 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt index a2dcc25d158..78ff1af8fae 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt @@ -101,7 +101,7 @@ private fun todoParts(): TodoParts { val glyph = JBLabel(SessionViewIcons.checklist) val title = JBLabel(KiloBundle.message("session.part.todo.title")) val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } - val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { + val center = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { isOpaque = false add(title, BorderLayout.WEST) add(sub, BorderLayout.CENTER) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt index cec374701fd..9e82636d8d7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt @@ -7,6 +7,7 @@ import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.SecondarySessionPartView +import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.md.MdCodeBlockBorder import ai.kilocode.client.ui.md.MdCodeBlockFactory import ai.kilocode.client.ui.md.MdCodeBlockOptions @@ -111,6 +112,9 @@ class ShellToolView( @RequiresEdt internal fun subtitleFont() = parts.sub.font + @RequiresEdt + internal fun subtitleForeground() = parts.sub.foreground + @RequiresEdt internal fun stateFont() = parts.state.font @@ -129,7 +133,7 @@ class ShellToolView( this.style = style var changed = false changed = setFont(parts.title, style.boldEditorFont) || changed - changed = setFont(parts.sub, style.smallEditorFont) || changed + changed = setFont(parts.sub, style.transcriptFont) || changed changed = setFont(parts.link, style.smallEditorFont) || changed changed = setFont(parts.state, style.smallEditorFont) || changed holder.shell?.let { changed = it.applyStyle(style) || changed } @@ -146,6 +150,7 @@ class ShellToolView( changed = setText(parts.title, title(item)) || changed changed = setText(parts.sub, subtitle(item)) || changed changed = setForeground(parts.title, titleColor(item)) || changed + changed = setForeground(parts.sub, UiStyle.Colors.weak()) || changed changed = setText(parts.state, stateText(item)) || changed changed = setForeground(parts.state, color(item)) || changed return changed @@ -192,7 +197,7 @@ class ShellBody( selection, MdCodeBlockFactory.default( MdCodeBlockOptions( - border = MdCodeBlockBorder.Horizontal, + border = MdCodeBlockBorder.Bottom, maxLines = 15, verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, editorOnly = true, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt index a78246d0605..b13400fd5b5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt @@ -328,7 +328,7 @@ internal fun toolParts( add(link, LINK_CARD) } val state = JBLabel().apply { foreground = UiStyle.Colors.weak() } - val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { isOpaque = false } + val center = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { isOpaque = false } val controls = Stack.horizontal() val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { isOpaque = false @@ -364,7 +364,7 @@ internal fun searchParts(count: Int): ToolParts { val state = JBLabel().apply { foreground = UiStyle.Colors.weak() } val stack = Stack.fitHorizontal(UiStyle.Gap.md()).apply { targets.forEach { next(it) } } val target = stack.align(HAlign.TRACK, VAlign.CENTER) - val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { + val center = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { isOpaque = false minimumSize = JBUI.size(0, minimumSize.height) add(title, BorderLayout.WEST) 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 index dc224de2e59..623794a85c8 100644 --- 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 @@ -33,7 +33,7 @@ data class MdCodeBlockOptions( val editorOnly: Boolean = false, ) -enum class MdCodeBlockBorder { All, Horizontal } +enum class MdCodeBlockBorder { All, Horizontal, Bottom } data class MdCodeBlockFactory(val opts: MdCodeBlockOptions = MdCodeBlockOptions()) { companion object { 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 a9a5a6bea1b..b1213472011 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 @@ -541,6 +541,7 @@ internal class MdViewHybrid( border = when (code.opts.border) { MdCodeBlockBorder.All -> JBUI.Borders.customLine(opts.codeBorder, width) MdCodeBlockBorder.Horizontal -> JBUI.Borders.customLine(opts.codeBorder, width, 0, width, 0) + MdCodeBlockBorder.Bottom -> JBUI.Borders.customLine(opts.codeBorder, 0, 0, width, 0) } viewportBorder = JBUI.Borders.empty( SessionUiStyle.View.Code.topPadding(), diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt index eea06ce279f..e9b090bee9b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt @@ -9,8 +9,10 @@ import ai.kilocode.client.session.views.tool.GlobToolView import ai.kilocode.client.session.views.tool.ReadToolView import ai.kilocode.client.session.views.tool.SearchToolView import ai.kilocode.client.session.views.tool.ToolView +import ai.kilocode.client.ui.UiStyle import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.BorderLayout import java.awt.Container import java.awt.Dimension import javax.swing.ScrollPaneConstants @@ -100,6 +102,14 @@ class SearchToolViewTest : BasePlatformTestCase() { assertEquals(style.regularFont, view.targetFont(1)) } + fun `test search header title target gap uses standard medium gap`() { + val view = SearchToolView(tool().also { + it.input = mapOf("pattern" to "TODO", "include" to "*.kt") + }) + + assertEquals(UiStyle.Gap.md(), (view.centerComponent().layout as BorderLayout).hgap) + } + fun `test completed search starts collapsed and expands output`() { val view = track(SearchToolView(tool().also { it.output = "src/A.kt:1:class A" })) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt index 282acd4546b..c9b4424a155 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt @@ -8,6 +8,7 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.tool.ShellToolView import ai.kilocode.client.session.views.tool.ToolView +import ai.kilocode.client.ui.UiStyle import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase @@ -190,7 +191,10 @@ class ShellToolViewTest : BasePlatformTestCase() { assertEquals(style.editorSize, view.commandFont().size) assertEquals(style.transcriptFont.name, view.titleFont().name) assertTrue(view.titleFont().isBold) - assertTrue(view.subtitleFont().size < style.editorSize) + assertEquals(style.transcriptFont.name, view.subtitleFont().name) + assertEquals(style.transcriptFont.size, view.subtitleFont().size) + assertFalse(view.subtitleFont().isBold) + assertEquals(UiStyle.Colors.weak().rgb, view.subtitleForeground().rgb) assertTrue(view.stateFont().size < style.editorSize) } @@ -216,7 +220,7 @@ class ShellToolViewTest : BasePlatformTestCase() { assertTrue(view.preferredSize.height > 0) } - fun `test shell labels are inset and code blocks use horizontal borders`() { + fun `test shell labels are inset and code blocks use bottom border only`() { val view = track(ShellToolView(tool().also { it.input = mapOf("command" to "pwd") it.output = "/tmp" @@ -237,7 +241,7 @@ class ShellToolViewTest : BasePlatformTestCase() { } panes.forEach { val pane = it.border.getBorderInsets(it) - assertEquals(SessionUiStyle.View.Code.BORDER_WIDTH, pane.top) + assertEquals(0, pane.top) assertEquals(SessionUiStyle.View.Code.BORDER_WIDTH, pane.bottom) assertEquals(0, pane.left) assertEquals(0, pane.right) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt index 1de7afe7eeb..1834157ce6b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt @@ -8,8 +8,10 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.session.views.tool.ToolView +import ai.kilocode.client.ui.UiStyle import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.BorderLayout import java.awt.Color import java.awt.image.BufferedImage import javax.swing.JPanel @@ -257,6 +259,12 @@ class ToolViewTest : BasePlatformTestCase() { assertSmallEditorFont(view.stateFont(), style) } + fun `test tool header title subtitle gap uses standard medium gap`() { + val view = track(ToolView(tool("p1", "bash", ToolExecState.COMPLETED).also { it.output = "done" })) + + assertEquals(UiStyle.Gap.md(), centerGap(view)) + } + fun `test applyStyle updates tool fonts in place`() { val view = ToolView(tool("p1", "bash", ToolExecState.COMPLETED)) val style = SessionEditorStyle.create(family = "Courier New", size = 25) @@ -381,6 +389,13 @@ class ToolViewTest : BasePlatformTestCase() { assertTrue(font.size < style.editorSize) } + private fun centerGap(view: ToolView): Int { + val row = view.components.filterIsInstance().single() + val header = (row.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER) as JPanel + val center = (header.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER) as JPanel + return (center.layout as BorderLayout).hgap + } + private fun paint(border: Border): Color { val image = BufferedImage(3, 3, BufferedImage.TYPE_INT_ARGB) val item = JPanel() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt index 444eda3bfec..578988381d2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt @@ -5,10 +5,13 @@ import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.views.base.PrimarySessionPartView +import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.TodoDto import ai.kilocode.rpc.dto.TodoViewDto import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.BorderLayout import java.awt.Color +import javax.swing.JPanel @Suppress("UnstableApiUsage") class TodoWriteViewTest : BasePlatformTestCase() { @@ -55,6 +58,14 @@ class TodoWriteViewTest : BasePlatformTestCase() { assertEquals(style.editorForeground, view.rowForeground(1)) } + fun `test todo header title subtitle gap uses standard medium gap`() { + val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also { + it.todos = listOf(TodoDto("Next", "pending", "medium")) + }) + + assertEquals(UiStyle.Gap.md(), centerGap(view)) + } + fun `test compact view renders hidden labels and visible rows`() { val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also { it.todos = listOf( @@ -94,5 +105,12 @@ class TodoWriteViewTest : BasePlatformTestCase() { assertTrue(view.rowText(0).contains("New")) } + private fun centerGap(view: TodoWriteView): Int { + val row = view.components.filterIsInstance().first() + val header = (row.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER) as JPanel + val center = (header.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER) as JPanel + return (center.layout as BorderLayout).hgap + } + private fun tool(name: String, state: ToolExecState) = Tool("p1", name, toolKind(name)).also { it.state = state } } From bde3d6c14998c1d23cb49b5f7a2c99d8d27b507c Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 12 Jun 2026 12:58:55 -0400 Subject: [PATCH 09/16] fix(jetbrains): restore markdown code block defaults --- .../ai/kilocode/client/ui/md/MdViewFactory.kt | 2 +- .../kilocode/client/ui/md/MdViewHtmlPane.kt | 344 ------------------ .../ai/kilocode/client/ui/md/MdViewHybrid.kt | 14 +- .../client/session/views/ShellToolViewTest.kt | 3 + .../client/ui/md/MdViewFactoryTest.kt | 4 +- .../kilocode/client/ui/md/MdViewHybridTest.kt | 25 ++ .../client/ui/md/MdViewLoggingTest.kt | 14 +- .../ai/kilocode/client/ui/md/MdViewTest.kt | 9 + 8 files changed, 61 insertions(+), 354 deletions(-) delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt 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 index 623794a85c8..bcb444d5602 100644 --- 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 @@ -23,7 +23,7 @@ object MdViewFactory { fun hybrid(code: MdCodeBlockFactory): MdView = hybrid(SessionEditorStyle.current(), null, code) fun html(style: SessionEditorStyle = SessionEditorStyle.current(), selection: SessionSelection? = null): MdView = - MdViewHtmlPane(style, selection) + hybrid(style, selection) } data class MdCodeBlockOptions( 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 deleted file mode 100644 index 47756753f7d..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt +++ /dev/null @@ -1,344 +0,0 @@ -package ai.kilocode.client.ui.md - -import ai.kilocode.client.session.ui.style.SessionEditorStyle -import ai.kilocode.client.session.ui.selection.SessionSelection -import ai.kilocode.log.KiloLog -import com.intellij.openapi.Disposable -import com.intellij.openapi.util.Disposer -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: SessionEditorStyle = SessionEditorStyle.current(), - private var selection: SessionSelection? = null, -) : 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 var reg: Disposable? = null - private var disposed = false - - 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)) - } - } - - init { - syncSelection() - } - - override val component: JComponent get() = pane - - override var font: Font - get() = fontOverride ?: opts().font - set(value) { - if (disposed) return - if (fontOverride == value) return - fontOverride = value - markDirty() - } - - override var foreground: Color - get() = foregroundOverride ?: opts().foreground - set(value) { - if (disposed) return - if (foregroundOverride == value) return - foregroundOverride = value - markDirty() - } - - override var background: Color - get() = backgroundOverride ?: opts().background - set(value) { - if (disposed) return - if (backgroundOverride == value) return - backgroundOverride = value - if (opaqueState) pane.background = value - markDirty() - } - - override var linkColor: Color - get() = linkColorOverride ?: opts().linkColor - set(value) { - if (disposed) return - if (linkColorOverride == value) return - linkColorOverride = value - markDirty() - } - - override var codeBg: Color - get() = codeBgOverride ?: opts().codeBg - set(value) { - if (disposed) return - if (codeBgOverride == value) return - codeBgOverride = value - markDirty() - } - - override var preBg: Color - get() = preBgOverride ?: opts().preBg - set(value) { - if (disposed) return - if (preBgOverride == value) return - preBgOverride = value - markDirty() - } - - override var preFg: Color - get() = preFgOverride ?: opts().preFg - set(value) { - if (disposed) return - if (preFgOverride == value) return - preFgOverride = value - markDirty() - } - - override var codeFont: String - get() = codeFontOverride ?: opts().codeFont - set(value) { - if (disposed) return - if (codeFontOverride == value) return - codeFontOverride = value - markDirty() - } - - override var quoteBorder: Color - get() = quoteBorderOverride ?: opts().quoteBorder - set(value) { - if (disposed) return - if (quoteBorderOverride == value) return - quoteBorderOverride = value - markDirty() - } - - override var quoteFg: Color - get() = quoteFgOverride ?: opts().quoteFg - set(value) { - if (disposed) return - if (quoteFgOverride == value) return - quoteFgOverride = value - markDirty() - } - - override var tableBorder: Color - get() = tableBorderOverride ?: opts().tableBorder - set(value) { - if (disposed) return - if (tableBorderOverride == value) return - tableBorderOverride = value - markDirty() - } - - override var opaque: Boolean - get() = opaqueState - set(value) { - if (disposed) return - if (opaqueState == value) return - opaqueState = value - pane.isOpaque = value - if (value) pane.background = background - markDirty() - } - - override fun applyStyle(style: SessionEditorStyle) { - if (disposed) return - this.style = style - selection?.applyStyle(style) - if (opaqueState) pane.background = background - markDirty() - } - - override fun setSelection(selection: SessionSelection?) { - if (disposed) return - if (this.selection === selection) return - reg?.let(Disposer::dispose) - reg = null - this.selection = selection - syncSelection() - } - - override fun resetStyles() { - if (disposed) return - 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 (disposed) return - if (source.toString() == text) return - source.clear() - source.append(text) - syncHtml() - } - - override fun append(delta: String) { - if (disposed) return - if (delta.isEmpty()) return - source.append(delta) - syncHtml() - } - - override fun clear() { - if (disposed) return - if (source.isEmpty() && rendered.isEmpty() && pane.text.isEmpty()) return - source.clear() - rendered = "" - pane.text = "" - } - - override fun addLinkListener(listener: MdView.LinkListener) { - if (disposed) return - 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) { - if (disposed) return - dispatch(MdView.LinkEvent(href)) - } - - override fun dispose() { - disposed = true - listeners.clear() - source.clear() - rendered = "" - reg?.let(Disposer::dispose) - reg = null - pane.text = "" - } - - private fun syncSelection() { - reg = selection?.register(pane) - } - - private fun dispatch(event: MdView.LinkEvent) { - for (l in listeners) l.onLink(event) - } - - private fun markDirty() { - if (disposed) return - pane.reloadCssStylesheets() - if (source.isNotEmpty()) syncHtml() - } - - private fun syncHtml() { - if (disposed) return - 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 index b1213472011..93b6edf5042 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 @@ -549,8 +549,9 @@ internal class MdViewHybrid( SessionUiStyle.View.Code.VIEWPORT_BOTTOM_PADDING, SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING, ) - isOpaque = opts.opaque + isOpaque = true background = opts.preBg + viewport.isOpaque = true viewport.background = opts.preBg horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED verticalScrollBarPolicy = code.opts.verticalPolicy @@ -634,7 +635,7 @@ internal class MdViewHybrid( } private fun styleTextArea(area: JBTextArea, opts: MdStyle) { - area.isOpaque = opts.opaque + area.isOpaque = true area.background = opts.preBg area.foreground = opts.preFg area.font = style.editorFont @@ -658,6 +659,8 @@ internal class MdViewHybrid( ed.scrollPane.viewportBorder = JBUI.Borders.empty() ed.backgroundColor = opts.preBg ed.scrollPane.background = opts.preBg + ed.scrollPane.isOpaque = true + ed.scrollPane.viewport.isOpaque = true ed.scrollPane.viewport.background = opts.preBg ed.settings.isUseSoftWraps = false ed.settings.isAdditionalPageAtBottom = false @@ -937,9 +940,16 @@ internal class MdViewHybrid( view.background = opts.preBg view.getEditor(false)?.let { 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.isOpaque = true + ed.scrollPane.viewport.isOpaque = true ed.scrollPane.viewport.background = opts.preBg + ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER } } is JBTextArea -> styleTextArea(view, opts) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt index c9b4424a155..60fd4c95220 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt @@ -258,6 +258,7 @@ class ShellToolViewTest : BasePlatformTestCase() { val root = view.mdComponent()!! val pane = root.components.filterIsInstance().single() val editor = view.codeEditors().single() + val nested = editor.getEditor(true)!!.scrollPane val chrome = pane.insets.top + pane.insets.bottom + pane.viewportBorder.getBorderInsets(pane).top + pane.viewportBorder.getBorderInsets(pane).bottom + pane.horizontalScrollBar.preferredSize.height @@ -265,6 +266,8 @@ class ShellToolViewTest : BasePlatformTestCase() { assertEquals(output, editor.text) assertEquals(1, view.codeEditors().size) assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, pane.verticalScrollBarPolicy) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, nested.verticalScrollBarPolicy) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, nested.horizontalScrollBarPolicy) assertTrue(editor.preferredSize.height > pane.preferredSize.height - chrome) assertTrue(pane.preferredSize.height < editor.preferredSize.height + chrome) } 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 index 59d82645613..2ecfdd04f1d 100644 --- 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 @@ -12,8 +12,8 @@ class MdViewFactoryTest : BasePlatformTestCase() { assertInstanceOf(MdViewFactory.hybrid(), MdViewHybrid::class.java) } - fun `test html returns html pane renderer`() { - assertInstanceOf(MdViewFactory.html(), MdViewHtmlPane::class.java) + fun `test html returns hybrid renderer`() { + assertInstanceOf(MdViewFactory.html(), MdViewHybrid::class.java) } fun `test create applies supplied session style`() { 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 ba5e2dd651b..de5d84facd3 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 @@ -68,9 +68,12 @@ class MdViewHybridTest : BasePlatformTestCase() { fun `test fenced code block shows horizontal scrollbar as needed`() { view.set("```kotlin\nval value = 1\n```") val pane = scrolls().single() + val editor = editors().single().getEditor(true)!! assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, pane.horizontalScrollBarPolicy) assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, pane.verticalScrollBarPolicy) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, editor.scrollPane.horizontalScrollBarPolicy) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, editor.scrollPane.verticalScrollBarPolicy) assertTrue(pane.isWheelScrollingEnabled) assertTrue(pane.horizontalScrollBar.preferredSize.height > 0) assertTrue(pane.horizontalScrollBar.isOpaque) @@ -78,6 +81,26 @@ class MdViewHybridTest : BasePlatformTestCase() { assertEquals(0, pane.verticalScrollBar.preferredSize.width) } + fun `test transparent markdown keeps code block background opaque`() { + view.opaque = false + + view.set("```kotlin\nval value = 1\n```") + val pane = scrolls().single() + val editor = editors().single().getEditor(true)!! + val bg = view.preBg + + assertFalse(view.component.isOpaque) + assertTrue(pane.isOpaque) + assertTrue(pane.viewport.isOpaque) + assertTrue(editor.scrollPane.isOpaque) + assertTrue(editor.scrollPane.viewport.isOpaque) + assertEquals(bg.rgb, pane.background.rgb) + assertEquals(bg.rgb, pane.viewport.background.rgb) + assertEquals(bg.rgb, editor.backgroundColor.rgb) + assertEquals(bg.rgb, editor.scrollPane.background.rgb) + assertEquals(bg.rgb, editor.scrollPane.viewport.background.rgb) + } + 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() @@ -599,6 +622,8 @@ class MdViewHybridTest : BasePlatformTestCase() { assertEquals(Color(0x44, 0x55, 0x66).rgb, pane.viewport.background.rgb) assertEquals(Color(0x44, 0x55, 0x66).rgb, editor.scrollPane.background.rgb) assertEquals(Color(0x44, 0x55, 0x66).rgb, editor.scrollPane.viewport.background.rgb) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, editor.scrollPane.horizontalScrollBarPolicy) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, editor.scrollPane.verticalScrollBarPolicy) } fun `test resetStyles keeps content rendered`() { 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 f5d698cf10e..3b8c743fdd3 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 @@ -1,5 +1,6 @@ package ai.kilocode.client.ui.md +import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase @Suppress("UnstableApiUsage") @@ -7,11 +8,14 @@ class MdViewLoggingTest : BasePlatformTestCase() { fun `test invalid font family does not throw while building override sheet`() { val view = MdViewFactory.html() + try { + view.codeFont = "broken'font" + view.set("`x`") - view.codeFont = "broken'font" - view.set("`x`") - - assertTrue(view.overrideSheet().contains("broken\\'font")) - assertTrue(view.html().contains("")) + assertTrue(view.overrideSheet().contains("broken\\'font")) + assertTrue(view.html().contains("")) + } finally { + Disposer.dispose(view) + } } } 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 8a0168b9125..39109bc8bbb 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 @@ -8,6 +8,7 @@ import com.intellij.openapi.editor.colors.EditorColors import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.editor.colors.EditorColorsScheme import com.intellij.openapi.editor.markup.TextAttributes +import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import java.awt.Color import java.awt.Font @@ -28,6 +29,14 @@ class MdViewTest : BasePlatformTestCase() { view = MdViewFactory.html() } + override fun tearDown() { + try { + if (this::view.isInitialized) Disposer.dispose(view) + } finally { + super.tearDown() + } + } + // ---- set ---- fun `test set stores source`() { From c4aac528ef9578d73a785872a6322b26eeb0ab43 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 12 Jun 2026 13:16:44 -0400 Subject: [PATCH 10/16] fix(jetbrains): balance markdown code padding --- .../kilocode/client/session/ui/style/SessionUiStyle.kt | 4 ++-- .../kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) 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 0defd9a2adc..1e36d1fd637 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 @@ -102,11 +102,11 @@ object SessionUiStyle { 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 VIEWPORT_BOTTOM_PADDING = 6 const val SCROLLBAR_HEIGHT = 12 const val WIDTH_PADDING = 16 - fun topPadding(): Int = VIEWPORT_TOP_PADDING + SCROLLBAR_HEIGHT + fun topPadding(): Int = VIEWPORT_TOP_PADDING } /** Permission session-view command preview limits. */ 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 de5d84facd3..ea569feef95 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 @@ -115,22 +115,25 @@ class MdViewHybridTest : BasePlatformTestCase() { assertTrue(pane.preferredSize.height >= line * 3) } - fun `test fenced code block horizontal scrollbar has no bottom padding`() { + fun `test fenced code block has symmetric content padding with horizontal scrollbar`() { view.set("```kotlin\n${"x".repeat(500)}\n```") val pane = scrolls().single() val pad = pane.viewportBorder.getBorderInsets(pane) + assertEquals(SessionUiStyle.View.Code.VIEWPORT_TOP_PADDING, pad.top) assertEquals(SessionUiStyle.View.Code.VIEWPORT_BOTTOM_PADDING, pad.bottom) + assertEquals(pad.top, pad.bottom) assertTrue(pane.horizontalScrollBar.preferredSize.height > 0) } - fun `test short code block top padding balances hidden scrollbar space`() { + fun `test short code block keeps symmetric content padding`() { 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_TOP_PADDING, pad.top) assertEquals(SessionUiStyle.View.Code.VIEWPORT_BOTTOM_PADDING, pad.bottom) + assertEquals(pad.top, pad.bottom) } fun `test fenced code block height is not capped`() { From f651e0819d53e294e06b039fac1faa407d480136 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 12 Jun 2026 13:24:04 -0400 Subject: [PATCH 11/16] fix(jetbrains): align code block gap with session views --- .../ai/kilocode/client/session/ui/style/SessionUiStyle.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1e36d1fd637..cd3e99b55f6 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 @@ -97,7 +97,7 @@ object SessionUiStyle { /** Markdown code block geometry inside assistant messages. */ object Code { - const val BLOCK_GAP = 6 + const val BLOCK_GAP = SessionLayout.GAP const val MIN_ROWS = 1 const val BORDER_WIDTH = 1 const val VIEWPORT_TOP_PADDING = 6 From 4aa2d429f959522fffad2bf4ef176394ac06587d Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 12 Jun 2026 15:48:44 -0400 Subject: [PATCH 12/16] fix(jetbrains): polish shell output layout --- .../session/views/tool/ShellToolView.kt | 52 +- .../ai/kilocode/client/ui/md/MdViewHybrid.kt | 1006 +--------------- .../client/ui/md/hybrid/MdLanguage.kt | 119 ++ .../client/ui/md/hybrid/MdShellHighlight.kt | 56 + .../client/ui/md/hybrid/MdTerminal.kt | 69 ++ .../client/ui/md/hybrid/MdViewHybrid.kt | 1066 +++++++++++++++++ .../client/session/views/ShellToolViewTest.kt | 68 +- .../kilocode/client/ui/md/MdViewHybridTest.kt | 103 ++ 8 files changed, 1515 insertions(+), 1024 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdLanguage.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdShellHighlight.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdTerminal.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt index 9e82636d8d7..80373d20e74 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt @@ -239,7 +239,7 @@ class ShellBody( private fun styleShell() { val root = md.component as? JPanel ?: return root.components.filterIsInstance().forEach { - it.border = JBUI.Borders.emptyLeft(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING)) + it.border = JBUI.Borders.emptyLeft(JBUI.scale(SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING)) } } @@ -258,24 +258,38 @@ class ShellBody( override fun dispose() = Unit } -private data class ShellContent(val command: String, val output: String, val error: String) { - constructor(tool: Tool) : this(command(tool), clean(output(tool)), clean(tool.error.orEmpty())) +private data class ShellContent( + val command: String, + val output: String, + val error: String, + val rawOutput: String = output, + val rawError: String = error, +) { + constructor(tool: Tool) : this( + command(tool), + clean(output(tool)), + clean(tool.error.orEmpty()), + output(tool), + tool.error.orEmpty(), + ) val body: String = listOf(command, output, error).filter { it.isNotBlank() }.joinToString("\n\n") val markdown: String = buildString { - section(KiloBundle.message("session.part.tool.shell.command"), command) - section(KiloBundle.message("session.part.tool.shell.output"), output) - section(KiloBundle.message("session.part.tool.shell.error"), error) + section(KiloBundle.message("session.part.tool.shell.command"), command, "shell") + section(KiloBundle.message("session.part.tool.shell.output"), rawOutput, outputLang(rawOutput)) + section(KiloBundle.message("session.part.tool.shell.error"), rawError, "ansi-stderr") } } -private fun StringBuilder.section(title: String, text: String) { +private fun outputLang(text: String): String = if (ANSI.containsMatchIn(text)) "ansi-stdout" else "shell-output" + +private fun StringBuilder.section(title: String, text: String, lang: String) { if (text.isBlank()) return if (isNotEmpty()) append("\n\n") val fence = fence(text) - append("### ").append(title).append("\n\n") - append(fence).append("shell\n") + append("**").append(title).append("**\n\n") + append(fence).append(lang).append("\n") append(text) if (!text.endsWith('\n')) append('\n') append(fence) @@ -286,13 +300,27 @@ private fun fence(text: String): String { return "`".repeat(maxOf(3, size + 1)) } -private fun clean(text: String): String = normalize(strip(text)) +private fun clean(text: String): String = strip(normalize(text)) private fun strip(text: String): String = ANSI.replace(text, "") private fun normalize(text: String): String = lines(text.replace("\r\n", "\n")).joinToString("\n") { line -> - if ('\r' !in line) return@joinToString line - frames(line).lastOrNull { it.isNotEmpty() } ?: "" + val frame = if ('\r' !in line) line else frames(line).lastOrNull { it.isNotEmpty() } ?: "" + backspace(frame) +} + +private fun backspace(text: String): String { + val out = StringBuilder() + var idx = 0 + while (idx < text.length) { + val ch = text[idx++] + if (ch == '\b') { + if (out.isNotEmpty()) out.deleteCharAt(out.length - 1) + continue + } + out.append(ch) + } + return out.toString() } private fun lines(text: String): List { 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 93b6edf5042..18f20bd6e9c 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,1008 +1,10 @@ package ai.kilocode.client.ui.md -import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.selection.SessionSelection -import ai.kilocode.client.session.ui.style.SessionUiStyle -import ai.kilocode.log.KiloLog -import com.intellij.openapi.Disposable -import com.intellij.openapi.editor.EditorFactory -import com.intellij.openapi.fileTypes.FileType -import com.intellij.openapi.fileTypes.FileTypeRegistry -import com.intellij.openapi.fileTypes.PlainTextFileType -import com.intellij.openapi.fileTypes.UnknownFileType -import com.intellij.openapi.project.ProjectManager -import com.intellij.openapi.util.Disposer -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.node.ThematicBreak -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 -import javax.swing.JComponent -import javax.swing.JPanel -import javax.swing.ScrollPaneConstants -import javax.swing.event.HyperlinkEvent -import javax.swing.text.html.StyleSheet +import ai.kilocode.client.session.ui.style.SessionEditorStyle -@Suppress("UnstableApiUsage") internal class MdViewHybrid( style: SessionEditorStyle = SessionEditorStyle.current(), - private var selection: SessionSelection? = null, - private val code: MdCodeBlockFactory = MdCodeBlockFactory.default(), -) : MdView { - companion object { - private val LOG = KiloLog.create(MdViewHybrid::class.java) - - private val FILES = mapOf( - "kt" to "kt", - "kotlin" to "kt", - "js" to "js", - "javascript" to "js", - "jsx" to "jsx", - "ts" to "ts", - "typescript" to "ts", - "tsx" to "tsx", - "java" to "java", - "py" to "py", - "python" to "py", - "sh" to "sh", - "bash" to "sh", - "shell" to "sh", - "zsh" to "sh", - "shellscript" to "sh", - "json" to "json", - "xml" to "xml", - "html" to "html", - "css" to "css", - "md" to "md", - "markdown" to "md", - "yaml" to "yaml", - "yml" to "yaml", - "toml" to "toml", - "go" to "go", - "golang" to "go", - "rs" to "rs", - "rust" to "rs", - "rb" to "rb", - "ruby" to "rb", - "php" to "php", - "swift" to "swift", - "scala" to "scala", - "sql" to "sql", - "dockerfile" to "dockerfile", - "docker" to "dockerfile", - "gradle" to "gradle", - "kts" to "kts", - "c" to "c", - "h" to "h", - "cpp" to "cpp", - "c++" to "cpp", - "cc" to "cc", - "cxx" to "cxx", - "hpp" to "hpp", - "h++" to "hpp", - "cs" to "cs", - "csharp" to "cs", - "c#" to "cs", - "fs" to "fs", - "fsharp" to "fs", - "f#" to "fs", - "ps1" to "ps1", - "powershell" to "ps1", - "pwsh" to "ps1", - "bat" to "bat", - "batch" to "bat", - "cmd" to "bat", - "makefile" to "makefile", - "make" to "makefile", - "terraform" to "tf", - "tf" to "tf", - "hcl" to "hcl", - "vue" to "vue", - "svelte" to "svelte", - "graphql" to "graphql", - "proto" to "proto", - "ini" to "ini", - "properties" to "properties", - "diff" to "diff", - "patch" to "patch", - ) - } - - private val listeners = mutableListOf() - private val source = StringBuilder() - private var style = style - private var rendered = "" - private var disposed = false - private val blocks = mutableListOf() - private var openFence: Fence? = null - private var stale = false - - 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 (disposed) return - if (fontOverride == value) return - fontOverride = value - syncStyle() - } - - override var foreground: Color - get() = foregroundOverride ?: opts().foreground - set(value) { - if (disposed) return - if (foregroundOverride == value) return - foregroundOverride = value - syncStyle() - } - - override var background: Color - get() = backgroundOverride ?: opts().background - set(value) { - if (disposed) return - if (backgroundOverride == value) return - backgroundOverride = value - syncStyle() - } - - override var linkColor: Color - get() = linkColorOverride ?: opts().linkColor - set(value) { - if (disposed) return - if (linkColorOverride == value) return - linkColorOverride = value - syncStyle() - } - - override var codeBg: Color - get() = codeBgOverride ?: opts().codeBg - set(value) { - if (disposed) return - if (codeBgOverride == value) return - codeBgOverride = value - syncStyle() - } - - override var preBg: Color - get() = preBgOverride ?: opts().preBg - set(value) { - if (disposed) return - if (preBgOverride == value) return - preBgOverride = value - syncStyle() - } - - override var preFg: Color - get() = preFgOverride ?: opts().preFg - set(value) { - if (disposed) return - if (preFgOverride == value) return - preFgOverride = value - syncStyle() - } - - override var codeFont: String - get() = codeFontOverride ?: opts().codeFont - set(value) { - if (disposed) return - if (codeFontOverride == value) return - codeFontOverride = value - syncStyle() - } - - override var quoteBorder: Color - get() = quoteBorderOverride ?: opts().quoteBorder - set(value) { - if (disposed) return - if (quoteBorderOverride == value) return - quoteBorderOverride = value - syncStyle() - } - - override var quoteFg: Color - get() = quoteFgOverride ?: opts().quoteFg - set(value) { - if (disposed) return - if (quoteFgOverride == value) return - quoteFgOverride = value - syncStyle() - } - - override var tableBorder: Color - get() = tableBorderOverride ?: opts().tableBorder - set(value) { - if (disposed) return - if (tableBorderOverride == value) return - tableBorderOverride = value - syncStyle() - } - - override var opaque: Boolean - get() = opaqueState - set(value) { - if (disposed) return - if (opaqueState == value) return - opaqueState = value - syncStyle() - } - - override fun applyStyle(style: SessionEditorStyle) { - if (disposed) return - this.style = style - selection?.applyStyle(style) - syncStyle() - } - - override fun setSelection(selection: SessionSelection?) { - if (disposed) return - if (this.selection === selection) return - this.selection = selection - clearBlocks() - syncBlocks() - } - - override fun resetStyles() { - if (disposed) return - 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 (disposed) return - if (source.toString() == text) return - source.clear() - source.append(text) - syncBlocks() - } - - override fun append(delta: String) { - if (disposed) return - if (delta.isEmpty()) return - val fence = openFence - val view = blocks.lastOrNull() as? CodeView - if (fence != null && view != null && clean(fence.char, delta)) { - source.append(delta) - view.grow(delta) - stale = true - root.revalidate() - root.repaint() - return - } - source.append(delta) - syncBlocks() - } - - override fun clear() { - if (disposed) return - if (source.isEmpty() && rendered.isEmpty() && root.componentCount == 0) return - source.clear() - rendered = "" - openFence = null - stale = false - clearBlocks() - root.revalidate() - root.repaint() - } - - override fun addLinkListener(listener: MdView.LinkListener) { - if (disposed) return - listeners.add(listener) - } - - override fun removeLinkListener(listener: MdView.LinkListener) { - listeners.remove(listener) - } - - override fun markdown(): String = source.toString() - - override fun html(): String { - if (!stale) return rendered - val out = project(source.toString()) - rendered = out.html - openFence = out.open - stale = false - return rendered - } - - override fun overrideSheet(): String = MdCommon.rules(opts()) - - override fun simulateLink(href: String) { - if (disposed) return - dispatch(MdView.LinkEvent(href)) - } - - override fun dispose() { - disposed = true - listeners.clear() - source.clear() - rendered = "" - openFence = null - stale = false - clearBlocks() - } - - private fun syncStyle() { - if (disposed) return - val opts = opts() - root.isOpaque = opts.opaque - if (opts.opaque) root.background = opts.background - for (view in blocks) view.style(opts) - root.revalidate() - root.repaint() - } - - private fun syncBlocks() { - if (disposed) return - val text = source.toString() - val out = project(text) - rendered = out.html - openFence = out.open - stale = false - val next = out.blocks - if (text.isEmpty()) { - openFence = null - clearBlocks() - root.revalidate() - root.repaint() - return - } - sync(next) - root.revalidate() - root.repaint() - } - - private fun clearBlocks() { - blocks.forEach { Disposer.dispose(it.disposable) } - blocks.clear() - root.removeAll() - } - - private fun sync(next: List) { - var at = 0 - while (at < blocks.size && at < next.size) { - val view = blocks[at] - val desc = next[at] - if (!view.compatible(desc)) break - view.update(desc) - at++ - } - removeBlocks(at) - for (desc in next.drop(at)) addBlock(view(desc)) - } - - private fun clean(char: Char, delta: String): Boolean { - if (delta.contains(char)) return false - val start = source.lastIndexOf("\n") + 1 - for (idx in start until source.length) { - if (source[idx] == char) return false - } - return true - } - - private fun removeBlocks(start: Int) { - if (start >= blocks.size) return - val idx = if (start == 0) 0 else start * 2 - 1 - while (root.componentCount > idx) root.remove(root.componentCount - 1) - val stale = blocks.drop(start) - repeat(blocks.size - start) { blocks.removeAt(blocks.lastIndex) } - stale.forEach { Disposer.dispose(it.disposable) } - } - - private fun addGap() { - if (root.componentCount == 0) return - root.add(Box.createVerticalStrut(JBUI.scale(SessionUiStyle.View.Code.BLOCK_GAP))) - } - - private fun addBlock(view: View) { - addGap() - view.component.alignmentX = JComponent.LEFT_ALIGNMENT - blocks.add(view) - root.add(view.component) - } - - private fun view(desc: Desc): View { - val disposable = Disposer.newDisposable("Markdown block") - return when (desc) { - is Desc.Html -> HtmlView(desc, htmlBlock(desc.body, disposable), disposable) - is Desc.Code -> CodeView(desc, codeBlock(desc.text, desc.file, disposable), disposable) - } - } - - private fun htmlBlock(body: String, disposable: Disposable): JBHtmlPane { - val opts = opts() - return JBHtmlPane( - JBHtmlPaneStyleConfiguration { - enableInlineCodeBackground = true - enableCodeBlocksBackground = true - }, - JBHtmlPaneConfiguration { - customStyleSheetProvider { sheet() } - }, - ).apply { - isEditable = false - isOpaque = opts.opaque - background = opts.background - text = "$body" - selection?.register(this, disposable) - 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, file: FileType, disposable: Disposable): JBScrollPane { - val opts = opts() - val value = text.trimEnd('\n') - fun editor(type: FileType) = CodeField(type, opts, text).also { ed -> - Disposer.register(disposable) { - ed.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) - } - ed.setDisposedWith(disposable) - selection?.register(ed, disposable) - } - val field = runCatching { - editor(file) - }.getOrElse { err -> - LOG.warn("kind=markdown codeEditor=true failed message=${err.message}", err) - if (code.opts.editorOnly) runCatching { - editor(PlainTextFileType.INSTANCE) - }.getOrElse { fallback -> - LOG.warn("kind=markdown codeEditor=true fallback=plain failed message=${fallback.message}", fallback) - throw fallback - } else { - textArea(text, opts, disposable) - } - } - sizeCodeField(field, value) - val pane = object : JBScrollPane(field) { - override fun doLayout() { - super.doLayout() - if (code.opts.verticalPolicy != ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER) return - 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) - } - } - styleCodePane(pane, opts) - sizeCodePane(pane, field) - return pane - } - - private fun styleCodePane(pane: JBScrollPane, opts: MdStyle) { - pane.apply { - val width = SessionUiStyle.View.Code.BORDER_WIDTH - border = when (code.opts.border) { - MdCodeBlockBorder.All -> JBUI.Borders.customLine(opts.codeBorder, width) - MdCodeBlockBorder.Horizontal -> JBUI.Borders.customLine(opts.codeBorder, width, 0, width, 0) - MdCodeBlockBorder.Bottom -> JBUI.Borders.customLine(opts.codeBorder, 0, 0, width, 0) - } - 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 = true - background = opts.preBg - viewport.isOpaque = true - viewport.background = opts.preBg - horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED - verticalScrollBarPolicy = code.opts.verticalPolicy - isWheelScrollingEnabled = true - setOverlappingScrollBar(false) - horizontalScrollBar.preferredSize = Dimension(0, JBUI.scale(SessionUiStyle.View.Code.SCROLLBAR_HEIGHT)) - horizontalScrollBar.isOpaque = true - if (code.opts.verticalPolicy == ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER) { - verticalScrollBar.preferredSize = JBUI.emptySize() - } - } - } - - private fun sizeCodeField(component: JComponent, text: String) { - val height = codeHeight(component, text) - val width = codeWidth(component, text) - component.preferredSize = Dimension(width, height) - component.minimumSize = Dimension(0, height) - component.maximumSize = Dimension(Int.MAX_VALUE, height) - } - - private fun sizeCodePane(pane: JBScrollPane, component: JComponent) { - val pad = pane.viewportBorder.getBorderInsets(pane) - val text = when (component) { - is CodeField -> component.text - is JBTextArea -> component.text - else -> "" - } - val content = visibleCodeHeight(component, text) - val height = content + pane.insets.top + pane.insets.bottom + - pad.top + pad.bottom + pane.horizontalScrollBar.preferredSize.height - pane.preferredSize = Dimension(0, height) - pane.minimumSize = Dimension(0, height) - pane.maximumSize = Dimension(Int.MAX_VALUE, height) - } - - 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 visibleCodeHeight(component: JComponent, text: String): Int { - val max = code.opts.maxLines ?: return component.preferredSize.height - val count = text.lineSequence().count() - val rows = count.coerceAtLeast(SessionUiStyle.View.Code.MIN_ROWS).coerceAtMost(max) - 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 line * rows - } - val line = component.getFontMetrics(component.font).height - return line * rows - } - - private fun textArea(text: String, opts: MdStyle, disposable: Disposable) = JBTextArea(text.trimEnd('\n')).apply { - isEditable = false - lineWrap = false - styleTextArea(this, opts) - border = JBUI.Borders.empty( - SessionUiStyle.View.Code.VIEWPORT_TOP_PADDING, - SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING, - ) - selection?.register(this, disposable) - } - - private fun styleTextArea(area: JBTextArea, opts: MdStyle) { - area.isOpaque = true - area.background = opts.preBg - area.foreground = opts.preFg - area.font = style.editorFont - } - - private inner class CodeField(file: FileType, opts: MdStyle, value: String) : - com.intellij.ui.EditorTextField( - EditorFactory.getInstance().createDocument(value.trimEnd('\n')), - ProjectManager.getInstance().defaultProject, - file, - true, - false, - ) { - init { - setFontInheritedFromLAF(false) - font = style.editorFont - 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.isOpaque = true - ed.scrollPane.viewport.isOpaque = true - ed.scrollPane.viewport.background = opts.preBg - ed.settings.isUseSoftWraps = false - ed.settings.isAdditionalPageAtBottom = false - ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER - } - } - } - - private fun file(lang: String?): FileType { - val key = lang?.trim()?.split(Regex("\\s+"))?.take(2)?.joinToString(" ")?.lowercase().orEmpty() - if (key == "shell script") return type("sh") - val single = key.substringBefore(' ') - val ext = FILES[key] ?: FILES[single] ?: return PlainTextFileType.INSTANCE - return type(ext) - } - - private fun type(ext: String): FileType { - val type = FileTypeRegistry.getInstance().getFileTypeByExtension(ext) - if (type == UnknownFileType.INSTANCE) return PlainTextFileType.INSTANCE - return type - } - - 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 fun collect(doc: Node): List { - val visitor = Visitor() - doc.accept(visitor) - return visitor.blocks - } - - private fun project(text: String): Projection { - val blocks = mutableListOf() - val html = StringBuilder() - val md = StringBuilder() - val lines = lines(text) - var trailing: Fence? = null - var idx = 0 - - fun flush() { - if (md.isEmpty()) return - val doc = parser.parse(md.toString()) - val descs = collect(doc) - blocks.addAll(descs) - for (desc in descs) { - when (desc) { - is Desc.Html -> html.append(desc.body) - is Desc.Code -> html.append(codeHtml(desc.text)) - } - } - md.clear() - } - - while (idx < lines.size) { - val line = lines[idx] - val open = opener(line.text) - if (open == null) { - val pending = idx == lines.lastIndex && pendingOpener(line.text) - if (pending) { - flush() - blocks.add(Desc.Code("", PlainTextFileType.INSTANCE)) - html.append(codeHtml("")) - } else { - md.append(line.text).append(line.end) - } - idx++ - continue - } - - flush() - idx++ - val code = StringBuilder() - var closed = false - var trimmed = false - while (idx < lines.size) { - val item = lines[idx] - val close = closer(item.text, open) - if (close) { - closed = true - idx++ - break - } - val partial = idx == lines.lastIndex && partialCloser(item.text, open) - if (partial) trimmed = true - if (!partial) code.append(item.text).append(item.end) - idx++ - } - val desc = Desc.Code(code.toString(), file(open.info)) - blocks.add(desc) - html.append(codeHtml(desc.text)) - trailing = if (!closed && !trimmed) open else null - } - - flush() - return Projection(html.toString(), blocks, trailing) - } - - private fun lines(text: String): List { - if (text.isEmpty()) return emptyList() - val lines = mutableListOf() - var start = 0 - while (start < text.length) { - val end = text.indexOf('\n', start) - if (end == -1) { - lines.add(Line(text.substring(start), "")) - break - } - lines.add(Line(text.substring(start, end), "\n")) - start = end + 1 - } - return lines - } - - private fun opener(text: String): Fence? { - val trimmed = text.dropWhile { it == ' ' } - val indent = text.length - trimmed.length - if (indent > 3) return null - val char = trimmed.firstOrNull() ?: return null - if (char != '`' && char != '~') return null - val size = trimmed.takeWhile { it == char }.length - if (size < 3) return null - val info = trimmed.drop(size).trim() - if (char == '`' && info.contains('`')) return null - return Fence(char, size, info) - } - - private fun closer(text: String, fence: Fence): Boolean { - val trimmed = text.dropWhile { it == ' ' } - val indent = text.length - trimmed.length - if (indent > 3) return false - val size = trimmed.takeWhile { it == fence.char }.length - if (size < fence.size) return false - return trimmed.drop(size).isBlank() - } - - private fun pendingOpener(text: String): Boolean { - val trimmed = text.dropWhile { it == ' ' } - val indent = text.length - trimmed.length - if (indent > 3) return false - val char = trimmed.firstOrNull() ?: return false - if (char != '`' && char != '~') return false - val size = trimmed.takeWhile { it == char }.length - if (size !in 1..2) return false - return trimmed.drop(size).isBlank() - } - - private fun partialCloser(text: String, fence: Fence): Boolean { - val trimmed = text.dropWhile { it == ' ' } - val indent = text.length - trimmed.length - if (indent > 3) return false - val size = trimmed.takeWhile { it == fence.char }.length - if (size !in 1 until fence.size) return false - return trimmed.drop(size).isBlank() - } - - private fun codeHtml(text: String): String = "
${escape(text)}
\n" - - private fun escape(text: String): String = text - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace("\"", """) - - private sealed class Desc { - data class Html(val body: String) : Desc() - data class Code(val text: String, val file: FileType) : Desc() - } - - private data class Projection(val html: String, val blocks: List, val open: Fence?) - - private data class Line(val text: String, val end: String) - - private data class Fence(val char: Char, val size: Int, val info: String) - - private abstract inner class View( - var desc: Desc, - val component: JComponent, - val disposable: Disposable, - ) { - abstract fun compatible(desc: Desc): Boolean - abstract fun update(desc: Desc) - abstract fun style(opts: MdStyle) - } - - private inner class HtmlView(desc: Desc.Html, private val pane: JBHtmlPane, disposable: Disposable) : - View(desc, pane, disposable) { - override fun compatible(desc: Desc) = desc is Desc.Html - - override fun update(desc: Desc) { - if (this.desc == desc) return - this.desc = desc - pane.text = "${(desc as Desc.Html).body}" - } - - override fun style(opts: MdStyle) { - pane.isOpaque = opts.opaque - pane.background = opts.background - pane.reloadCssStylesheets() - val item = desc as Desc.Html - pane.text = "${item.body}" - } - } - - private inner class CodeView(desc: Desc.Code, private val pane: JBScrollPane, disposable: Disposable) : - View(desc, pane, disposable) { - override fun compatible(desc: Desc) = desc is Desc.Code && (this.desc as Desc.Code).file == desc.file - - override fun update(desc: Desc) { - if (this.desc == desc) return - this.desc = desc - val value = (desc as Desc.Code).text.trimEnd('\n') - val view = pane.viewport.view - when (view) { - is CodeField -> view.text = value - is JBTextArea -> view.text = value - } - if (view is JComponent) { - sizeCodeField(view, value) - sizeCodePane(pane, view) - } - } - - fun grow(delta: String) { - val item = desc as Desc.Code - val next = item.copy(text = item.text + delta) - desc = next - val value = next.text.trimEnd('\n') - val view = pane.viewport.view - when (view) { - is CodeField -> view.text = value - is JBTextArea -> view.text = value - } - if (view is JComponent) { - sizeCodeField(view, value) - sizeCodePane(pane, view) - } - } - - override fun style(opts: MdStyle) { - styleCodePane(pane, opts) - val view = pane.viewport.view - when (view) { - is CodeField -> { - view.font = style.editorFont - view.background = opts.preBg - view.getEditor(false)?.let { 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.isOpaque = true - ed.scrollPane.viewport.isOpaque = true - ed.scrollPane.viewport.background = opts.preBg - ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER - } - } - is JBTextArea -> styleTextArea(view, opts) - } - if (view is JComponent) { - val text = when (view) { - is CodeField -> view.text - is JBTextArea -> view.text - else -> "" - } - sizeCodeField(view, text) - sizeCodePane(pane, view) - } - } - } - - private inner class Visitor : AbstractVisitor() { - val blocks = mutableListOf() - private val run = StringBuilder() - - override fun visit(document: Document) { - visitChildren(document) - flush() - } - - override fun visit(code: FencedCodeBlock) { - flush() - blocks.add(Desc.Code(code.literal, file(code.info))) - } - - override fun visit(code: IndentedCodeBlock) { - flush() - blocks.add(Desc.Code(code.literal, file(null))) - } - - private fun flush() { - if (run.isEmpty()) return - blocks.add(Desc.Html(run.toString())) - run.clear() - } - - public override fun visitChildren(parent: Node) { - var child = parent.firstChild - while (child != null) { - val next = child.next - if (child is ThematicBreak) { - child = next - continue - } - if (child is FencedCodeBlock || child is IndentedCodeBlock) child.accept(this) - if (child is Block && child !is FencedCodeBlock && child !is IndentedCodeBlock) run.append(renderer.render(child)) - child = next - } - } - } -} + selection: SessionSelection? = null, + code: MdCodeBlockFactory = MdCodeBlockFactory.default(), +) : ai.kilocode.client.ui.md.hybrid.MdViewHybrid(style, selection, code) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdLanguage.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdLanguage.kt new file mode 100644 index 00000000000..630b38a9b3d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdLanguage.kt @@ -0,0 +1,119 @@ +package ai.kilocode.client.ui.md.hybrid + +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.fileTypes.FileTypeRegistry +import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.openapi.fileTypes.UnknownFileType + +internal sealed class Kind { + data class Source(val file: FileType) : Kind() + data class Terminal(val stream: Stream, val mode: Mode) : Kind() +} + +internal enum class Stream { Stdout, Stderr } + +internal enum class Mode { Ansi, Shell } + +internal object MdLanguage { + private val terms = mapOf( + "ansi" to Kind.Terminal(Stream.Stdout, Mode.Ansi), + "ansi-stdout" to Kind.Terminal(Stream.Stdout, Mode.Ansi), + "terminal" to Kind.Terminal(Stream.Stdout, Mode.Ansi), + "terminal-output" to Kind.Terminal(Stream.Stdout, Mode.Ansi), + "shell-output" to Kind.Terminal(Stream.Stdout, Mode.Shell), + "ansi-stderr" to Kind.Terminal(Stream.Stderr, Mode.Ansi), + "terminal-error" to Kind.Terminal(Stream.Stderr, Mode.Ansi), + "shell-error" to Kind.Terminal(Stream.Stderr, Mode.Ansi), + ) + + private val files = mapOf( + "kt" to "kt", + "kotlin" to "kt", + "js" to "js", + "javascript" to "js", + "jsx" to "jsx", + "ts" to "ts", + "typescript" to "ts", + "tsx" to "tsx", + "java" to "java", + "py" to "py", + "python" to "py", + "sh" to "sh", + "bash" to "sh", + "shell" to "sh", + "zsh" to "sh", + "shellscript" to "sh", + "json" to "json", + "xml" to "xml", + "html" to "html", + "css" to "css", + "md" to "md", + "markdown" to "md", + "yaml" to "yaml", + "yml" to "yaml", + "toml" to "toml", + "go" to "go", + "golang" to "go", + "rs" to "rs", + "rust" to "rs", + "rb" to "rb", + "ruby" to "rb", + "php" to "php", + "swift" to "swift", + "scala" to "scala", + "sql" to "sql", + "dockerfile" to "dockerfile", + "docker" to "dockerfile", + "gradle" to "gradle", + "kts" to "kts", + "c" to "c", + "h" to "h", + "cpp" to "cpp", + "c++" to "cpp", + "cc" to "cc", + "cxx" to "cxx", + "hpp" to "hpp", + "h++" to "hpp", + "cs" to "cs", + "csharp" to "cs", + "c#" to "cs", + "fs" to "fs", + "fsharp" to "fs", + "f#" to "fs", + "ps1" to "ps1", + "powershell" to "ps1", + "pwsh" to "ps1", + "bat" to "bat", + "batch" to "bat", + "cmd" to "bat", + "makefile" to "makefile", + "make" to "makefile", + "terraform" to "tf", + "tf" to "tf", + "hcl" to "hcl", + "vue" to "vue", + "svelte" to "svelte", + "graphql" to "graphql", + "proto" to "proto", + "ini" to "ini", + "properties" to "properties", + "diff" to "diff", + "patch" to "patch", + ) + + fun kind(lang: String?): Kind { + val key = lang?.trim()?.split(Regex("\\s+"))?.take(2)?.joinToString(" ")?.lowercase().orEmpty() + terms[key]?.let { return it } + if (key == "shell script") return Kind.Source(type("sh")) + val single = key.substringBefore(' ') + terms[single]?.let { return it } + val ext = files[key] ?: files[single] ?: return Kind.Source(PlainTextFileType.INSTANCE) + return Kind.Source(type(ext)) + } + + private fun type(ext: String): FileType { + val type = FileTypeRegistry.getInstance().getFileTypeByExtension(ext) + if (type == UnknownFileType.INSTANCE) return PlainTextFileType.INSTANCE + return type + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdShellHighlight.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdShellHighlight.kt new file mode 100644 index 00000000000..9beea14c70e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdShellHighlight.kt @@ -0,0 +1,56 @@ +package ai.kilocode.client.ui.md.hybrid + +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors +import com.intellij.openapi.editor.colors.TextAttributesKey + +internal data class ShellRange(val start: Int, val end: Int, val key: TextAttributesKey) + +internal data class ShellDisplay(val text: String, val ranges: List) + +internal object MdShellHighlight { + private val hash = Regex("(?m)^[0-9a-f]{7,40}(?=\\s)") + private val commit = Regex("^[0-9a-f]{7,40}(?:\\s|$)") + private val file = Regex("^\\s+.+\\s+\\|\\s+\\d+") + private val summary = Regex("^\\d+ files? changed(?:,|$)") + private val refs = Regex("\\((?:HEAD|origin|main|master|develop|release|feature|bugfix|hotfix|[^)]+/[^)]+)[^)]*\\)") + private val plus = Regex("\\+{2,}") + private val minus = Regex("-{2,}") + private val insertions = Regex("\\b\\d+ insertions?\\(\\+\\)") + private val deletions = Regex("\\b\\d+ deletions?\\(-\\)") + private val meta = Regex("(?m)^<(?:shell_metadata|/shell_metadata)>$") + private val cut = Regex("(?m)^\\.\\.\\.output truncated\\.\\.\\.$") + + fun project(text: String): ShellDisplay { + val out = mutableListOf() + var grouped = false + var stat = false + + for (line in text.lines()) { + val header = commit.containsMatchIn(line) + if (header && grouped && stat && out.lastOrNull()?.isNotEmpty() == true) out.add("") + out.add(line) + if (header) grouped = true + stat = line.isNotBlank() && (file.containsMatchIn(line) || summary.containsMatchIn(line)) + } + + val display = out.joinToString("\n") + return ShellDisplay(display, ranges(display)) + } + + fun ranges(text: String): List = buildList { + fun add(regex: Regex, key: TextAttributesKey) { + regex.findAll(text).forEach { match -> + add(ShellRange(match.range.first, match.range.last + 1, key)) + } + } + + add(hash, DefaultLanguageHighlighterColors.NUMBER) + add(refs, DefaultLanguageHighlighterColors.KEYWORD) + add(insertions, DefaultLanguageHighlighterColors.STRING) + add(deletions, DefaultLanguageHighlighterColors.LINE_COMMENT) + add(plus, DefaultLanguageHighlighterColors.STRING) + add(minus, DefaultLanguageHighlighterColors.LINE_COMMENT) + add(meta, DefaultLanguageHighlighterColors.DOC_COMMENT) + add(cut, DefaultLanguageHighlighterColors.KEYWORD) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdTerminal.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdTerminal.kt new file mode 100644 index 00000000000..dba405690ae --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdTerminal.kt @@ -0,0 +1,69 @@ +package ai.kilocode.client.ui.md.hybrid + +import com.intellij.execution.process.AnsiEscapeDecoder +import com.intellij.execution.process.ProcessOutputTypes +import com.intellij.openapi.util.Key + +internal data class Range(val start: Int, val end: Int, val key: Key<*>) + +internal data class Term(val text: String, val ranges: List) + +internal object MdTerminal { + fun decode(text: String, stream: Stream): Term { + val out = StringBuilder() + val ranges = mutableListOf() + val key = when (stream) { + Stream.Stdout -> ProcessOutputTypes.STDOUT + Stream.Stderr -> ProcessOutputTypes.STDERR + } + AnsiEscapeDecoder().escapeText(controls(text), key) { chunk, attrs -> + val start = out.length + out.append(chunk) + val end = out.length + if (start != end) ranges.add(Range(start, end, attrs)) + } + return Term(out.toString().trimEnd('\n'), ranges) + } + + private fun controls(text: String): String { + val out = StringBuilder() + val line = StringBuilder() + val src = text.replace("\r\n", "\n") + var idx = 0 + fun esc(): String? { + if (src[idx] != '\u001B') return null + if (idx + 1 >= src.length || src[idx + 1] != '[') { + idx++ + return "" + } + var end = idx + 2 + while (end < src.length && src[end] !in '@'..'~') end++ + if (end >= src.length) { + idx = src.length + return "" + } + val seq = src.substring(idx, end + 1) + idx = end + 1 + return if (seq.endsWith('m')) seq else "" + } + while (idx < src.length) { + val seq = esc() + if (seq != null) { + line.append(seq) + continue + } + when (val ch = src[idx++]) { + '\r' -> line.clear() + '\n' -> { + out.append(line).append('\n') + line.clear() + } + '\b' -> if (line.isNotEmpty()) line.deleteCharAt(line.length - 1) + '\t' -> line.append(ch) + else -> if (!ch.isISOControl()) line.append(ch) + } + } + out.append(line) + return out.toString() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt new file mode 100644 index 00000000000..eb786ddcff1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt @@ -0,0 +1,1066 @@ +package ai.kilocode.client.ui.md.hybrid + +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.md.MdCodeBlockBorder +import ai.kilocode.client.ui.md.MdCodeBlockFactory +import ai.kilocode.client.ui.md.MdCommon +import ai.kilocode.client.ui.md.MdStyle +import ai.kilocode.client.ui.md.MdView +import ai.kilocode.log.KiloLog +import com.intellij.execution.ui.ConsoleViewContentType +import com.intellij.openapi.Disposable +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.editor.markup.HighlighterLayer +import com.intellij.openapi.editor.markup.HighlighterTargetArea +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.util.Disposer +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.node.ThematicBreak +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 +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 open class MdViewHybrid( + style: SessionEditorStyle = SessionEditorStyle.current(), + private var selection: SessionSelection? = null, + private val code: MdCodeBlockFactory = MdCodeBlockFactory.default(), +) : 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 var disposed = false + private val blocks = mutableListOf() + private var openFence: Fence? = null + private var stale = false + + 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 (disposed) return + if (fontOverride == value) return + fontOverride = value + syncStyle() + } + + override var foreground: Color + get() = foregroundOverride ?: opts().foreground + set(value) { + if (disposed) return + if (foregroundOverride == value) return + foregroundOverride = value + syncStyle() + } + + override var background: Color + get() = backgroundOverride ?: opts().background + set(value) { + if (disposed) return + if (backgroundOverride == value) return + backgroundOverride = value + syncStyle() + } + + override var linkColor: Color + get() = linkColorOverride ?: opts().linkColor + set(value) { + if (disposed) return + if (linkColorOverride == value) return + linkColorOverride = value + syncStyle() + } + + override var codeBg: Color + get() = codeBgOverride ?: opts().codeBg + set(value) { + if (disposed) return + if (codeBgOverride == value) return + codeBgOverride = value + syncStyle() + } + + override var preBg: Color + get() = preBgOverride ?: opts().preBg + set(value) { + if (disposed) return + if (preBgOverride == value) return + preBgOverride = value + syncStyle() + } + + override var preFg: Color + get() = preFgOverride ?: opts().preFg + set(value) { + if (disposed) return + if (preFgOverride == value) return + preFgOverride = value + syncStyle() + } + + override var codeFont: String + get() = codeFontOverride ?: opts().codeFont + set(value) { + if (disposed) return + if (codeFontOverride == value) return + codeFontOverride = value + syncStyle() + } + + override var quoteBorder: Color + get() = quoteBorderOverride ?: opts().quoteBorder + set(value) { + if (disposed) return + if (quoteBorderOverride == value) return + quoteBorderOverride = value + syncStyle() + } + + override var quoteFg: Color + get() = quoteFgOverride ?: opts().quoteFg + set(value) { + if (disposed) return + if (quoteFgOverride == value) return + quoteFgOverride = value + syncStyle() + } + + override var tableBorder: Color + get() = tableBorderOverride ?: opts().tableBorder + set(value) { + if (disposed) return + if (tableBorderOverride == value) return + tableBorderOverride = value + syncStyle() + } + + override var opaque: Boolean + get() = opaqueState + set(value) { + if (disposed) return + if (opaqueState == value) return + opaqueState = value + syncStyle() + } + + override fun applyStyle(style: SessionEditorStyle) { + if (disposed) return + this.style = style + selection?.applyStyle(style) + syncStyle() + } + + override fun setSelection(selection: SessionSelection?) { + if (disposed) return + if (this.selection === selection) return + this.selection = selection + clearBlocks() + syncBlocks() + } + + override fun resetStyles() { + if (disposed) return + 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 (disposed) return + if (source.toString() == text) return + source.clear() + source.append(text) + syncBlocks() + } + + override fun append(delta: String) { + if (disposed) return + if (delta.isEmpty()) return + val fence = openFence + val view = blocks.lastOrNull() as? CodeView + if (fence != null && view != null && clean(fence.char, delta)) { + source.append(delta) + view.grow(delta) + stale = true + root.revalidate() + root.repaint() + return + } + source.append(delta) + syncBlocks() + } + + override fun clear() { + if (disposed) return + if (source.isEmpty() && rendered.isEmpty() && root.componentCount == 0) return + source.clear() + rendered = "" + openFence = null + stale = false + clearBlocks() + root.revalidate() + root.repaint() + } + + override fun addLinkListener(listener: MdView.LinkListener) { + if (disposed) return + listeners.add(listener) + } + + override fun removeLinkListener(listener: MdView.LinkListener) { + listeners.remove(listener) + } + + override fun markdown(): String = source.toString() + + override fun html(): String { + if (!stale) return rendered + val out = project(source.toString()) + rendered = out.html + openFence = out.open + stale = false + return rendered + } + + override fun overrideSheet(): String = MdCommon.rules(opts()) + + override fun simulateLink(href: String) { + if (disposed) return + dispatch(MdView.LinkEvent(href)) + } + + override fun dispose() { + disposed = true + listeners.clear() + source.clear() + rendered = "" + openFence = null + stale = false + clearBlocks() + } + + private fun syncStyle() { + if (disposed) return + val opts = opts() + root.isOpaque = opts.opaque + if (opts.opaque) root.background = opts.background + for (view in blocks) view.style(opts) + root.revalidate() + root.repaint() + } + + private fun syncBlocks() { + if (disposed) return + val text = source.toString() + val out = project(text) + rendered = out.html + openFence = out.open + stale = false + val next = out.blocks + if (text.isEmpty()) { + openFence = null + clearBlocks() + root.revalidate() + root.repaint() + return + } + sync(next) + root.revalidate() + root.repaint() + } + + private fun clearBlocks() { + blocks.forEach { Disposer.dispose(it.disposable) } + blocks.clear() + root.removeAll() + } + + private fun sync(next: List) { + var at = 0 + while (at < blocks.size && at < next.size) { + val view = blocks[at] + val desc = next[at] + if (!view.compatible(desc)) break + view.update(desc) + at++ + } + removeBlocks(at) + for (desc in next.drop(at)) addBlock(view(desc)) + } + + private fun clean(char: Char, delta: String): Boolean { + if (delta.contains(char)) return false + val start = source.lastIndexOf("\n") + 1 + for (idx in start until source.length) { + if (source[idx] == char) return false + } + return true + } + + private fun removeBlocks(start: Int) { + if (start >= blocks.size) return + val idx = if (start == 0) 0 else start * 2 - 1 + while (root.componentCount > idx) root.remove(root.componentCount - 1) + val stale = blocks.drop(start) + repeat(blocks.size - start) { blocks.removeAt(blocks.lastIndex) } + stale.forEach { Disposer.dispose(it.disposable) } + } + + private fun addGap() { + if (root.componentCount == 0) return + root.add(Box.createVerticalStrut(JBUI.scale(SessionUiStyle.View.Code.BLOCK_GAP))) + } + + private fun addBlock(view: View) { + addGap() + view.component.alignmentX = JComponent.LEFT_ALIGNMENT + blocks.add(view) + root.add(view.component) + } + + private fun view(desc: Desc): View { + val disposable = Disposer.newDisposable("Markdown block") + return when (desc) { + is Desc.Html -> HtmlView(desc, htmlBlock(desc.body, disposable), disposable) + is Desc.Code -> when (val kind = desc.kind) { + is Kind.Source -> CodeView(desc, codeBlock(desc.text, kind.file, disposable), disposable) + is Kind.Terminal -> TermView(desc, terminalBlock(desc.text, kind, disposable), disposable) + } + } + } + + private fun htmlBlock(body: String, disposable: Disposable): JBHtmlPane { + val opts = opts() + return JBHtmlPane( + JBHtmlPaneStyleConfiguration { + enableInlineCodeBackground = true + enableCodeBlocksBackground = true + }, + JBHtmlPaneConfiguration { + customStyleSheetProvider { sheet() } + }, + ).apply { + isEditable = false + isOpaque = opts.opaque + background = opts.background + text = "$body" + selection?.register(this, disposable) + 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, file: FileType, disposable: Disposable): JBScrollPane { + val opts = opts() + val value = text.trimEnd('\n') + fun editor(type: FileType) = CodeField(type, opts, text, false).also { ed -> + Disposer.register(disposable) { + ed.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) + } + ed.setDisposedWith(disposable) + selection?.register(ed, disposable) + } + val field = runCatching { + editor(file) + }.getOrElse { err -> + LOG.warn("kind=markdown codeEditor=true failed message=${err.message}", err) + if (code.opts.editorOnly) runCatching { + editor(PlainTextFileType.INSTANCE) + }.getOrElse { fallback -> + LOG.warn("kind=markdown codeEditor=true fallback=plain failed message=${fallback.message}", fallback) + throw fallback + } else { + textArea(text, opts, disposable) + } + } + sizeCodeField(field, value) + val pane = object : JBScrollPane(field) { + override fun doLayout() { + super.doLayout() + if (code.opts.verticalPolicy != ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER) return + 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) + } + } + styleCodePane(pane, opts) + sizeCodePane(pane, field) + return pane + } + + private fun terminalBlock(text: String, kind: Kind.Terminal, disposable: Disposable): JBScrollPane { + val opts = opts() + val term = MdTerminal.decode(text, kind.stream) + val value = shellDisplay(term, kind.mode) + val field = CodeField(PlainTextFileType.INSTANCE, opts, value.text, false).also { ed -> + Disposer.register(disposable) { + ed.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) + } + ed.setDisposedWith(disposable) + selection?.register(ed, disposable) + } + sizeCodeField(field, value.text) + val pane = object : JBScrollPane(field) { + override fun doLayout() { + super.doLayout() + if (code.opts.verticalPolicy != ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER) return + 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) + } + } + styleCodePane(pane, opts) + sizeCodePane(pane, field) + applyTerm(field, term, kind.mode, value) + return pane + } + + private fun styleCodePane(pane: JBScrollPane, opts: MdStyle) { + pane.apply { + val width = SessionUiStyle.View.Code.BORDER_WIDTH + border = when (code.opts.border) { + MdCodeBlockBorder.All -> JBUI.Borders.customLine(opts.codeBorder, width) + MdCodeBlockBorder.Horizontal -> JBUI.Borders.customLine(opts.codeBorder, width, 0, width, 0) + MdCodeBlockBorder.Bottom -> JBUI.Borders.customLine(opts.codeBorder, 0, 0, width, 0) + } + 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 = true + background = opts.preBg + viewport.isOpaque = true + viewport.background = opts.preBg + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED + verticalScrollBarPolicy = code.opts.verticalPolicy + isWheelScrollingEnabled = true + setOverlappingScrollBar(false) + horizontalScrollBar.preferredSize = Dimension(0, JBUI.scale(SessionUiStyle.View.Code.SCROLLBAR_HEIGHT)) + horizontalScrollBar.isOpaque = true + if (code.opts.verticalPolicy == ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER) { + verticalScrollBar.preferredSize = JBUI.emptySize() + } + } + } + + private fun sizeCodeField(component: JComponent, text: String) { + val height = codeHeight(component, text) + val width = codeWidth(component, text) + component.preferredSize = Dimension(width, height) + component.minimumSize = Dimension(0, height) + component.maximumSize = Dimension(Int.MAX_VALUE, height) + } + + private fun sizeCodePane(pane: JBScrollPane, component: JComponent) { + val pad = pane.viewportBorder.getBorderInsets(pane) + val text = when (component) { + is CodeField -> component.text + is JBTextArea -> component.text + else -> "" + } + val content = visibleCodeHeight(component, text) + val height = content + pane.insets.top + pane.insets.bottom + + pad.top + pad.bottom + pane.horizontalScrollBar.preferredSize.height + pane.preferredSize = Dimension(0, height) + pane.minimumSize = Dimension(0, height) + pane.maximumSize = Dimension(Int.MAX_VALUE, height) + } + + 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 visibleCodeHeight(component: JComponent, text: String): Int { + val max = code.opts.maxLines ?: return component.preferredSize.height + val count = text.lineSequence().count() + val rows = count.coerceAtLeast(SessionUiStyle.View.Code.MIN_ROWS).coerceAtMost(max) + 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 line * rows + } + val line = component.getFontMetrics(component.font).height + return line * rows + } + + private fun textArea(text: String, opts: MdStyle, disposable: Disposable) = JBTextArea(text.trimEnd('\n')).apply { + isEditable = false + lineWrap = false + styleTextArea(this, opts) + border = JBUI.Borders.empty( + SessionUiStyle.View.Code.VIEWPORT_TOP_PADDING, + SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING, + ) + selection?.register(this, disposable) + } + + private fun styleTextArea(area: JBTextArea, opts: MdStyle) { + area.isOpaque = true + area.background = opts.preBg + area.foreground = opts.preFg + area.font = style.editorFont + } + + private inner class CodeField(file: FileType, opts: MdStyle, value: String, val soft: Boolean) : + com.intellij.ui.EditorTextField( + EditorFactory.getInstance().createDocument(value.trimEnd('\n')), + ProjectManager.getInstance().defaultProject, + file, + true, + false, + ) { + init { + setFontInheritedFromLAF(false) + font = style.editorFont + 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.isOpaque = true + ed.scrollPane.viewport.isOpaque = true + ed.scrollPane.viewport.background = opts.preBg + ed.settings.isUseSoftWraps = soft + ed.settings.isAdditionalPageAtBottom = false + ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + } + } + } + + private fun shellDisplay(term: Term, mode: Mode): ShellDisplay { + if (mode == Mode.Shell) return MdShellHighlight.project(term.text) + return ShellDisplay(term.text, emptyList()) + } + + private fun applyTerm(field: CodeField, term: Term, mode: Mode, display: ShellDisplay = shellDisplay(term, mode)) { + val editor = field.getEditor(true) ?: return + editor.markupModel.removeAllHighlighters() + if (mode == Mode.Shell) { + applyShell(field, display) + return + } + val size = editor.document.textLength + for (range in term.ranges) { + val start = range.start.coerceAtMost(size) + val end = range.end.coerceAtMost(size) + if (start >= end) continue + val type = ConsoleViewContentType.getConsoleViewType(range.key) + val key = type.attributesKey + if (key != null) { + editor.markupModel.addRangeHighlighter( + key, + start, + end, + HighlighterLayer.SYNTAX + 1, + HighlighterTargetArea.EXACT_RANGE, + ) + } else { + editor.markupModel.addRangeHighlighter( + start, + end, + HighlighterLayer.SYNTAX + 1, + type.attributes, + HighlighterTargetArea.EXACT_RANGE, + ) + } + } + } + + private fun applyShell(field: CodeField, display: ShellDisplay) { + val editor = field.getEditor(false) ?: return + val size = editor.document.textLength + for (range in display.ranges) { + val start = range.start.coerceAtMost(size) + val end = range.end.coerceAtMost(size) + if (start >= end) continue + editor.markupModel.addRangeHighlighter( + range.key, + start, + end, + HighlighterLayer.SYNTAX + 1, + HighlighterTargetArea.EXACT_RANGE, + ) + } + } + + 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 fun collect(doc: Node): List { + val visitor = Visitor() + doc.accept(visitor) + return visitor.blocks + } + + private fun project(text: String): Projection { + val blocks = mutableListOf() + val html = StringBuilder() + val md = StringBuilder() + val lines = lines(text) + var trailing: Fence? = null + var idx = 0 + + fun flush() { + if (md.isEmpty()) return + val doc = parser.parse(md.toString()) + val descs = collect(doc) + blocks.addAll(descs) + for (desc in descs) { + when (desc) { + is Desc.Html -> html.append(desc.body) + is Desc.Code -> html.append(codeHtml(desc.text)) + } + } + md.clear() + } + + while (idx < lines.size) { + val line = lines[idx] + val open = opener(line.text) + if (open == null) { + val pending = idx == lines.lastIndex && pendingOpener(line.text) + if (pending) { + flush() + blocks.add(Desc.Code("", Kind.Source(PlainTextFileType.INSTANCE))) + html.append(codeHtml("")) + } else { + md.append(line.text).append(line.end) + } + idx++ + continue + } + + flush() + idx++ + val code = StringBuilder() + var closed = false + var trimmed = false + while (idx < lines.size) { + val item = lines[idx] + val close = closer(item.text, open) + if (close) { + closed = true + idx++ + break + } + val partial = idx == lines.lastIndex && partialCloser(item.text, open) + if (partial) trimmed = true + if (!partial) code.append(item.text).append(item.end) + idx++ + } + val desc = Desc.Code(code.toString(), MdLanguage.kind(open.info)) + blocks.add(desc) + html.append(codeHtml(desc.text)) + trailing = if (!closed && !trimmed) open else null + } + + flush() + return Projection(html.toString(), blocks, trailing) + } + + private fun lines(text: String): List { + if (text.isEmpty()) return emptyList() + val lines = mutableListOf() + var start = 0 + while (start < text.length) { + val end = text.indexOf('\n', start) + if (end == -1) { + lines.add(Line(text.substring(start), "")) + break + } + lines.add(Line(text.substring(start, end), "\n")) + start = end + 1 + } + return lines + } + + private fun opener(text: String): Fence? { + val trimmed = text.dropWhile { it == ' ' } + val indent = text.length - trimmed.length + if (indent > 3) return null + val char = trimmed.firstOrNull() ?: return null + if (char != '`' && char != '~') return null + val size = trimmed.takeWhile { it == char }.length + if (size < 3) return null + val info = trimmed.drop(size).trim() + if (char == '`' && info.contains('`')) return null + return Fence(char, size, info) + } + + private fun closer(text: String, fence: Fence): Boolean { + val trimmed = text.dropWhile { it == ' ' } + val indent = text.length - trimmed.length + if (indent > 3) return false + val size = trimmed.takeWhile { it == fence.char }.length + if (size < fence.size) return false + return trimmed.drop(size).isBlank() + } + + private fun pendingOpener(text: String): Boolean { + val trimmed = text.dropWhile { it == ' ' } + val indent = text.length - trimmed.length + if (indent > 3) return false + val char = trimmed.firstOrNull() ?: return false + if (char != '`' && char != '~') return false + val size = trimmed.takeWhile { it == char }.length + if (size !in 1..2) return false + return trimmed.drop(size).isBlank() + } + + private fun partialCloser(text: String, fence: Fence): Boolean { + val trimmed = text.dropWhile { it == ' ' } + val indent = text.length - trimmed.length + if (indent > 3) return false + val size = trimmed.takeWhile { it == fence.char }.length + if (size !in 1 until fence.size) return false + return trimmed.drop(size).isBlank() + } + + private fun codeHtml(text: String): String = "
${escape(text)}
\n" + + private fun escape(text: String): String = text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + + private sealed class Desc { + data class Html(val body: String) : Desc() + data class Code(val text: String, val kind: Kind) : Desc() + } + + private data class Projection(val html: String, val blocks: List, val open: Fence?) + + private data class Line(val text: String, val end: String) + + private data class Fence(val char: Char, val size: Int, val info: String) + + private abstract inner class View( + var desc: Desc, + val component: JComponent, + val disposable: Disposable, + ) { + abstract fun compatible(desc: Desc): Boolean + abstract fun update(desc: Desc) + abstract fun style(opts: MdStyle) + } + + private inner class HtmlView(desc: Desc.Html, private val pane: JBHtmlPane, disposable: Disposable) : + View(desc, pane, disposable) { + override fun compatible(desc: Desc) = desc is Desc.Html + + override fun update(desc: Desc) { + if (this.desc == desc) return + this.desc = desc + pane.text = "${(desc as Desc.Html).body}" + } + + override fun style(opts: MdStyle) { + pane.isOpaque = opts.opaque + pane.background = opts.background + pane.reloadCssStylesheets() + val item = desc as Desc.Html + pane.text = "${item.body}" + } + } + + private inner class CodeView(desc: Desc.Code, private val pane: JBScrollPane, disposable: Disposable) : + View(desc, pane, disposable) { + override fun compatible(desc: Desc) = desc is Desc.Code && (this.desc as Desc.Code).kind == desc.kind + + override fun update(desc: Desc) { + if (this.desc == desc) return + this.desc = desc + val value = (desc as Desc.Code).text.trimEnd('\n') + val view = pane.viewport.view + when (view) { + is CodeField -> view.text = value + is JBTextArea -> view.text = value + } + if (view is JComponent) { + sizeCodeField(view, value) + sizeCodePane(pane, view) + } + } + + fun grow(delta: String) { + val item = desc as Desc.Code + val next = item.copy(text = item.text + delta) + desc = next + val value = next.text.trimEnd('\n') + val view = pane.viewport.view + when (view) { + is CodeField -> view.text = value + is JBTextArea -> view.text = value + } + if (view is JComponent) { + sizeCodeField(view, value) + sizeCodePane(pane, view) + } + } + + override fun style(opts: MdStyle) { + styleCodePane(pane, opts) + val view = pane.viewport.view + when (view) { + is CodeField -> { + view.font = style.editorFont + view.background = opts.preBg + view.getEditor(false)?.let { 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.isOpaque = true + ed.scrollPane.viewport.isOpaque = true + ed.scrollPane.viewport.background = opts.preBg + ed.settings.isUseSoftWraps = view.soft + ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + } + } + is JBTextArea -> styleTextArea(view, opts) + } + if (view is JComponent) { + val text = when (view) { + is CodeField -> view.text + is JBTextArea -> view.text + else -> "" + } + sizeCodeField(view, text) + sizeCodePane(pane, view) + } + } + } + + private inner class TermView(desc: Desc.Code, private val pane: JBScrollPane, disposable: Disposable) : + View(desc, pane, disposable) { + override fun compatible(desc: Desc) = desc is Desc.Code && (this.desc as Desc.Code).kind == desc.kind + + override fun update(desc: Desc) { + if (this.desc == desc) return + this.desc = desc + val item = desc as Desc.Code + val kind = item.kind as Kind.Terminal + val term = MdTerminal.decode(item.text, kind.stream) + val value = shellDisplay(term, kind.mode) + val view = pane.viewport.view as? CodeField ?: return + view.text = value.text + sizeCodeField(view, value.text) + sizeCodePane(pane, view) + applyTerm(view, term, kind.mode, value) + } + + fun grow(delta: String) { + val item = desc as Desc.Code + update(item.copy(text = item.text + delta)) + } + + override fun style(opts: MdStyle) { + styleCodePane(pane, opts) + val view = pane.viewport.view as? CodeField ?: return + val item = desc as Desc.Code + val kind = item.kind as Kind.Terminal + view.font = style.editorFont + view.background = opts.preBg + view.getEditor(false)?.let { 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.isOpaque = true + ed.scrollPane.viewport.isOpaque = true + ed.scrollPane.viewport.background = opts.preBg + ed.settings.isUseSoftWraps = view.soft + ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + } + val term = MdTerminal.decode(item.text, kind.stream) + val value = shellDisplay(term, kind.mode) + if (view.text != value.text) view.text = value.text + sizeCodeField(view, value.text) + sizeCodePane(pane, view) + applyTerm(view, term, kind.mode, value) + } + } + + private inner class Visitor : AbstractVisitor() { + val blocks = mutableListOf() + private val run = StringBuilder() + + override fun visit(document: Document) { + visitChildren(document) + flush() + } + + override fun visit(code: FencedCodeBlock) { + flush() + blocks.add(Desc.Code(code.literal, MdLanguage.kind(code.info))) + } + + override fun visit(code: IndentedCodeBlock) { + flush() + blocks.add(Desc.Code(code.literal, MdLanguage.kind(null))) + } + + private fun flush() { + if (run.isEmpty()) return + blocks.add(Desc.Html(run.toString())) + run.clear() + } + + public override fun visitChildren(parent: Node) { + var child = parent.firstChild + while (child != null) { + val next = child.next + if (child is ThematicBreak) { + child = next + continue + } + if (child is FencedCodeBlock || child is IndentedCodeBlock) child.accept(this) + if (child is Block && child !is FencedCodeBlock && child !is IndentedCodeBlock) run.append(renderer.render(child)) + child = next + } + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt index 60fd4c95220..bf7e13d437a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt @@ -9,6 +9,8 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.tool.ShellToolView import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.ui.UiStyle +import com.intellij.execution.process.ProcessOutputTypes +import com.intellij.execution.ui.ConsoleViewContentType import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase @@ -39,7 +41,7 @@ class ShellToolViewTest : BasePlatformTestCase() { assertEquals("pwd", view.bodyText()) view.toggle() - assertEquals("### Command\n\n```shell\npwd\n```", view.markdown()) + assertEquals("**Command**\n\n```shell\npwd\n```", view.markdown()) assertEquals(listOf("pwd"), view.codeTexts()) } @@ -49,7 +51,7 @@ class ShellToolViewTest : BasePlatformTestCase() { assertEquals("done", view.outputText()) view.toggle() - assertEquals("### Output\n\n```shell\ndone\n```", view.markdown()) + assertEquals("**Output**\n\n```shell-output\ndone\n```", view.markdown()) assertEquals(listOf("done"), view.codeTexts()) } @@ -67,19 +69,21 @@ class ShellToolViewTest : BasePlatformTestCase() { view.toggle() assertEquals( - "### Command\n\n```shell\ngit status\n```\n\n### Output\n\n```shell\nclean\n```", + "**Command**\n\n```shell\ngit status\n```\n\n**Output**\n\n```shell-output\nclean\n```", view.markdown(), ) assertEquals(listOf("git status", "clean"), view.codeTexts()) } - fun `test ansi escapes are stripped from output`() { + fun `test ansi escapes are preserved in markdown and decoded in output`() { val view = track(ShellToolView(tool().also { it.output = "\u001B[32mgreen\u001B[0m line" })) assertEquals("green line", view.outputText()) view.toggle() + assertTrue(view.markdown().contains("\u001B[32mgreen\u001B[0m line")) assertEquals(listOf("green line"), view.codeTexts()) + assertTrue(view.codeEditors().single().getEditor(true)!!.markupModel.allHighlighters.isNotEmpty()) } fun `test carriage return frames keep last non-empty value`() { @@ -93,12 +97,21 @@ class ShellToolViewTest : BasePlatformTestCase() { assertEquals(listOf("progress done\nstdout line"), view.codeTexts()) } + fun `test output backspaces clean visible text`() { + val view = track(ShellToolView(tool().also { it.output = "abc\b\bd" })) + + assertEquals("ad", view.outputText()) + view.toggle() + + assertEquals(listOf("ad"), view.codeTexts()) + } + fun `test output backticks use longer markdown fence`() { val view = track(ShellToolView(tool().also { it.output = "before\n```\nafter" })) view.toggle() - assertTrue(view.markdown().contains("````shell\nbefore\n```\nafter\n````")) + assertTrue(view.markdown().contains("````shell-output\nbefore\n```\nafter\n````")) assertEquals(listOf("before\n```\nafter"), view.codeTexts()) } @@ -113,10 +126,13 @@ class ShellToolViewTest : BasePlatformTestCase() { view.toggle() assertEquals( - "### Command\n\n```shell\nfail\n```\n\n### Error\n\n```shell\nboom\n```", + "**Command**\n\n```shell\nfail\n```\n\n**Error**\n\n```ansi-stderr\nboom\n```", view.markdown(), ) assertEquals(listOf("fail", "boom"), view.codeTexts()) + val error = view.codeEditors().last().getEditor(true)!! + val expected = ConsoleViewContentType.getConsoleViewType(ProcessOutputTypes.STDERR).attributesKey + assertEquals(expected, error.markupModel.allHighlighters.single().textAttributesKey) } fun `test body is created lazily and reused`() { @@ -220,7 +236,35 @@ class ShellToolViewTest : BasePlatformTestCase() { assertTrue(view.preferredSize.height > 0) } - fun `test shell labels are inset and code blocks use bottom border only`() { + fun `test plain git output receives shell output highlighters`() { + val output = """ + 475ab514 (HEAD -> main, origin/main, origin/HEAD) Bump kotlinSerialization from 1.10.0 to 1.11.0 + gradle/libs.versions.toml | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + e8b9785 Add second change + packages/kilo-jetbrains/frontend/src/main/kotlin/App.kt | 14 ++++++++++---- + 1 file changed, 10 insertions(+), 4 deletions(-) + """.trimIndent() + val display = """ + 475ab514 (HEAD -> main, origin/main, origin/HEAD) Bump kotlinSerialization from 1.10.0 to 1.11.0 + gradle/libs.versions.toml | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + + e8b9785 Add second change + packages/kilo-jetbrains/frontend/src/main/kotlin/App.kt | 14 ++++++++++---- + 1 file changed, 10 insertions(+), 4 deletions(-) + """.trimIndent() + val view = track(ShellToolView(tool().also { it.output = output })) + + view.toggle() + val editor = view.codeEditors().single().getEditor(true)!! + + assertTrue(view.markdown().contains("```shell-output\n$output\n```")) + assertEquals(display, view.codeTexts().single()) + assertTrue(editor.markupModel.allHighlighters.size >= 4) + } + + fun `test shell labels align with code text and code blocks use bottom border only`() { val view = track(ShellToolView(tool().also { it.input = mapOf("command" to "pwd") it.output = "/tmp" @@ -235,9 +279,9 @@ class ShellToolViewTest : BasePlatformTestCase() { assertEquals(2, labels.size) assertEquals(2, panes.size) labels.forEach { - val label = it.border.getBorderInsets(it) - assertEquals(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), label.left) - assertEquals(0, label.right) + val label = it.border?.getBorderInsets(it) + assertEquals(JBUI.scale(SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING), label?.left ?: 0) + assertEquals(0, label?.right ?: 0) } panes.forEach { val pane = it.border.getBorderInsets(it) @@ -259,6 +303,7 @@ class ShellToolViewTest : BasePlatformTestCase() { val pane = root.components.filterIsInstance().single() val editor = view.codeEditors().single() val nested = editor.getEditor(true)!!.scrollPane + val line = editor.getEditor(true)!!.lineHeight val chrome = pane.insets.top + pane.insets.bottom + pane.viewportBorder.getBorderInsets(pane).top + pane.viewportBorder.getBorderInsets(pane).bottom + pane.horizontalScrollBar.preferredSize.height @@ -266,8 +311,11 @@ class ShellToolViewTest : BasePlatformTestCase() { assertEquals(output, editor.text) assertEquals(1, view.codeEditors().size) assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, pane.verticalScrollBarPolicy) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, pane.horizontalScrollBarPolicy) assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, nested.verticalScrollBarPolicy) assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, nested.horizontalScrollBarPolicy) + assertFalse(editor.getEditor(true)!!.settings.isUseSoftWraps) + assertTrue(pane.preferredSize.height <= line * 15 + chrome) assertTrue(editor.preferredSize.height > pane.preferredSize.height - chrome) assertTrue(pane.preferredSize.height < editor.preferredSize.height + chrome) } 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 ea569feef95..0c57ba8d0d8 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 @@ -2,6 +2,8 @@ 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.execution.process.ProcessOutputTypes +import com.intellij.execution.ui.ConsoleViewContentType import com.intellij.openapi.editor.DefaultLanguageHighlighterColors import com.intellij.openapi.editor.HighlighterColors import com.intellij.openapi.editor.colors.CodeInsightColors @@ -511,6 +513,107 @@ class MdViewHybridTest : BasePlatformTestCase() { assertSame(PlainTextFileType.INSTANCE, editors().single().fileType) } + fun `test ansi stdout aliases render terminal plain text`() { + listOf("ansi", "ansi-stdout", "terminal-output").forEach { lang -> + view.set("```$lang\n\u001B[32mgreen\u001B[0m\n```") + + assertSame(PlainTextFileType.INSTANCE, editors().single().fileType) + assertEquals("green", editors().single().text) + assertTrue(editors().single().getEditor(true)!!.markupModel.allHighlighters.isNotEmpty()) + } + } + + fun `test shell output renders plain text with semantic highlighters`() { + val output = """ + 475ab514 (HEAD -> main, origin/main, origin/HEAD) Bump kotlinSerialization from 1.10.0 to 1.11.0 + gradle/libs.versions.toml | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + e8b9785 Add second change + packages/kilo-jetbrains/frontend/src/main/kotlin/App.kt | 14 ++++++++++---- + 1 file changed, 10 insertions(+), 4 deletions(-) + """.trimIndent() + val display = """ + 475ab514 (HEAD -> main, origin/main, origin/HEAD) Bump kotlinSerialization from 1.10.0 to 1.11.0 + gradle/libs.versions.toml | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + + e8b9785 Add second change + packages/kilo-jetbrains/frontend/src/main/kotlin/App.kt | 14 ++++++++++---- + 1 file changed, 10 insertions(+), 4 deletions(-) + """.trimIndent() + + view.set("```shell-output\n$output\n```") + val pane = scrolls().single() + val field = editors().single() + val editor = field.getEditor(true)!! + val spans = editor.markupModel.allHighlighters.map { + field.text.substring(it.startOffset, it.endOffset) to it.textAttributesKey + } + + assertSame(PlainTextFileType.INSTANCE, field.fileType) + assertEquals("```shell-output\n$output\n```", view.markdown()) + assertEquals(display, field.text) + assertTrue(spans.contains("475ab514" to DefaultLanguageHighlighterColors.NUMBER)) + assertTrue(spans.contains("(HEAD -> main, origin/main, origin/HEAD)" to DefaultLanguageHighlighterColors.KEYWORD)) + assertTrue(spans.contains("1 insertion(+)" to DefaultLanguageHighlighterColors.STRING)) + assertTrue(spans.contains("1 deletion(-)" to DefaultLanguageHighlighterColors.LINE_COMMENT)) + assertTrue(spans.contains("++++++++++" to DefaultLanguageHighlighterColors.STRING)) + assertTrue(spans.contains("----" to DefaultLanguageHighlighterColors.LINE_COMMENT)) + assertFalse(editor.settings.isUseSoftWraps) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, pane.horizontalScrollBarPolicy) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, editor.scrollPane.horizontalScrollBarPolicy) + } + + fun `test ansi stderr aliases render terminal plain text`() { + listOf("ansi-stderr", "terminal-error", "shell-error").forEach { lang -> + view.set("```$lang\nboom\n```") + val editor = editors().single().getEditor(true)!! + val expected = ConsoleViewContentType.getConsoleViewType(ProcessOutputTypes.STDERR).attributesKey + + assertSame(PlainTextFileType.INSTANCE, editors().single().fileType) + assertEquals("boom", editors().single().text) + assertEquals(expected, editor.markupModel.allHighlighters.single().textAttributesKey) + } + } + + fun `test terminal block updates retained editor without soft wraps`() { + view.set("```ansi-stdout\none\n```") + val pane = scrolls().single() + val field = editors().single() + val editor = field.getEditor(true)!! + + view.set("```ansi-stdout\ntwo\n```") + + assertSame(pane, scrolls().single()) + assertSame(field, editors().single()) + assertEquals("two", field.text) + assertFalse(editor.settings.isUseSoftWraps) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, pane.horizontalScrollBarPolicy) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, editor.scrollPane.horizontalScrollBarPolicy) + } + + fun `test terminal and source plain text blocks are incompatible`() { + view.set("```text\none\n```") + val source = editors().single().getEditor(true)!! + + view.set("```ansi-stdout\none\n```") + drainEdt() + + assertTrue(source.isDisposed) + assertEquals("one", editors().single().text) + } + + fun `test ansi and shell output blocks are incompatible`() { + view.set("```ansi-stdout\none\n```") + val ansi = editors().single().getEditor(true)!! + + view.set("```shell-output\none\n```") + drainEdt() + + assertTrue(ansi.isDisposed) + assertEquals("one", editors().single().text) + } + fun `test fenced code block width is bounded and boxed`() { view.set("```kotlin\n${"x".repeat(500)}\n```") val pane = scrolls().single() From 9eddaf17126a63822307e9a52d9a32794eca5176 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 14 Jun 2026 13:46:50 -0400 Subject: [PATCH 13/16] fix(jetbrains): highlight shell tool commands --- .../highlight-jetbrains-shell-commands.md | 5 ++++ .../session/views/tool/ShellToolView.kt | 2 +- .../client/ui/md/hybrid/MdLanguage.kt | 3 ++- .../client/ui/md/hybrid/MdShellHighlight.kt | 23 ++++++++++++++++ .../client/ui/md/hybrid/MdViewHybrid.kt | 3 ++- .../client/session/views/ShellToolViewTest.kt | 26 ++++++++++++++++--- .../kilocode/client/ui/md/MdViewHybridTest.kt | 18 +++++++++++++ 7 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 .changeset/highlight-jetbrains-shell-commands.md diff --git a/.changeset/highlight-jetbrains-shell-commands.md b/.changeset/highlight-jetbrains-shell-commands.md new file mode 100644 index 00000000000..394ac4ea35e --- /dev/null +++ b/.changeset/highlight-jetbrains-shell-commands.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Highlight shell tool commands in JetBrains chat transcripts. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt index 80373d20e74..464baef4afe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt @@ -276,7 +276,7 @@ private data class ShellContent( val body: String = listOf(command, output, error).filter { it.isNotBlank() }.joinToString("\n\n") val markdown: String = buildString { - section(KiloBundle.message("session.part.tool.shell.command"), command, "shell") + section(KiloBundle.message("session.part.tool.shell.command"), command, "shell-command") section(KiloBundle.message("session.part.tool.shell.output"), rawOutput, outputLang(rawOutput)) section(KiloBundle.message("session.part.tool.shell.error"), rawError, "ansi-stderr") } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdLanguage.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdLanguage.kt index 630b38a9b3d..1c90651229a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdLanguage.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdLanguage.kt @@ -12,7 +12,7 @@ internal sealed class Kind { internal enum class Stream { Stdout, Stderr } -internal enum class Mode { Ansi, Shell } +internal enum class Mode { Ansi, Shell, Command } internal object MdLanguage { private val terms = mapOf( @@ -20,6 +20,7 @@ internal object MdLanguage { "ansi-stdout" to Kind.Terminal(Stream.Stdout, Mode.Ansi), "terminal" to Kind.Terminal(Stream.Stdout, Mode.Ansi), "terminal-output" to Kind.Terminal(Stream.Stdout, Mode.Ansi), + "shell-command" to Kind.Terminal(Stream.Stdout, Mode.Command), "shell-output" to Kind.Terminal(Stream.Stdout, Mode.Shell), "ansi-stderr" to Kind.Terminal(Stream.Stderr, Mode.Ansi), "terminal-error" to Kind.Terminal(Stream.Stderr, Mode.Ansi), diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdShellHighlight.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdShellHighlight.kt index 9beea14c70e..e1754aaf63a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdShellHighlight.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdShellHighlight.kt @@ -19,6 +19,10 @@ internal object MdShellHighlight { private val deletions = Regex("\\b\\d+ deletions?\\(-\\)") private val meta = Regex("(?m)^<(?:shell_metadata|/shell_metadata)>$") private val cut = Regex("(?m)^\\.\\.\\.output truncated\\.\\.\\.$") + private val cmd = Regex("(?m)(^|[|&;]\\s*)([A-Za-z_./~][A-Za-z0-9_./~+-]*)") + private val flag = Regex("(?() @@ -37,6 +41,8 @@ internal object MdShellHighlight { return ShellDisplay(display, ranges(display)) } + fun command(text: String) = ShellDisplay(text, commandRanges(text)) + fun ranges(text: String): List = buildList { fun add(regex: Regex, key: TextAttributesKey) { regex.findAll(text).forEach { match -> @@ -53,4 +59,21 @@ internal object MdShellHighlight { add(meta, DefaultLanguageHighlighterColors.DOC_COMMENT) add(cut, DefaultLanguageHighlighterColors.KEYWORD) } + + private fun commandRanges(text: String): List = buildList { + cmd.findAll(text).forEach { match -> + val group = match.groups[2] ?: return@forEach + add(ShellRange(group.range.first, group.range.last + 1, DefaultLanguageHighlighterColors.FUNCTION_CALL)) + } + flag.findAll(text).forEach { match -> + add(ShellRange(match.range.first, match.range.last + 1, DefaultLanguageHighlighterColors.KEYWORD)) + } + string.findAll(text).forEach { match -> + add(ShellRange(match.range.first, match.range.last + 1, DefaultLanguageHighlighterColors.STRING)) + } + env.findAll(text).forEach { match -> + val group = match.groups[2] ?: return@forEach + add(ShellRange(group.range.first, group.range.last + 1, DefaultLanguageHighlighterColors.STATIC_FIELD)) + } + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt index eb786ddcff1..5a87b9e7e0a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt @@ -634,13 +634,14 @@ internal open class MdViewHybrid( private fun shellDisplay(term: Term, mode: Mode): ShellDisplay { if (mode == Mode.Shell) return MdShellHighlight.project(term.text) + if (mode == Mode.Command) return MdShellHighlight.command(term.text) return ShellDisplay(term.text, emptyList()) } private fun applyTerm(field: CodeField, term: Term, mode: Mode, display: ShellDisplay = shellDisplay(term, mode)) { val editor = field.getEditor(true) ?: return editor.markupModel.removeAllHighlighters() - if (mode == Mode.Shell) { + if (mode == Mode.Shell || mode == Mode.Command) { applyShell(field, display) return } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt index bf7e13d437a..00f8a5b9bd9 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt @@ -11,6 +11,7 @@ import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.ui.UiStyle import com.intellij.execution.process.ProcessOutputTypes import com.intellij.execution.ui.ConsoleViewContentType +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase @@ -41,7 +42,7 @@ class ShellToolViewTest : BasePlatformTestCase() { assertEquals("pwd", view.bodyText()) view.toggle() - assertEquals("**Command**\n\n```shell\npwd\n```", view.markdown()) + assertEquals("**Command**\n\n```shell-command\npwd\n```", view.markdown()) assertEquals(listOf("pwd"), view.codeTexts()) } @@ -69,7 +70,7 @@ class ShellToolViewTest : BasePlatformTestCase() { view.toggle() assertEquals( - "**Command**\n\n```shell\ngit status\n```\n\n**Output**\n\n```shell-output\nclean\n```", + "**Command**\n\n```shell-command\ngit status\n```\n\n**Output**\n\n```shell-output\nclean\n```", view.markdown(), ) assertEquals(listOf("git status", "clean"), view.codeTexts()) @@ -126,7 +127,7 @@ class ShellToolViewTest : BasePlatformTestCase() { view.toggle() assertEquals( - "**Command**\n\n```shell\nfail\n```\n\n**Error**\n\n```ansi-stderr\nboom\n```", + "**Command**\n\n```shell-command\nfail\n```\n\n**Error**\n\n```ansi-stderr\nboom\n```", view.markdown(), ) assertEquals(listOf("fail", "boom"), view.codeTexts()) @@ -264,6 +265,25 @@ class ShellToolViewTest : BasePlatformTestCase() { assertTrue(editor.markupModel.allHighlighters.size >= 4) } + fun `test command receives shell command highlighters`() { + val view = track(ShellToolView(tool().also { + it.input = mapOf("command" to "git log -30 --oneline --decorate") + })) + + view.toggle() + val field = view.codeEditors().single() + val editor = field.getEditor(true)!! + val spans = editor.markupModel.allHighlighters.map { + field.text.substring(it.startOffset, it.endOffset) to it.textAttributesKey + } + + assertTrue(view.markdown().contains("```shell-command\ngit log -30 --oneline --decorate\n```")) + assertTrue(spans.contains("git" to DefaultLanguageHighlighterColors.FUNCTION_CALL)) + assertTrue(spans.contains("-30" to DefaultLanguageHighlighterColors.KEYWORD)) + assertTrue(spans.contains("--oneline" to DefaultLanguageHighlighterColors.KEYWORD)) + assertTrue(spans.contains("--decorate" to DefaultLanguageHighlighterColors.KEYWORD)) + } + fun `test shell labels align with code text and code blocks use bottom border only`() { val view = track(ShellToolView(tool().also { it.input = mapOf("command" to "pwd") 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 0c57ba8d0d8..8d046526a62 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 @@ -479,6 +479,24 @@ class MdViewHybridTest : BasePlatformTestCase() { assertSame(type("sh"), editors().single().fileType) } + fun `test shell command code fence renders terminal semantic highlighters`() { + view.set("```shell-command\ngit log -30 --oneline --decorate\n```") + val field = editors().single() + val editor = field.getEditor(true)!! + val spans = editor.markupModel.allHighlighters.map { + field.text.substring(it.startOffset, it.endOffset) to it.textAttributesKey + } + + assertSame(PlainTextFileType.INSTANCE, field.fileType) + assertEquals("git log -30 --oneline --decorate", field.text) + assertTrue(spans.contains("git" to DefaultLanguageHighlighterColors.FUNCTION_CALL)) + assertTrue(spans.contains("-30" to DefaultLanguageHighlighterColors.KEYWORD)) + assertTrue(spans.contains("--oneline" to DefaultLanguageHighlighterColors.KEYWORD)) + assertTrue(spans.contains("--decorate" to DefaultLanguageHighlighterColors.KEYWORD)) + assertFalse(editor.settings.isUseSoftWraps) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, scrolls().single().horizontalScrollBarPolicy) + } + fun `test shell script aliases resolve shell file type`() { view.set("```shell script\necho hi\n```") From 90ceadd70ab26f68113a98ef974b08ac872c36d2 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 14 Jun 2026 19:11:41 -0400 Subject: [PATCH 14/16] fix(jetbrains): retain streamed terminal blocks --- .../client/ui/md/hybrid/MdViewHybrid.kt | 15 +++++---- .../client/session/SessionScrollTest.kt | 32 +++++++++++++++---- .../session/ui/SessionSelectionCopyTest.kt | 15 ++++++--- .../client/session/ui/SessionUiUpdateTest.kt | 13 ++++++-- .../kilocode/client/ui/md/MdViewHybridTest.kt | 16 ++++++++++ 5 files changed, 70 insertions(+), 21 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt index 5a87b9e7e0a..d27b2b62327 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt @@ -253,7 +253,7 @@ internal open class MdViewHybrid( if (disposed) return if (delta.isEmpty()) return val fence = openFence - val view = blocks.lastOrNull() as? CodeView + val view = blocks.lastOrNull() if (fence != null && view != null && clean(fence.char, delta)) { source.append(delta) view.grow(delta) @@ -880,6 +880,7 @@ internal open class MdViewHybrid( abstract fun compatible(desc: Desc): Boolean abstract fun update(desc: Desc) abstract fun style(opts: MdStyle) + open fun grow(delta: String) = Unit } private inner class HtmlView(desc: Desc.Html, private val pane: JBHtmlPane, disposable: Disposable) : @@ -920,7 +921,7 @@ internal open class MdViewHybrid( } } - fun grow(delta: String) { + override fun grow(delta: String) { val item = desc as Desc.Code val next = item.copy(text = item.text + delta) desc = next @@ -990,11 +991,6 @@ internal open class MdViewHybrid( applyTerm(view, term, kind.mode, value) } - fun grow(delta: String) { - val item = desc as Desc.Code - update(item.copy(text = item.text + delta)) - } - override fun style(opts: MdStyle) { styleCodePane(pane, opts) val view = pane.viewport.view as? CodeField ?: return @@ -1023,6 +1019,11 @@ internal open class MdViewHybrid( sizeCodePane(pane, view) applyTerm(view, term, kind.mode, value) } + + override fun grow(delta: String) { + val item = desc as Desc.Code + update(item.copy(text = item.text + delta)) + } } private inner class Visitor : AbstractVisitor() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt index 6aea6cecff2..48db71adf44 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt @@ -13,6 +13,7 @@ import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionStatusDto import ai.kilocode.rpc.dto.ToolRefDto import ai.kilocode.client.session.ui.prompt.PromptPanel +import ai.kilocode.client.session.views.tool.ShellToolView import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.plugin.KiloBundle import com.intellij.ui.EditorTextField @@ -330,14 +331,14 @@ class SessionScrollTest : SessionUiTestBase() { setBottom(bar) drainScroll() val view = toolView(mid, pid) - assertFalse(view.bodyVisible()) + assertFalse(bodyVisible(view)) val y = visibleY(view) val value = bar.value - view.toggle() + toggle(view) drainScroll() - assertTrue(view.bodyVisible()) + assertTrue(bodyVisible(view)) assertEquals(y, visibleY(view)) assertEquals(value, bar.value) } @@ -356,10 +357,10 @@ class SessionScrollTest : SessionUiTestBase() { drainScroll() val y = visibleY(view) - view.toggle() + toggle(view) drainScroll() - assertTrue(view.bodyVisible()) + assertTrue(bodyVisible(view)) assertEquals(y, visibleY(view)) assertTrue(jumpButton().isVisible) } @@ -1067,12 +1068,29 @@ class SessionScrollTest : SessionUiTestBase() { private inline fun option(label: String): T where T : AbstractButton = findAll(ui).first { it.actionCommand == label } - private fun toolView(mid: String, pid: String): ToolView { + private fun toolView(mid: String, pid: String): JComponent { val messages = find(ui) - return messages.findMessage(mid)?.part(pid) as? ToolView + val view = messages.findMessage(mid)?.part(pid) + return when (view) { + is ShellToolView -> view + is ToolView -> view + else -> null + } ?: error("missing tool $mid/$pid\n${messages.dumpDetailed()}") } + private fun bodyVisible(view: JComponent): Boolean = when (view) { + is ShellToolView -> view.bodyVisible() + is ToolView -> view.bodyVisible() + else -> false + } + + private fun toggle(view: JComponent) = when (view) { + is ShellToolView -> view.toggle() + is ToolView -> view.toggle() + else -> Unit + } + private fun visibleY(component: JComponent): Int = SwingUtilities.convertPoint(component, Point(0, 0), scrollComponent()).y diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt index a266bc91432..da2e4952e21 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.session.SessionUiTestBase +import ai.kilocode.client.session.views.tool.ShellToolView import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.PartDto @@ -73,20 +74,26 @@ class SessionSelectionCopyTest : SessionUiTestBase() { output = text, ), )) - for (view in toolViews(ui)) view.expand() + for (view in toolViews(ui)) expand(view) layout() return textComponent(text) } - private fun toolViews(root: Container): List { - val out = mutableListOf() - if (root is ToolView) out.add(root) + private fun toolViews(root: Container): List { + val out = mutableListOf() + if (root is ShellToolView || root is ToolView) out.add(root) for (child in root.components) { if (child is Container) out.addAll(toolViews(child)) } return out } + private fun expand(view: Container) = when (view) { + is ShellToolView -> view.expand() + is ToolView -> view.expand() + else -> false + } + private fun copyProvider(): CopyProvider? { val sink = CopySink() (ui as UiDataProvider).uiDataSnapshot(sink) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt index 880899b007e..14410599603 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt @@ -3,6 +3,8 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.views.TextView +import ai.kilocode.client.session.views.tool.ShellToolView +import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto @@ -77,14 +79,19 @@ class SessionUiUpdateTest : BasePlatformTestCase() { // ------ tool lifecycle ------ - fun `test tool state transitions are reflected in ToolView`() { + fun `test tool state transitions are reflected in tool view`() { model.upsertMessage(msg("a1", "assistant")) model.updateContent("a1", toolPart("t1", "a1", "bash", "pending")) model.updateContent("a1", toolPart("t1", "a1", "bash", "running")) model.updateContent("a1", toolPart("t1", "a1", "bash", "completed")) - val tv = panel.findMessage("a1")!!.part("t1") as ai.kilocode.client.session.views.tool.ToolView - assertFalse(tv.labelText().contains("Running")) + val view = panel.findMessage("a1")!!.part("t1") + val label = when (view) { + is ShellToolView -> view.labelText() + is ToolView -> view.labelText() + else -> error("unexpected tool view ${view?.javaClass?.name}") + } + assertFalse(label.contains("Running")) } fun `test read tool renders as ReadToolView`() { 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 8d046526a62..3613657c507 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 @@ -497,6 +497,22 @@ class MdViewHybridTest : BasePlatformTestCase() { assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, scrolls().single().horizontalScrollBarPolicy) } + fun `test streaming shell command fence preserves terminal editor component`() { + view.append("```shell-command\ngit") + val pane = scrolls().single() + val field = editors().single() + val editor = field.getEditor(true)!! + + view.append(" status --short\n") + + assertSame(pane, scrolls().single()) + assertSame(field, editors().single()) + assertEquals("git status --short", field.text) + assertTrue(editor.markupModel.allHighlighters.map { + field.text.substring(it.startOffset, it.endOffset) to it.textAttributesKey + }.contains("git" to DefaultLanguageHighlighterColors.FUNCTION_CALL)) + } + fun `test shell script aliases resolve shell file type`() { view.set("```shell script\necho hi\n```") From a2185f2335d9e8ec323163428dad4f6219ae1a44 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 14 Jun 2026 21:46:24 -0400 Subject: [PATCH 15/16] fix(jetbrains): align session transcript padding --- .../ai/kilocode/client/session/ui/style/SessionUiStyle.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 cd3e99b55f6..f4a0d34915b 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 @@ -17,7 +17,7 @@ object SessionUiStyle { /** Geometry for the transcript list and its scroll behavior. */ object SessionLayout { const val GAP = 3 - val InnerInsets = Insets(UiStyle.Gap.md(), UiStyle.Gap.sm(), UiStyle.Gap.sm(), UiStyle.Gap.sm()) + val InnerInsets = Insets(UiStyle.Gap.md(), UiStyle.Gap.md(), UiStyle.Gap.sm(), UiStyle.Gap.sm()) const val TRANSCRIPT_SCROLLBAR_PADDING = 10 const val USER_PROMPT_INDENT = 100 const val SCROLL_INCREMENT = 48 From 681b8090f367ae4c2d0f15c6e99b137271e11439 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 15 Jun 2026 12:12:43 -0400 Subject: [PATCH 16/16] fix(jetbrains): consolidate shell terminal parsing --- .../session/views/tool/ShellToolView.kt | 56 +---------------- .../client/ui/md/hybrid/MdLanguage.kt | 54 ++-------------- .../client/ui/md/hybrid/MdTerminal.kt | 62 ++++++++++++++----- .../client/session/views/ShellToolViewTest.kt | 9 +++ .../kilocode/client/ui/md/MdTerminalTest.kt | 26 ++++++++ .../kilocode/client/ui/md/MdViewHybridTest.kt | 8 +++ 6 files changed, 99 insertions(+), 116 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdTerminalTest.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt index 464baef4afe..7200420d4ea 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt @@ -12,6 +12,7 @@ import ai.kilocode.client.ui.md.MdCodeBlockBorder import ai.kilocode.client.ui.md.MdCodeBlockFactory import ai.kilocode.client.ui.md.MdCodeBlockOptions import ai.kilocode.client.ui.md.MdViewFactory +import ai.kilocode.client.ui.md.hybrid.MdTerminal import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.ui.EditorTextField @@ -282,7 +283,7 @@ private data class ShellContent( } } -private fun outputLang(text: String): String = if (ANSI.containsMatchIn(text)) "ansi-stdout" else "shell-output" +private fun outputLang(text: String): String = if (MdTerminal.hasAnsi(text)) "ansi-stdout" else "shell-output" private fun StringBuilder.section(title: String, text: String, lang: String) { if (text.isBlank()) return @@ -300,55 +301,4 @@ private fun fence(text: String): String { return "`".repeat(maxOf(3, size + 1)) } -private fun clean(text: String): String = strip(normalize(text)) - -private fun strip(text: String): String = ANSI.replace(text, "") - -private fun normalize(text: String): String = lines(text.replace("\r\n", "\n")).joinToString("\n") { line -> - val frame = if ('\r' !in line) line else frames(line).lastOrNull { it.isNotEmpty() } ?: "" - backspace(frame) -} - -private fun backspace(text: String): String { - val out = StringBuilder() - var idx = 0 - while (idx < text.length) { - val ch = text[idx++] - if (ch == '\b') { - if (out.isNotEmpty()) out.deleteCharAt(out.length - 1) - continue - } - out.append(ch) - } - return out.toString() -} - -private fun lines(text: String): List { - val list = mutableListOf() - var start = 0 - while (true) { - val index = text.indexOf('\n', start) - if (index < 0) { - list.add(text.substring(start)) - return list - } - list.add(text.substring(start, index)) - start = index + 1 - } -} - -private fun frames(text: String): List { - val list = mutableListOf() - var start = 0 - while (true) { - val index = text.indexOf('\r', start) - if (index < 0) { - list.add(text.substring(start)) - return list - } - list.add(text.substring(start, index)) - start = index + 1 - } -} - -private val ANSI = Regex("\\u001B\\[[0-?]*[ -/]*[@-~]") +private fun clean(text: String): String = MdTerminal.strip(MdTerminal.reduce(text, keepSgr = false)) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdLanguage.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdLanguage.kt index 1c90651229a..4acb36e8d12 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdLanguage.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdLanguage.kt @@ -15,6 +15,7 @@ internal enum class Stream { Stdout, Stderr } internal enum class Mode { Ansi, Shell, Command } internal object MdLanguage { + /** Internal terminal fence tags produced by ShellToolView shell transcript markdown. */ private val terms = mapOf( "ansi" to Kind.Terminal(Stream.Stdout, Mode.Ansi), "ansi-stdout" to Kind.Terminal(Stream.Stdout, Mode.Ansi), @@ -27,79 +28,34 @@ internal object MdLanguage { "shell-error" to Kind.Terminal(Stream.Stderr, Mode.Ansi), ) + // Alias layer only; canonical extensions fall through to FileTypeRegistry below. private val files = mapOf( - "kt" to "kt", "kotlin" to "kt", - "js" to "js", "javascript" to "js", - "jsx" to "jsx", - "ts" to "ts", "typescript" to "ts", - "tsx" to "tsx", - "java" to "java", - "py" to "py", "python" to "py", - "sh" to "sh", "bash" to "sh", "shell" to "sh", "zsh" to "sh", "shellscript" to "sh", - "json" to "json", - "xml" to "xml", - "html" to "html", - "css" to "css", - "md" to "md", "markdown" to "md", - "yaml" to "yaml", "yml" to "yaml", - "toml" to "toml", - "go" to "go", "golang" to "go", - "rs" to "rs", "rust" to "rs", - "rb" to "rb", "ruby" to "rb", - "php" to "php", - "swift" to "swift", - "scala" to "scala", - "sql" to "sql", - "dockerfile" to "dockerfile", "docker" to "dockerfile", - "gradle" to "gradle", - "kts" to "kts", - "c" to "c", - "h" to "h", - "cpp" to "cpp", "c++" to "cpp", - "cc" to "cc", - "cxx" to "cxx", - "hpp" to "hpp", "h++" to "hpp", - "cs" to "cs", "csharp" to "cs", "c#" to "cs", - "fs" to "fs", "fsharp" to "fs", "f#" to "fs", - "ps1" to "ps1", "powershell" to "ps1", "pwsh" to "ps1", - "bat" to "bat", "batch" to "bat", "cmd" to "bat", - "makefile" to "makefile", "make" to "makefile", "terraform" to "tf", - "tf" to "tf", - "hcl" to "hcl", - "vue" to "vue", - "svelte" to "svelte", - "graphql" to "graphql", - "proto" to "proto", - "ini" to "ini", - "properties" to "properties", - "diff" to "diff", - "patch" to "patch", ) fun kind(lang: String?): Kind { @@ -108,8 +64,10 @@ internal object MdLanguage { if (key == "shell script") return Kind.Source(type("sh")) val single = key.substringBefore(' ') terms[single]?.let { return it } - val ext = files[key] ?: files[single] ?: return Kind.Source(PlainTextFileType.INSTANCE) - return Kind.Source(type(ext)) + files[key]?.let { return Kind.Source(type(it)) } + files[single]?.let { return Kind.Source(type(it)) } + type(key).takeIf { it != PlainTextFileType.INSTANCE }?.let { return Kind.Source(it) } + return Kind.Source(type(single)) } private fun type(ext: String): FileType { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdTerminal.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdTerminal.kt index dba405690ae..2422c9b9810 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdTerminal.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdTerminal.kt @@ -9,6 +9,8 @@ internal data class Range(val start: Int, val end: Int, val key: Key<*>) internal data class Term(val text: String, val ranges: List) internal object MdTerminal { + private val ansi = Regex("\\u001B\\[[0-?]*[ -/]*[@-~]") + fun decode(text: String, stream: Stream): Term { val out = StringBuilder() val ranges = mutableListOf() @@ -16,7 +18,8 @@ internal object MdTerminal { Stream.Stdout -> ProcessOutputTypes.STDOUT Stream.Stderr -> ProcessOutputTypes.STDERR } - AnsiEscapeDecoder().escapeText(controls(text), key) { chunk, attrs -> + // AnsiEscapeDecoder handles SGR coloring; full terminal emulation is too heavy for inline transcripts. + AnsiEscapeDecoder().escapeText(reduce(text, keepSgr = true), key) { chunk, attrs -> val start = out.length out.append(chunk) val end = out.length @@ -25,10 +28,44 @@ internal object MdTerminal { return Term(out.toString().trimEnd('\n'), ranges) } - private fun controls(text: String): String { + fun split(text: String, delim: Char): List { + val list = mutableListOf() + var start = 0 + while (true) { + val index = text.indexOf(delim, start) + if (index < 0) { + list.add(text.substring(start)) + return list + } + list.add(text.substring(start, index)) + start = index + 1 + } + } + + fun backspace(text: String): String { + val out = StringBuilder() + var idx = 0 + while (idx < text.length) { + val ch = text[idx++] + if (ch == '\b') { + if (out.isNotEmpty()) out.deleteCharAt(out.length - 1) + continue + } + out.append(ch) + } + return out.toString() + } + + fun reduce(text: String, keepSgr: Boolean): String = split(text.replace("\r\n", "\n"), '\n') + .joinToString("\n") { controls(it, keepSgr) } + + fun strip(text: String): String = ansi.replace(text, "") + + fun hasAnsi(text: String): Boolean = ansi.containsMatchIn(text) + + private fun controls(text: String, keepSgr: Boolean): String { val out = StringBuilder() - val line = StringBuilder() - val src = text.replace("\r\n", "\n") + val src = text var idx = 0 fun esc(): String? { if (src[idx] != '\u001B') return null @@ -44,26 +81,21 @@ internal object MdTerminal { } val seq = src.substring(idx, end + 1) idx = end + 1 - return if (seq.endsWith('m')) seq else "" + return if (keepSgr && seq.endsWith('m')) seq else "" } while (idx < src.length) { val seq = esc() if (seq != null) { - line.append(seq) + out.append(seq) continue } when (val ch = src[idx++]) { - '\r' -> line.clear() - '\n' -> { - out.append(line).append('\n') - line.clear() - } - '\b' -> if (line.isNotEmpty()) line.deleteCharAt(line.length - 1) - '\t' -> line.append(ch) - else -> if (!ch.isISOControl()) line.append(ch) + '\r' -> out.clear() + '\b' -> if (out.isNotEmpty()) out.deleteCharAt(out.length - 1) + '\t' -> out.append(ch) + else -> if (!ch.isISOControl()) out.append(ch) } } - out.append(line) return out.toString() } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt index 00f8a5b9bd9..8046cfe3d35 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt @@ -107,6 +107,15 @@ class ShellToolViewTest : BasePlatformTestCase() { assertEquals(listOf("ad"), view.codeTexts()) } + fun `test clean output delegates terminal reducer`() { + val view = track(ShellToolView(tool().also { + it.output = "\u001B[31mspin 1\u001B[0m\r\u001B[32mspin 2\u001B[0m\nabc\bd\u001B[K" + })) + + assertEquals("spin 2\nabd", view.outputText()) + assertEquals("spin 2\nabd", view.bodyText()) + } + fun `test output backticks use longer markdown fence`() { val view = track(ShellToolView(tool().also { it.output = "before\n```\nafter" })) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdTerminalTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdTerminalTest.kt new file mode 100644 index 00000000000..c7766d764b1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdTerminalTest.kt @@ -0,0 +1,26 @@ +package ai.kilocode.client.ui.md + +import ai.kilocode.client.ui.md.hybrid.MdTerminal +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class MdTerminalTest : BasePlatformTestCase() { + fun `test split preserves trailing empty segment`() { + assertEquals(listOf("one", "two", ""), MdTerminal.split("one\ntwo\n", '\n')) + } + + fun `test reduce collapses carriage frames and backspaces`() { + assertEquals("done\nab", MdTerminal.reduce("step 1\rstep 2\rdone\nabc\b", keepSgr = false)) + } + + fun `test reduce keeps only sgr escapes when requested`() { + val text = "\u001B[32mgreen\u001B[0m\u001B[K" + + assertEquals("\u001B[32mgreen\u001B[0m", MdTerminal.reduce(text, keepSgr = true)) + assertEquals("green", MdTerminal.reduce(text, keepSgr = false)) + } + + fun `test strip removes ansi escapes`() { + assertEquals("green", MdTerminal.strip("\u001B[32mgreen\u001B[0m")) + assertTrue(MdTerminal.hasAnsi("\u001B[32mgreen\u001B[0m")) + } +} 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 3613657c507..6a6a4ce3ad5 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 @@ -533,6 +533,14 @@ class MdViewHybridTest : BasePlatformTestCase() { view.set("```yaml\nvalue: 1\n```") assertSame(type("yaml"), editors().single().fileType) + + view.set("```golang\nfmt.Println(1)\n```") + + assertSame(type("go"), editors().single().fileType) + + view.set("```pwsh\nWrite-Host hi\n```") + + assertSame(type("ps1"), editors().single().fileType) } fun `test unknown fenced code language uses plain text`() {