From 3b762484f37e36510efb430366071ad0f2a31e93 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 5 Jun 2026 11:28:42 -0400 Subject: [PATCH 01/26] fix(jetbrains): hide markdown separators --- .changeset/quiet-jetbrains-separators.md | 5 + .../ai/kilocode/client/ui/md/MdViewHybrid.kt | 29 +++++- .../kilocode/client/ui/md/MdViewHybridTest.kt | 94 +++++++++++++++++++ 3 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 .changeset/quiet-jetbrains-separators.md diff --git a/.changeset/quiet-jetbrains-separators.md b/.changeset/quiet-jetbrains-separators.md new file mode 100644 index 00000000000..c5a60098907 --- /dev/null +++ b/.changeset/quiet-jetbrains-separators.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Hide thematic separator lines in JetBrains chat markdown while preserving surrounding prose and code blocks. 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 b5579cdd4fe..30b0c6c94df 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 @@ -27,6 +27,7 @@ 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 @@ -682,8 +683,14 @@ internal class MdViewHybrid( fun flush() { if (md.isEmpty()) return val doc = parser.parse(md.toString()) - html.append(renderer.render(doc)) - blocks.addAll(collect(doc)) + 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() } @@ -896,27 +903,39 @@ internal class MdViewHybrid( 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 FencedCodeBlock || child is IndentedCodeBlock) child.accept(this) - if (child is Block && child !is FencedCodeBlock && child !is IndentedCodeBlock) { - blocks.add(Desc.Html(renderer.render(child))) + 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/ui/md/MdViewHybridTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt index 7d49a24661a..104b5ab88d3 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 @@ -14,6 +14,7 @@ import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.UIUtil import java.awt.BorderLayout import java.awt.Color +import javax.swing.Box import javax.swing.JPanel import javax.swing.ScrollPaneConstants @@ -130,6 +131,97 @@ class MdViewHybridTest : BasePlatformTestCase() { assertTrue("streamed editor should not be clipped vertically", editor.height >= editor.preferredSize.height) } + fun `test consecutive prose blocks coalesce into one html pane`() { + view.set("# Title\n\npara one\n\n- a\n- b") + + val pane = htmls().single() + assertTrue(pane.text.contains("

")) + assertTrue(pane.text.contains("

")) + assertTrue(pane.text.contains("

    ")) + assertTrue(pane.text.contains("
  • ")) + } + + fun `test code block separates surrounding prose runs`() { + view.set("intro\n\n```kotlin\nval x = 1\n```\n\noutro") + + val html = htmls() + assertEquals(2, html.size) + assertEquals(1, scrolls().size) + assertTrue(html[0].text.contains("intro")) + assertTrue(html[1].text.contains("outro")) + } + + fun `test indented code block separates prose and renders as editor`() { + view.set("before\n\n code line\n\nafter") + + val html = htmls() + assertEquals(2, html.size) + assertEquals(1, editors().size) + assertTrue(html[0].text.contains("before")) + assertEquals("code line", editors().single().text) + assertTrue(html[1].text.contains("after")) + } + + fun `test coalesced prose has no inter block struts`() { + view.set("first\n\nsecond\n\n- third") + + assertEquals(1, htmls().size) + assertTrue(struts().isEmpty()) + } + + fun `test thematic break after code block is filtered`() { + view.set("```kotlin\nval x = 1\n```\n\n---\n\n# Next") + + val pane = htmls().single() + + assertEquals(1, scrolls().size) + assertTrue(pane.text.contains("

    ")) + assertFalse(pane.text.contains("")) + assertFalse(pane.text.contains(" = (view.component as JPanel).components.filterIsInstance() + private fun struts(): List = (view.component as JPanel).components.filterIsInstance() + private fun editors(): List = scrolls().mapNotNull { it.viewport.view as? EditorTextField } private fun type(ext: String): FileType { From 01f28861900d4794d6329821f0c9f5c9efdedae3 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 5 Jun 2026 12:03:35 -0400 Subject: [PATCH 02/26] fix(jetbrains): speed up session mouse wheel scrolling --- .changeset/stellar-wolf.md | 5 +++++ .../client/session/ui/style/SessionUiStyle.kt | 2 +- .../client/session/SessionScrollTest.kt | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 .changeset/stellar-wolf.md diff --git a/.changeset/stellar-wolf.md b/.changeset/stellar-wolf.md new file mode 100644 index 00000000000..5248ade0cf4 --- /dev/null +++ b/.changeset/stellar-wolf.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve mouse wheel scrolling speed in the JetBrains session view. 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 83fc0fa2006..90403809cfd 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 @@ -14,7 +14,7 @@ object SessionUiStyle { const val GAP = 4 const val TRANSCRIPT_PADDING = 12 const val USER_PROMPT_INDENT = 100 - const val SCROLL_INCREMENT = 16 + const val SCROLL_INCREMENT = 48 } /** Shared tokens for individual transcript views and session views. */ 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 a5c6fbcacd3..fbac305b5e7 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 @@ -1,6 +1,7 @@ package ai.kilocode.client.session import ai.kilocode.client.session.ui.SessionMessageListPanel +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.MessageErrorDto import ai.kilocode.rpc.dto.PermissionRequestDto @@ -17,6 +18,8 @@ import com.intellij.util.ui.JBUI import java.awt.Container import javax.swing.AbstractButton import javax.swing.JButton +import javax.swing.Scrollable +import javax.swing.SwingConstants import javax.swing.JTextArea import kotlinx.coroutines.CompletableDeferred @@ -161,6 +164,22 @@ class SessionScrollTest : SessionUiTestBase() { assertFalse(jumpButton().isVisible) } + fun `test physical mouse wheel uses accelerated transcript unit distance`() { + showMessages() + fillTranscript(48) + val bar = scrollBar() + setValue(bar, 0) + drainScroll() + val amount = 3 + val expected = JBUI.scale(SessionUiStyle.SessionLayout.SCROLL_INCREMENT * amount) + assertTrue("bottom=${bottom(bar)} expected=$expected", bottom(bar) >= expected * 2) + + val view = scrollView() as Scrollable + val unit = view.getScrollableUnitIncrement(scrollComponent().visibleRect, SwingConstants.VERTICAL, 1) + + assertEquals(expected, unit * amount) + } + fun `test part delta follows bottom after height growth`() { showMessages() fillTranscript(24) From cd80323c0f478056c6903cfe231df87ce3cd9427 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 5 Jun 2026 12:16:30 -0400 Subject: [PATCH 03/26] test(jetbrains): add markdown stress leak coverage --- packages/kilo-jetbrains/AGENTS.md | 16 ++ .../client/ui/md/MdViewHybridStressTest.kt | 180 ++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridStressTest.kt diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index d9a13a7427e..3bb2c6abab0 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -268,6 +268,22 @@ Tests for retained Swing components should assert: - `update(model)` changes existing labels/body text without duplicating components. - Updates while collapsed do not eagerly create lazy bodies. - No-op updates, empty deltas, repeated hover values, and toggling non-expandable cards do not repaint/revalidate the whole view. +- Streaming/rebuilding surfaces additionally require stress + leak tests (see below). + +### Stress and Leak Tests for Streaming UI + +Session/transcript UI that streams updates or rebuilds its component tree (markdown +views, code blocks, transcript parts, collapsible cards) must ship stress + leak tests in +addition to behavior tests. These tests must: + +- Drive many updates (hundreds of streamed deltas or `set` cycles) through the public API. +- Assert that retained component instances stay identical across updates (`assertSame`). +- Assert the component count stays bounded — no growth per update. +- Assert disposable-backed resources return to baseline after churn + clear/dispose. + For code editors, compare `EditorFactory.getInstance().allEditors.size` against a + baseline captured before the loop. + +See `MdViewHybridStressTest` for the reference pattern. ### Platform Components and Utilities diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridStressTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridStressTest.kt new file mode 100644 index 00000000000..e9d40248cf6 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridStressTest.kt @@ -0,0 +1,180 @@ +package ai.kilocode.client.ui.md + +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.util.Disposer +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.UIUtil +import javax.swing.Box +import javax.swing.JPanel + +/** + * Stress + leak coverage for the hybrid markdown renderer. + * + * These tests drive many updates through the public [MdView] API and inspect the real + * Swing component tree to prove that: + * - retained component instances survive heavy streaming, + * - the component tree stays bounded (no per-update growth), + * - editors created for code blocks are released (no leak) after churn + clear. + */ +@Suppress("UnstableApiUsage") +class MdViewHybridStressTest : BasePlatformTestCase() { + private lateinit var view: MdView + private var disposed = false + + override fun setUp() { + super.setUp() + view = MdViewFactory.hybrid() + disposed = false + } + + override fun tearDown() { + try { + if (this::view.isInitialized && !disposed) Disposer.dispose(view) + } finally { + super.tearDown() + } + } + + fun `test streaming a large mixed document token by token stays consistent`() { + val doc = buildString { + append("# Heading\n\n") + append("Intro paragraph with **bold** text.\n\n") + append("- one\n- two\n- three\n\n") + append("```kotlin\nval x = 1\n```\n\n") + append("middle prose paragraph\n\n") + append("```java\nclass A {}\n```\n\n") + append("closing prose") + } + + for (token in doc.chunked(3)) view.append(token) + + assertEquals(doc, view.markdown()) + assertEquals(3, htmls().size) + assertEquals(2, scrolls().size) + assertEquals(4, struts().size) // blocks - 1 + assertEquals(9, panel().componentCount) // 5 blocks + 4 struts = 2*5 - 1 + + val html = view.html() + assertTrue(html.contains("

    ")) + assertTrue(html.contains("
      ")) + assertTrue(html.contains("class A")) + assertFalse(html.contains(" view.append(" more$i") } + + assertSame(intro, htmls().first()) + assertSame(tail, htmls().last()) + assertSame(editor, editors().single()) + assertEquals(2, htmls().size) + assertEquals(1, scrolls().size) + assertFalse(editor.getEditor(true)!!.isDisposed) + assertTrue(view.markdown().contains("more99")) + } + + fun `test repeated same structure set reuses single editor and stays bounded`() { + repeat(150) { i -> + view.set("```kotlin\nval x = $i\n```") + editors().single().getEditor(true) + } + val editor = editors().single() + + repeat(50) { i -> view.set("```kotlin\nval y = $i\n```") } + + assertSame(editor, editors().single()) + assertEquals(1, scrolls().size) + assertEquals(1, panel().componentCount) + assertEquals("val y = 49", editor.text) + } + + fun `test structural churn releases every editor after clear`() { + val base = EditorFactory.getInstance().allEditors.size + + repeat(60) { i -> + view.set("```kotlin\nval x = $i\n```") + editors().single().getEditor(true) + view.set("```java\nclass A$i {}\n```") + editors().single().getEditor(true) + view.set("plain prose $i") + } + + view.clear() + drainEdt() + + assertTrue(scrolls().isEmpty()) + assertTrue(htmls().isEmpty()) + assertEquals(0, panel().componentCount) + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + + fun `test streaming code body reuses one editor and keeps html in sync`() { + view.append("```java\n") + val pane = scrolls().single() + val editor = editors().single() + + val body = StringBuilder() + repeat(100) { i -> + val line = "void m$i() {}\n" + body.append(line) + view.append(line) + } + + assertSame(pane, scrolls().single()) + assertSame(editor, editors().single()) + assertEquals(body.toString().trimEnd('\n'), editor.text) + assertTrue(view.html().contains("void m0()")) + assertTrue(view.html().contains("void m99()")) + + view.append("```") + + assertSame(pane, scrolls().single()) + assertSame(editor, editors().single()) + } + + fun `test style changes during streaming do not rebuild components`() { + view.append("intro\n\n```kotlin\nval x = 1\n```\n\n") + val intro = htmls().first() + val editor = editors().single() + editor.getEditor(true) + val styled = SessionEditorStyle.create(family = "Courier New", size = 18) + val current = SessionEditorStyle.current() + + repeat(50) { i -> + view.append("line $i ") + view.applyStyle(if (i % 2 == 0) styled else current) + if (i % 5 == 0) view.resetStyles() + } + + assertSame(intro, htmls().first()) + assertSame(editor, editors().single()) + assertFalse(editor.getEditor(true)!!.isDisposed) + assertEquals(2, htmls().size) + assertEquals(1, scrolls().size) + assertTrue(view.markdown().contains("line 49")) + } + + private fun panel(): JPanel = view.component as JPanel + + private fun scrolls(): List = panel().components.filterIsInstance() + + private fun htmls(): List = panel().components.filterIsInstance() + + private fun struts(): List = panel().components.filterIsInstance() + + private fun editors(): List = scrolls().mapNotNull { it.viewport.view as? EditorTextField } + + private fun drainEdt() { + UIUtil.dispatchAllInvocationEvents() + } +} From d505677d88816cf528b64392e23b7ccdddf98a4a Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 5 Jun 2026 12:45:02 -0400 Subject: [PATCH 04/26] fix(jetbrains): prevent transcript scrollbar overlap --- .changeset/gentle-canyon.md | 5 +++++ .../ai/kilocode/client/session/ui/SessionMessageListPanel.kt | 2 +- .../ai/kilocode/client/session/ui/style/SessionUiStyle.kt | 1 + .../kotlin/ai/kilocode/client/session/SessionScrollTest.kt | 4 +++- 4 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 .changeset/gentle-canyon.md diff --git a/.changeset/gentle-canyon.md b/.changeset/gentle-canyon.md new file mode 100644 index 00000000000..863dfb8c229 --- /dev/null +++ b/.changeset/gentle-canyon.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Prevent the JetBrains session scrollbar from covering transcript content. 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 d2506bb67b0..ec87a14abe7 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 @@ -57,7 +57,7 @@ class SessionMessageListPanel( SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING, SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING, SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING, - SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING, + SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING + 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 90403809cfd..3befb98eb55 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 @@ -13,6 +13,7 @@ object SessionUiStyle { object SessionLayout { const val GAP = 4 const val TRANSCRIPT_PADDING = 12 + 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/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt index fbac305b5e7..3422c423fde 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.ToolRefDto import ai.kilocode.client.session.ui.prompt.PromptPanel import ai.kilocode.client.plugin.KiloBundle import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBScrollPane import com.intellij.ui.components.JBRadioButton import com.intellij.util.ui.JBUI import java.awt.Container @@ -495,11 +496,12 @@ class SessionScrollTest : SessionUiTestBase() { assertBottom(scrollBar()) } - fun `test scroll owns the session viewport`() { + fun `test scroll owns the session viewport without overlapping content`() { settle() assertSame(scrollComponent(), scrollView()?.parent?.parent) assertFalse(scrollView() is SessionMessageListPanel) + assertFalse((scrollComponent() as JBScrollPane).isOverlappingScrollBar) } // ------ question/login-required autoscroll ------ From 256fe3a819eefcebf4c40c8fca0be83b9361167f Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 5 Jun 2026 17:56:57 -0400 Subject: [PATCH 05/26] fix(jetbrains): improve streaming reasoning display --- .changeset/fix-jetbrains-reasoning.md | 5 ++ .../client/session/ui/style/SessionUiStyle.kt | 2 + .../client/session/views/MessageView.kt | 78 +++++++++++++++---- .../client/session/views/ReasoningView.kt | 54 +++++++++++-- .../client/session/views/ReasoningViewTest.kt | 61 +++++++++++---- .../client/session/views/TurnViewTest.kt | 55 ++++++++++++- 6 files changed, 221 insertions(+), 34 deletions(-) create mode 100644 .changeset/fix-jetbrains-reasoning.md diff --git a/.changeset/fix-jetbrains-reasoning.md b/.changeset/fix-jetbrains-reasoning.md new file mode 100644 index 00000000000..5356a2175c8 --- /dev/null +++ b/.changeset/fix-jetbrains-reasoning.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Improve JetBrains reasoning blocks so active reasoning opens while streaming, empty blocks stay hidden, and adjacent reasoning renders as one block. 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 83fc0fa2006..25b4c2f2e11 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 @@ -55,6 +55,8 @@ object SessionUiStyle { fun topOutline(): Border = JBUI.Borders.customLineTop(line()) + fun leftOutline(): Border = JBUI.Borders.customLine(line(), 0, 1, 0, 0) + /** Prompt input dimensions and chrome inside the session view. */ object Prompt { const val EDITOR_LINES = 3 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 5b71c23b5e6..8075b25e918 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 @@ -2,6 +2,7 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Message +import ai.kilocode.client.session.model.Reasoning import ai.kilocode.client.session.model.StepFinish import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolCallRef @@ -48,6 +49,8 @@ class MessageView( get() = if (role == SessionUiStyle.View.Message.USER_ROLE) SessionView.Kind.UserPrompt else SessionView.Kind.Default private val parts = LinkedHashMap() + private val aliases = LinkedHashMap() + private val sources = LinkedHashMap() private var hidden: ToolCallRef? = null init { @@ -59,10 +62,7 @@ class MessageView( for ((_, content) in msg.parts) { if (content is StepFinish) continue if (isHidden(content)) continue - val view = view(content) - view.applyStyle(style) - parts[content.id] = view - add(view) + addPart(content) } } @@ -81,7 +81,9 @@ class MessageView( if (content is StepFinish) return if (isHidden(content)) { // Remove any stale view for this content so it disappears when suppressed - val stale = parts.remove(content.id) + val id = aliases.remove(content.id) + sources.remove(content.id) + val stale = if (id == null) parts.remove(content.id) else null if (stale != null) { remove(stale) Disposer.dispose(stale) @@ -90,6 +92,16 @@ class MessageView( } return } + val id = aliases[content.id] + if (id != null && content is Reasoning) { + updateAlias(content, id) + refresh() + return + } + if (id != null) { + aliases.remove(content.id) + sources.remove(content.id) + } val existing = parts[content.id] if (existing != null) { if (ViewFactory.shouldReplace(existing, content)) { @@ -100,17 +112,48 @@ class MessageView( refresh() return } + addPart(content) + syncBorder() + refresh() + } + + private fun addPart(content: Content) { + if (content is Reasoning) { + val previous = parts.values.lastOrNull() + if (previous is ReasoningView) { + aliases[content.id] = previous.contentId + sources[content.id] = content.content.toString() + previous.update(merged(previous, content, content.content.toString())) + return + } + } val view = view(content) view.applyStyle(style) parts[content.id] = view add(view) - syncBorder() - refresh() + } + + private fun updateAlias(content: Reasoning, id: String) { + val view = parts[id] as? ReasoningView ?: return + val prev = sources[content.id].orEmpty() + val next = content.content.toString() + val delta = if (next.startsWith(prev)) next.removePrefix(prev) else next + sources[content.id] = next + if (delta.isEmpty()) return + view.update(merged(view, content, delta)) + } + + private fun merged(view: ReasoningView, content: Reasoning, delta: String) = Reasoning(view.contentId).also { + it.done = content.done + it.content.append(view.markdown()) + it.content.append(delta) } private fun replacePart(content: Content, existing: PartView) { val at = components.indexOfFirst { it === existing }.takeIf { it >= 0 } ?: componentCount parts.remove(content.id) + aliases.values.removeAll { it == content.id } + sources.keys.removeAll { it !in aliases } remove(existing) Disposer.dispose(existing) val view = view(content) @@ -123,7 +166,13 @@ class MessageView( /** Remove the renderer for [contentId] if present. */ fun removePart(contentId: String) { + if (aliases.remove(contentId) != null) { + sources.remove(contentId) + return + } val view = parts.remove(contentId) ?: return + aliases.values.removeAll { it == contentId } + sources.keys.removeAll { it !in aliases } remove(view) Disposer.dispose(view) syncBorder() @@ -154,13 +203,12 @@ class MessageView( Disposer.dispose(it) } parts.clear() + aliases.clear() + sources.clear() for ((_, content) in msg.parts) { if (content is StepFinish) continue if (isHidden(content)) continue - val view = view(content) - view.applyStyle(style) - parts[content.id] = view - add(view) + addPart(content) } syncBorder() refresh() @@ -179,13 +227,15 @@ class MessageView( /** Append a streaming delta to the renderer for [contentId]. */ fun appendDelta(contentId: String, delta: String): Boolean { - val part = parts[contentId] ?: return false + val id = aliases[contentId] + if (id != null) sources[contentId] = sources[contentId].orEmpty() + delta + val part = parts[id ?: contentId] ?: return false part.appendDelta(delta) return true } /** Look up a renderer by part id. */ - fun part(id: String): PartView? = parts[id] + fun part(id: String): PartView? = parts[aliases[id] ?: id] /** Ordered part ids — stable for test assertions. */ fun partIds(): List = parts.keys.toList() @@ -206,6 +256,8 @@ class MessageView( Disposer.dispose(it) } parts.clear() + aliases.clear() + sources.clear() hidden = null } 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 9907738612f..b50f8ebaa67 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 @@ -24,6 +24,8 @@ import java.awt.Rectangle import javax.swing.JPanel import javax.swing.ScrollPaneConstants import javax.swing.Scrollable +import javax.swing.SwingUtilities +import javax.swing.border.Border /** Renders reasoning as a secondary collapsible block. */ class ReasoningView( @@ -32,7 +34,11 @@ class ReasoningView( private val selection: SessionSelection? = null, private val parts: ReasoningParts = reasoningParts(selection), ) : - SecondarySessionPartView(parts.header, { parts.scroll(openUrl) }) { + SecondarySessionPartView( + parts.header, + { parts.scroll(openUrl) }, + expanded = reasoning.content.isNotBlank() && !reasoning.done, + ) { override val contentId: String = reasoning.id @@ -50,11 +56,13 @@ class ReasoningView( private var style = SessionEditorStyle.current() private var source = reasoning.content.toString() + private var done = reasoning.done private var registered = false init { bindHeader(parts.title, parts.icon) applyStyle(style) + if (bodyVisible()) syncBody() sync() } @@ -70,9 +78,16 @@ class ReasoningView( if (content !is Reasoning) return var changed = false val next = content.content.toString() + if (done != content.done) { + done = content.done + changed = true + } if (source != next) { source = next - if (parts.bodyCreated()) md.set(source) + if (parts.bodyCreated()) { + md.set(source) + followTail() + } changed = true } changed = sync() || changed @@ -82,7 +97,10 @@ class ReasoningView( override fun appendDelta(delta: String) { if (delta.isEmpty()) return source += delta - if (parts.bodyCreated()) md.append(delta) + if (parts.bodyCreated()) { + md.append(delta) + followTail() + } val changed = sync() if (changed || bodyVisible()) refresh() } @@ -95,6 +113,9 @@ class ReasoningView( internal fun horizontalPolicy() = parts.scrollOrNull?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER internal fun bodyMaxRows() = SessionUiStyle.View.Reasoning.BODY_LINES internal fun bodyCreated() = parts.bodyCreated() + internal fun bodyBorder(): Border? = parts.scrollOrNull?.border + internal fun bodyScrollValue() = parts.scrollOrNull?.verticalScrollBar?.value ?: 0 + internal fun bodyScrollBottom() = parts.scrollOrNull?.verticalScrollBar?.let { it.maximum - it.visibleAmount } ?: 0 override fun applyStyle(style: SessionEditorStyle) { this.style = style @@ -116,7 +137,20 @@ class ReasoningView( private fun canExpand(): Boolean = source.isNotBlank() - private fun sync(): Boolean = syncExpandable(canExpand()) + private fun sync(): Boolean { + var changed = false + val visible = source.isNotBlank() + if (isVisible != visible) { + isVisible = visible + changed = true + } + changed = syncExpandable(canExpand()) || changed + if (visible && !done && !parts.bodyCreated()) { + changed = expand() || changed + changed = syncExpandable(canExpand()) || changed + } + return changed + } private fun apply(md: MdView): Boolean { var changed = false @@ -134,6 +168,7 @@ class ReasoningView( val md = md registerBody(md) md.set(source) + followTail() } private fun applyBodyStyle(): Boolean { @@ -157,6 +192,15 @@ class ReasoningView( JBUI.scale(SessionUiStyle.View.SESSION_VIEW_BODY_EXTRA_HEIGHT) } + private fun followTail() { + if (!bodyVisible()) return + val scroll = parts.scrollOrNull ?: return + SwingUtilities.invokeLater { + val bar = scroll.verticalScrollBar + bar.value = bar.maximum - bar.visibleAmount + } + } + override fun dumpLabel(): String { val state = if (bodyVisible()) "open" else "closed" return "ReasoningView#$contentId($state)" @@ -195,7 +239,7 @@ class ReasoningParts( add(md.component, BorderLayout.CENTER) } val scroll = JBScrollPane(panel).apply { - border = SessionUiStyle.View.topOutline() + border = SessionUiStyle.View.leftOutline() isOpaque = true background = SessionUiStyle.View.surface() viewport.background = SessionUiStyle.View.surface() 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 2019e30a4e9..d8192353e21 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 @@ -2,8 +2,10 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Reasoning 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 com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.UIUtil import javax.swing.ScrollPaneConstants @Suppress("UnstableApiUsage") @@ -33,15 +35,16 @@ class ReasoningViewTest : BasePlatformTestCase() { assertTrue(view.bodyCreated()) } - fun `test streaming reasoning is collapsed by default`() { + fun `test streaming reasoning is expanded by default`() { val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo\nthree\nfour")) - assertFalse(view.isExpanded()) + assertTrue(view.isExpanded()) assertTrue(view.hasToggle()) + assertTrue(view.bodyVisible()) } fun `test update to done preserves collapsed reasoning`() { - val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo\nthree\nfour")) + val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) view.update(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) @@ -59,7 +62,7 @@ class ReasoningViewTest : BasePlatformTestCase() { } fun `test collapsed reasoning stays collapsed on update`() { - val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo")) + val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo")) view.update(reasoning("p1", done = true, text = "one\ntwo\nthree")) assertFalse(view.isExpanded()) @@ -72,22 +75,24 @@ class ReasoningViewTest : BasePlatformTestCase() { view.appendDelta("b") assertEquals("ab", view.markdown()) - assertFalse(view.isExpanded()) + assertTrue(view.isExpanded()) } - fun `test blank reasoning stays collapsed when delta arrives`() { + fun `test blank streaming reasoning opens when delta arrives`() { val view = ReasoningView(reasoning("p1", done = false, text = "")) + assertFalse(view.isVisible) view.appendDelta("b") assertEquals("b", view.markdown()) - assertFalse(view.bodyCreated()) - assertFalse(view.bodyVisible()) + assertTrue(view.isVisible) + assertTrue(view.bodyCreated()) + assertTrue(view.bodyVisible()) assertTrue(view.hasToggle()) } - fun `test collapsed append keeps lazy reasoning body uncreated`() { - val view = ReasoningView(reasoning("p1", done = false, text = "a")) + fun `test collapsed completed append keeps lazy reasoning body uncreated`() { + val view = ReasoningView(reasoning("p1", done = true, text = "a")) view.appendDelta("b") @@ -96,10 +101,10 @@ class ReasoningViewTest : BasePlatformTestCase() { assertFalse(view.bodyVisible()) } - fun `test collapsed update keeps lazy reasoning body uncreated`() { - val view = ReasoningView(reasoning("p1", done = false, text = "a")) + fun `test collapsed completed update keeps lazy reasoning body uncreated`() { + val view = ReasoningView(reasoning("p1", done = true, text = "a")) - view.update(reasoning("p1", done = false, text = "abc")) + view.update(reasoning("p1", done = true, text = "abc")) assertEquals("abc", view.markdown()) assertFalse(view.bodyCreated()) @@ -107,7 +112,7 @@ class ReasoningViewTest : BasePlatformTestCase() { } fun `test reasoning creates lazy markdown body once`() { - val view = ReasoningView(reasoning("p1", done = false, text = "one")) + val view = ReasoningView(reasoning("p1", done = true, text = "one")) view.toggle() val component = view.md.component @@ -121,6 +126,7 @@ class ReasoningViewTest : BasePlatformTestCase() { fun `test blank reasoning has no toggle`() { val view = ReasoningView(reasoning("p1", done = true, text = "")) + assertFalse(view.isVisible) assertFalse(view.isExpanded()) assertFalse(view.hasToggle()) } @@ -158,10 +164,35 @@ class ReasoningViewTest : BasePlatformTestCase() { fun `test expanded reasoning body is capped to five rows`() { val view = ReasoningView(reasoning("p1", done = false, text = (1..20).joinToString("\n") { "line $it" })) - view.toggle() + val taller = ReasoningView(reasoning("p2", done = false, text = (1..200).joinToString("\n") { "line $it" })) assertEquals(5, view.bodyMaxRows()) assertTrue(view.preferredSize.height > 0) + assertEquals(view.preferredSize.height, taller.preferredSize.height) + } + + fun `test appended reasoning scrolls nested body to bottom`() { + val view = ReasoningView(reasoning("p1", done = false, text = (1..20).joinToString("\n") { "line $it" })) + view.setSize(300, 80) + view.doLayout() + + view.appendDelta("\nline 21\nline 22") + UIUtil.dispatchAllInvocationEvents() + + assertEquals(view.bodyScrollBottom(), view.bodyScrollValue()) + } + + fun `test reasoning body uses vertical separator`() { + val view = ReasoningView(reasoning("p1", done = true, text = "one")) + + view.toggle() + + val insets = view.bodyBorder()!!.getBorderInsets(view) + assertEquals(0, insets.top) + assertEquals(1, insets.left) + assertEquals(0, insets.bottom) + assertEquals(0, insets.right) + assertEquals(SessionUiStyle.View.Reasoning.BODY_LINES, view.bodyMaxRows()) } fun `test link opens url callback`() { 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 5536843a8a5..9e358f61d76 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 @@ -188,6 +188,52 @@ class TurnViewTest : BasePlatformTestCase() { assertEquals("hello world", view.markdown()) } + fun `test consecutive reasoning parts reuse one view`() { + val message = msg("a1", "assistant") + message.parts["r1"] = reasoning("r1", "first ") + message.parts["r2"] = reasoning("r2", "second") + + val mv = MessageView(message, openFile) + + assertEquals(listOf("r1"), mv.partIds()) + assertSame(mv.part("r1"), mv.part("r2")) + assertEquals("first second", (mv.part("r1") as ReasoningView).markdown()) + } + + fun `test delta for aliased reasoning appends to reused view`() { + val message = msg("a1", "assistant") + message.parts["r1"] = reasoning("r1", "first ") + message.parts["r2"] = reasoning("r2", "second") + val mv = MessageView(message, openFile) + + assertTrue(mv.appendDelta("r2", " third")) + + assertEquals("first second third", (mv.part("r1") as ReasoningView).markdown()) + } + + fun `test text between reasoning parts keeps separate views`() { + val message = msg("a1", "assistant") + message.parts["r1"] = reasoning("r1", "first") + message.parts["t1"] = text("t1", "middle") + message.parts["r2"] = reasoning("r2", "second") + + val mv = MessageView(message, openFile) + + assertEquals(listOf("r1", "t1", "r2"), mv.partIds()) + assertNotSame(mv.part("r1"), mv.part("r2")) + } + + fun `test blank reasoning part is invisible`() { + val message = msg("a1", "assistant") + message.parts["r1"] = reasoning("r1", "") + message.parts["t1"] = text("t1", "middle") + + val mv = MessageView(message, openFile) + + assertFalse(mv.part("r1")!!.isVisible) + assertTrue(mv.part("t1")!!.isVisible) + } + fun `test appendDelta for unknown part id is noop`() { val mv = MessageView(msg("a1", "assistant"), openFile) // Must not throw @@ -226,7 +272,7 @@ class TurnViewTest : BasePlatformTestCase() { fun `test assistant card parts use shared compact gap`() { val message = msg("a1", "assistant") - val reasoning = Reasoning("r1") + val reasoning = reasoning("r1", "thinking") val tool = Tool("t1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED } message.parts["r1"] = reasoning message.parts["t1"] = tool @@ -263,6 +309,13 @@ class TurnViewTest : BasePlatformTestCase() { private fun msg(id: String, role: String): Message = Message(MessageDto(id = id, sessionID = "ses", role = role, time = MessageTimeDto(0.0))) + private fun reasoning(id: String, content: String) = Reasoning(id).also { + it.done = false + it.content.append(content) + } + + private fun text(id: String, content: String) = Text(id).also { it.content.append(content) } + private class TrackingRepaintManager(private val watched: Set) : RepaintManager() { val dirty = mutableListOf() val invalid = mutableListOf() From cdccebe2795b0d234c5de21fc971a9e7b2cda0a8 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 7 Jun 2026 15:45:17 -0400 Subject: [PATCH 06/26] fix(jetbrains): refine reasoning block spacing --- .../client/session/ui/style/SessionUiStyle.kt | 4 +++ .../client/session/views/ReasoningView.kt | 25 +++++++++++++++---- .../views/base/AbstractSessionPartView.kt | 2 +- .../client/session/views/ReasoningViewTest.kt | 6 +++-- 4 files changed, 29 insertions(+), 8 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 25b4c2f2e11..b188e2cee1a 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 @@ -76,6 +76,10 @@ object SessionUiStyle { /** Reasoning block preview sizing. */ object Reasoning { const val BODY_LINES = 5 + const val HEADER_VERTICAL_PADDING = 5 + const val HEADER_HORIZONTAL_PADDING = 10 + const val BODY_VERTICAL_PADDING = 4 + const val BODY_HORIZONTAL_PADDING = 8 } /** Message container roles and user bubble geometry. */ 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 b50f8ebaa67..10c21cf1352 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 @@ -25,7 +25,6 @@ import javax.swing.JPanel import javax.swing.ScrollPaneConstants import javax.swing.Scrollable import javax.swing.SwingUtilities -import javax.swing.border.Border /** Renders reasoning as a secondary collapsible block. */ class ReasoningView( @@ -60,20 +59,33 @@ class ReasoningView( private var registered = false init { + row.border = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.Reasoning.HEADER_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Reasoning.HEADER_HORIZONTAL_PADDING), + ) bindHeader(parts.title, parts.icon) applyStyle(style) if (bodyVisible()) syncBody() + syncBorder() sync() } override fun expand(): Boolean { val changed = super.expand() if (!changed) return false + syncBorder() syncBody() applyBodyStyle() return true } + override fun collapse(): Boolean { + val changed = super.collapse() + if (!changed) return false + syncBorder() + return true + } + override fun update(content: Content) { if (content !is Reasoning) return var changed = false @@ -113,7 +125,6 @@ class ReasoningView( internal fun horizontalPolicy() = parts.scrollOrNull?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER internal fun bodyMaxRows() = SessionUiStyle.View.Reasoning.BODY_LINES internal fun bodyCreated() = parts.bodyCreated() - internal fun bodyBorder(): Border? = parts.scrollOrNull?.border internal fun bodyScrollValue() = parts.scrollOrNull?.verticalScrollBar?.value ?: 0 internal fun bodyScrollBottom() = parts.scrollOrNull?.verticalScrollBar?.let { it.maximum - it.visibleAmount } ?: 0 @@ -152,6 +163,10 @@ class ReasoningView( return changed } + private fun syncBorder() { + border = if (isExpanded()) SessionUiStyle.View.leftOutline() else JBUI.Borders.empty(0, 1, 0, 0) + } + private fun apply(md: MdView): Boolean { var changed = false val font = style.smallEditorFont.deriveFont(Font.ITALIC) @@ -233,13 +248,13 @@ class ReasoningParts( isOpaque = true background = SessionUiStyle.View.surface() border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING), + JBUI.scale(SessionUiStyle.View.Reasoning.BODY_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Reasoning.BODY_HORIZONTAL_PADDING), ) add(md.component, BorderLayout.CENTER) } val scroll = JBScrollPane(panel).apply { - border = SessionUiStyle.View.leftOutline() + border = JBUI.Borders.empty() isOpaque = true background = SessionUiStyle.View.surface() viewport.background = SessionUiStyle.View.surface() 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 52942a60677..e5cb30d95a3 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 @@ -78,7 +78,7 @@ abstract class AbstractSessionPartView( return true } - fun collapse(): Boolean { + open fun collapse(): Boolean { val item = body ?: return false if (item.parent !== this) return false remove(item) 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 d8192353e21..6589e0411eb 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 @@ -182,12 +182,14 @@ class ReasoningViewTest : BasePlatformTestCase() { assertEquals(view.bodyScrollBottom(), view.bodyScrollValue()) } - fun `test reasoning body uses vertical separator`() { + fun `test reasoning block uses vertical separator`() { val view = ReasoningView(reasoning("p1", done = true, text = "one")) + assertEquals(1, view.border!!.getBorderInsets(view).left) + view.toggle() - val insets = view.bodyBorder()!!.getBorderInsets(view) + val insets = view.border!!.getBorderInsets(view) assertEquals(0, insets.top) assertEquals(1, insets.left) assertEquals(0, insets.bottom) From 5736a394597f250f64cf8c684d2426b56ca273ce Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 8 Jun 2026 13:47:15 -0400 Subject: [PATCH 07/26] feat(jetbrains): render search tools with dedicated views --- .changeset/glob-jetbrains-view.md | 5 + .changeset/search-jetbrains-view.md | 5 + .../kilocode/client/session/views/ToolView.kt | 242 +++++++++++++++++- .../client/session/views/ViewFactory.kt | 6 + .../ai/kilocode/client/ui/layout/Stack.kt | 62 +++++ .../resources/messages/KiloBundle.properties | 2 + .../client/session/ui/SessionUiUpdateTest.kt | 16 ++ .../client/session/views/GlobToolViewTest.kt | 90 +++++++ .../client/session/views/ReadToolViewTest.kt | 4 +- .../session/views/SearchToolViewTest.kt | 126 +++++++++ 10 files changed, 554 insertions(+), 4 deletions(-) create mode 100644 .changeset/glob-jetbrains-view.md create mode 100644 .changeset/search-jetbrains-view.md create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt diff --git a/.changeset/glob-jetbrains-view.md b/.changeset/glob-jetbrains-view.md new file mode 100644 index 00000000000..be879d87bbb --- /dev/null +++ b/.changeset/glob-jetbrains-view.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Render glob search results in the JetBrains chat as collapsible tool output with separate directory and pattern rows. diff --git a/.changeset/search-jetbrains-view.md b/.changeset/search-jetbrains-view.md new file mode 100644 index 00000000000..f1ca3800839 --- /dev/null +++ b/.changeset/search-jetbrains-view.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Render grep searches in the JetBrains chat with a dedicated search header that shows stacked, clipped targets. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt index 5951f40ff00..7192e8f5e5e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt @@ -11,6 +11,10 @@ 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.SecondarySessionPartView +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align import ai.kilocode.client.ui.UiStyle import com.intellij.icons.AllIcons import com.intellij.ui.components.JBLabel @@ -26,7 +30,6 @@ import java.awt.Dimension import java.awt.Font import java.awt.event.MouseAdapter import java.awt.event.MouseEvent -import javax.swing.Box import javax.swing.Icon import javax.swing.JComponent import javax.swing.JPanel @@ -298,6 +301,182 @@ class ReadToolView( override fun dumpLabel() = "ReadToolView#$contentId(${labelText()})" } +abstract class BaseSearchToolView( + tool: Tool, + private val selection: SessionSelection? = null, + private val parts: ToolParts, +) : SecondarySessionPartView(parts.header, { parts.scroll(tool) }) { + + override val contentId: String = tool.id + + protected var item = tool + private var style = SessionEditorStyle.current() + private var registered = false + + protected abstract fun toolIcon(tool: Tool): Icon + protected abstract fun toolTitle(tool: Tool): String + protected abstract fun targets(tool: Tool): List + protected abstract fun viewName(): String + + init { + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) + parts.targets.forEach { bindHeader(it) } + applyStyle(style) + sync() + } + + override fun expand(): Boolean { + val changed = super.expand() + if (!changed) return false + syncBody() + applyBodyStyle() + return true + } + + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + if (!bodyVisible()) return size + val height = row.preferredSize.height + bodyMaxHeight() + return Dimension(size.width, minOf(size.height, height)) + } + + override fun update(content: Content) { + if (content !is Tool) return + item = content + var changed = sync() + changed = syncBody() || changed + if (changed) refresh() + } + + fun labelText(): String = listOf(parts.title.text).plus(targetTexts()).plus(parts.state.text) + .filter { it.isNotBlank() } + .joinToString(" ") + + fun bodyText(): String = body(item) + internal fun targetTexts(): List = parts.targets.map { it.text }.filter { it.isNotBlank() } + internal fun targetVisible(index: Int): Boolean = parts.targets.getOrNull(index)?.isVisible ?: false + internal fun bodyVisible() = parts.scroll?.parent === this + internal fun hasToggle() = arrow.isVisible + internal fun bodyFont() = parts.text?.font ?: style.transcriptFont + internal fun titleFont() = parts.title.font + internal fun targetFont(index: Int) = parts.targets.getOrNull(index)?.font ?: style.smallEditorFont + internal fun stateFont() = parts.state.font + internal fun bodyCreated() = parts.bodyCreated() + internal fun scrollComponent() = parts.scroll + internal fun headerComponent() = parts.header + internal fun centerComponent() = parts.center + internal fun targetComponents() = parts.targets + + 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 + parts.targets.forEach { changed = setFont(it, style.smallEditorFont) || changed } + changed = setFont(parts.state, style.smallEditorFont) || changed + changed = applyBodyStyle() || changed + if (changed) refresh() + } + + private fun sync(): Boolean { + val expand = canExpand(item) + var changed = false + changed = syncExpandable(expand) || changed + changed = setVisible(parts.state, item.state != ToolExecState.COMPLETED) || changed + changed = setIcon(parts.glyph, toolIcon(item)) || changed + changed = setForeground(parts.glyph, color(item)) || changed + changed = setText(parts.title, toolTitle(item)) || changed + changed = setForeground(parts.title, titleColor(item)) || changed + changed = setForeground(parts.sub, UiStyle.Colors.weak()) || changed + changed = syncTargets() || changed + changed = setText(parts.state, stateText(item)) || changed + changed = setForeground(parts.state, color(item)) || changed + parts.text?.let { changed = setForeground(it, bodyColor()) || changed } + return changed + } + + private fun syncTargets(): Boolean { + val values = targets(item) + var changed = false + parts.targets.forEachIndexed { index, label -> + val text = values.getOrNull(index) ?: "" + changed = setVisible(label, text.isNotBlank()) || changed + changed = setPlainText(label, text) || changed + changed = setForeground(label, UiStyle.Colors.weak()) || changed + } + return changed + } + + private fun syncBody(): Boolean { + val text = parts.text ?: return false + val value = plainBody(item) + if (text.text != value) { + text.text = value + text.caretPosition = 0 + return true + } + return false + } + + private fun applyBodyStyle(): Boolean { + val text = parts.text ?: return false + if (!registered && selection != null && text.parent != null) { + registered = true + selection.register(text, this) + } + return setFont(text, style.transcriptFont) + } + + private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() + + private fun bodyMaxHeight(): Int { + val text = parts.text ?: return 0 + return text.getFontMetrics(text.font).height * SessionUiStyle.View.Tool.BODY_LINES + + JBUI.scale(SessionUiStyle.View.SESSION_VIEW_BODY_EXTRA_HEIGHT) + } + + override fun dumpLabel() = "${viewName()}#$contentId(${labelText()})" +} + +/** Renders glob calls with a stacked, collapsible search-result header. */ +class GlobToolView( + tool: Tool, + selection: SessionSelection? = null, + parts: ToolParts = searchParts(2), +) : BaseSearchToolView(tool, selection, parts) { + + companion object { + fun canRender(tool: Tool): Boolean = tool.name == "glob" + } + + internal fun directoryText(): String = globDirectory(item) + internal fun patternText(): String = globPattern(item) + internal fun patternVisible(): Boolean = targetVisible(1) + internal fun directoryFont() = targetFont(0) + internal fun patternFont() = targetFont(1) + + override fun toolIcon(tool: Tool) = icon(tool) + override fun toolTitle(tool: Tool) = KiloBundle.message("session.part.tool.glob") + override fun targets(tool: Tool) = listOf(globDirectory(tool), globPattern(tool)) + override fun viewName() = "GlobToolView" +} + +/** Renders grep/content-search calls with stacked, clipped search targets. */ +class SearchToolView( + tool: Tool, + selection: SessionSelection? = null, +) : BaseSearchToolView(tool, selection, searchParts(3)) { + + companion object { + fun canRender(tool: Tool): Boolean = tool.name == "grep" + } + + override fun toolIcon(tool: Tool) = AllIcons.Actions.Search + override fun toolTitle(tool: Tool) = KiloBundle.message("session.part.tool.search") + override fun targets(tool: Tool) = searchTargets(tool) + override fun viewName() = "SearchToolView" +} + class ToolParts( val header: JPanel, val glyph: JBLabel, @@ -309,6 +488,8 @@ class ToolParts( val center: JPanel, val controls: JComponent, private val open: ((String) -> Unit)? = null, + val extra: JBLabel? = null, + val targets: List = emptyList(), ) { var href: String? = null var label: String = "" @@ -389,7 +570,7 @@ private fun toolParts(tool: Tool, openFile: ((String) -> Unit)? = null): ToolPar } val state = JBLabel().apply { foreground = UiStyle.Colors.weak() } val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { isOpaque = false } - val controls = Box.createHorizontalBox() + val controls = Stack.horizontal() val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { isOpaque = false center.add(title, BorderLayout.WEST) @@ -404,6 +585,43 @@ private fun toolParts(tool: Tool, openFile: ((String) -> Unit)? = null): ToolPar } } +private fun searchParts(count: Int): ToolParts { + val glyph = JBLabel() + val title = JBLabel() + val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } + val targets = List(count) { + JBLabel().apply { + foreground = UiStyle.Colors.weak() + minimumSize = Dimension(0, minimumSize.height) + } + } + val link = JBLabel().apply { isVisible = false } + val slot = JPanel(CardLayout()).apply { + isOpaque = false + add(sub, SUB_CARD) + add(link, LINK_CARD) + } + val state = JBLabel().apply { foreground = UiStyle.Colors.weak() } + val stack = Stack.fitHorizontal(UiStyle.Gap.xs()).apply { targets.forEach { next(it) } } + val target = stack.align(HAlign.TRACK, VAlign.CENTER) + val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { + isOpaque = false + minimumSize = Dimension(0, minimumSize.height) + add(title, BorderLayout.WEST) + add(target, BorderLayout.CENTER) + } + val controls = Stack.horizontal() + val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { + isOpaque = false + add(glyph, BorderLayout.WEST) + add(center, BorderLayout.CENTER) + add(controls, BorderLayout.EAST) + } + return ToolParts(header, glyph, title, sub, link, slot, state, center, controls, targets = targets).also { + controls.add(it.state) + } +} + private fun icon(tool: Tool) = when (tool.name) { "read" -> AllIcons.Actions.Preview "bash" -> AllIcons.Debugger.Console @@ -434,6 +652,12 @@ private fun setText(label: JBLabel, text: String): Boolean { return true } +private fun setPlainText(label: JBLabel, text: String): Boolean { + if (label.text == text) return false + label.text = text + return true +} + private fun setLinkText(parts: ToolParts, text: String): Boolean { val value = if (text.isBlank()) "" else XmlStringUtil.wrapInHtml("${XmlStringUtil.escapeString(text)}") if (parts.label == text && parts.link.text == value) return false @@ -506,6 +730,20 @@ private fun readPath(tool: Tool): String { return tail(path).ifBlank { path } } +private fun globDirectory(tool: Tool): String = + tool.input["path"]?.takeIf { it.isNotBlank() } + ?: tool.title?.takeIf { it.isNotBlank() } + ?: "" + +private fun globPattern(tool: Tool): String = + tool.input["pattern"]?.takeIf { it.isNotBlank() }?.let { "pattern=$it" } ?: "" + +private fun searchTargets(tool: Tool): List = listOfNotNull( + tool.input["path"]?.takeIf { it.isNotBlank() }, + tool.input["pattern"]?.takeIf { it.isNotBlank() }?.let { "pattern=$it" }, + tool.input["include"]?.takeIf { it.isNotBlank() }?.let { "include=$it" }, +) + private data class Target( val path: String, val type: String, 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 fca054a0c93..be28f79170c 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 @@ -45,6 +45,8 @@ object ViewFactory { TodoWriteView.canRender(content) -> TodoWriteView(content) PlanExitView.canRender(content) -> PlanExitView(content, openFile, selection) QuestionResultView.canRender(content) -> QuestionResultView(content, selection) + GlobToolView.canRender(content) -> GlobToolView(content, selection = selection) + SearchToolView.canRender(content) -> SearchToolView(content, selection = selection) ReadToolView.canRender(content) -> ReadToolView(content, openFile, selection = selection) else -> ToolView(content, selection = selection) } @@ -86,6 +88,10 @@ 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 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) + if (view !is SearchToolView && SearchToolView.canRender(content)) return true if (view is ReadToolView) return !ReadToolView.canRender(content) || QuestionResultView.canRender(content) if (view is ToolView && ReadToolView.canRender(content)) return true if (view is ToolView) return QuestionResultView.canRender(content) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Stack.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Stack.kt index bc8385e96b4..e496858f6b8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Stack.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Stack.kt @@ -51,11 +51,19 @@ open class Stack( private val mgr: Layout get() = getLayout() as Layout + internal fun fit(): Stack { + mgr.fit = true + revalidate() + return this + } + private class Layout( private val axis: StackAxis, private val gap: Int, ) : LayoutManager2 { + var fit = false + private val entries = mutableListOf() fun gap(size: Int) { @@ -83,6 +91,10 @@ open class Stack( val ins = parent.insets val w = maxOf(0, parent.width - ins.left - ins.right) val h = maxOf(0, parent.height - ins.top - ins.bottom) + if (axis == StackAxis.HORIZONTAL && fit) { + fit(parent, ins.left, ins.top, w, h) + return + } var x = ins.left var y = ins.top var seen = false @@ -131,6 +143,53 @@ open class Stack( } } + private fun fit(parent: Container, left: Int, top: Int, w: Int, h: Int) { + val items = children(parent, h) + val gap = items.sumOf { it.gap } + val total = items.sumOf { it.width } + gap + val widths = if (total <= w) { + items.map { it.width } + } else { + val space = maxOf(0, w - gap) + val base = if (items.isEmpty()) 0 else space / items.size + val extra = if (items.isEmpty()) 0 else space % items.size + items.mapIndexed { index, _ -> base + if (index < extra) 1 else 0 } + } + var x = left + items.forEachIndexed { index, item -> + x += item.gap + item.comp.setBounds(x, top, widths[index], h) + x += widths[index] + } + } + + private fun children(parent: Container, h: Int): List { + val items = mutableListOf() + var seen = false + var ready = false + var pending: Int? = null + for (entry in entries) { + when (entry) { + is Entry.Gap -> if (ready) pending = safe(pending ?: 0, entry.size) + is Entry.Child -> { + val space = pending + pending = null + ready = false + if (entry.comp.isVisible) { + entry.comp.setSize(entry.comp.width.coerceAtLeast(1), h) + val pref = entry.comp.preferredSize + val min = entry.comp.minimumSize + val max = entry.comp.maximumSize + items.add(Item(entry.comp, if (seen) space ?: gap else 0, bound(pref.width, min.width, max.width))) + seen = true + ready = true + } + } + } + } + return items + } + override fun minimumLayoutSize(parent: Container) = size(parent, Size.MIN) override fun preferredLayoutSize(parent: Container) = size(parent, Size.PREF) override fun maximumLayoutSize(target: Container) = size(target, Size.MAX) @@ -204,6 +263,8 @@ open class Stack( data class Child(val comp: Component) : Entry data class Gap(val size: Int) : Entry } + + private data class Item(val comp: Component, val gap: Int, val width: Int) } private enum class Size { MIN, PREF, MAX } @@ -211,6 +272,7 @@ open class Stack( companion object { fun vertical(gap: Int = 0) = Stack(StackAxis.VERTICAL, gap) fun horizontal(gap: Int = 0) = Stack(StackAxis.HORIZONTAL, gap) + fun fitHorizontal(gap: Int = 0) = Stack(StackAxis.HORIZONTAL, gap).fit() fun verticalFiller(size: Int): Component = filler(StackAxis.VERTICAL, size) fun horizontalFiller(size: Int): Component = filler(StackAxis.HORIZONTAL, size) } 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 ff2594f38fa..9141c1b9a82 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -88,6 +88,8 @@ session.part.tool.copy=Copy session.part.tool.error=Error session.part.tool.pending=Pending session.part.tool.read=Read +session.part.tool.glob=Glob +session.part.tool.search=Search session.part.tool.running=Running session.part.tool.shell=Shell session.part.tool.truncated=Output truncated in preview. Full output remains in session data. 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 4d0177f5666..f8d832cb771 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 @@ -95,6 +95,22 @@ class SessionUiUpdateTest : BasePlatformTestCase() { assertTrue(tv is ai.kilocode.client.session.views.ReadToolView) } + fun `test glob tool renders as GlobToolView`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", toolPart("t1", "a1", "glob", "completed")) + + val tv = panel.findMessage("a1")!!.part("t1") + assertTrue(tv is ai.kilocode.client.session.views.GlobToolView) + } + + fun `test grep tool renders as SearchToolView`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", toolPart("t1", "a1", "grep", "completed")) + + val tv = panel.findMessage("a1")!!.part("t1") + assertTrue(tv is ai.kilocode.client.session.views.SearchToolView) + } + // ------ multiple turns update correctly ------ fun `test content goes to correct turn when multiple turns exist`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt new file mode 100644 index 00000000000..497d10439c9 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt @@ -0,0 +1,90 @@ +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.views.base.SecondarySessionPartView +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +@Suppress("UnstableApiUsage") +class GlobToolViewTest : BasePlatformTestCase() { + + fun `test header renders title directory and pattern rows`() { + val view = GlobToolView(tool().also { + it.input = mapOf("path" to "/repo/src", "pattern" to "**/*.kt") + }) + val base: Any = view + + assertTrue(base is SecondarySessionPartView) + assertTrue(view.labelText().contains("Glob")) + assertEquals("/repo/src", view.directoryText()) + assertEquals("pattern=**/*.kt", view.patternText()) + assertTrue(view.patternVisible()) + } + + fun `test pattern row hides when pattern is absent`() { + val view = GlobToolView(tool().also { + it.input = mapOf("path" to "/repo/src") + }) + + assertEquals("/repo/src", view.directoryText()) + assertEquals("", view.patternText()) + assertFalse(view.patternVisible()) + } + + fun `test completed glob starts collapsed and expands output`() { + val view = GlobToolView(tool().also { it.output = "/repo/src/A.kt\n/repo/src/B.kt" }) + + assertTrue(view.hasToggle()) + assertFalse(view.isExpanded()) + assertFalse(view.bodyVisible()) + assertEquals("/repo/src/A.kt\n/repo/src/B.kt", view.bodyText()) + + view.toggle() + + assertTrue(view.isExpanded()) + assertTrue(view.bodyVisible()) + assertEquals("/repo/src/A.kt\n/repo/src/B.kt", view.bodyText()) + } + + fun `test glob body is lazy and reused`() { + val view = GlobToolView(tool().also { it.output = "/repo/src/A.kt" }) + + assertFalse(view.bodyCreated()) + view.toggle() + val body = view.scrollComponent() + assertNotNull(body) + + view.toggle() + assertFalse(view.bodyVisible()) + view.toggle() + + assertSame(body, view.scrollComponent()) + assertTrue(view.bodyVisible()) + } + + fun `test collapsed update keeps glob body uncreated`() { + val view = GlobToolView(tool().also { it.output = "/repo/src/A.kt" }) + + view.update(tool().also { it.output = "/repo/src/B.kt" }) + + assertFalse(view.bodyCreated()) + assertEquals("/repo/src/B.kt", view.bodyText()) + } + + fun `test view factory routes glob to glob tool view`() { + assertTrue(ViewFactory.create(tool(), openFile = {}) is GlobToolView) + } + + fun `test should replace when glob renderer changes`() { + val glob = tool() + val read = Tool("p1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED } + + assertTrue(ViewFactory.shouldReplace(ReadToolView(read), glob)) + assertTrue(ViewFactory.shouldReplace(ToolView(read), glob)) + assertTrue(ViewFactory.shouldReplace(GlobToolView(glob), read)) + assertFalse(ViewFactory.shouldReplace(GlobToolView(glob), glob)) + } + + private fun tool() = Tool("p1", "glob", toolKind("glob")).also { it.state = ToolExecState.COMPLETED } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt index a1d27fab1bb..a3eddef67a7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt @@ -95,8 +95,8 @@ class ReadToolViewTest : BasePlatformTestCase() { fun `test view factory routes read kind tools to read tool view`() { assertTrue(ViewFactory.create(tool(), openFile = {}) is ReadToolView) - assertTrue(ViewFactory.create(Tool("p2", "grep", toolKind("grep")), openFile = {}) is ReadToolView) - assertTrue(ViewFactory.create(Tool("p3", "glob", toolKind("glob")), openFile = {}) is ReadToolView) + assertTrue(ViewFactory.create(Tool("p2", "grep", toolKind("grep")), openFile = {}) is SearchToolView) + assertTrue(ViewFactory.create(Tool("p3", "glob", toolKind("glob")), openFile = {}) is GlobToolView) } fun `test canRender matches read kind tools only`() { 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 new file mode 100644 index 00000000000..604f32423a0 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt @@ -0,0 +1,126 @@ +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.views.base.SecondarySessionPartView +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Container +import java.awt.Dimension + +@Suppress("UnstableApiUsage") +class SearchToolViewTest : BasePlatformTestCase() { + + fun `test header renders title pattern and include targets`() { + val view = SearchToolView(tool().also { + it.input = mapOf("pattern" to "class SearchToolView", "include" to "*.{kt,kts}") + }) + val base: Any = view + + assertTrue(base is SecondarySessionPartView) + assertTrue(view.labelText().contains("Search")) + assertEquals(listOf("pattern=class SearchToolView", "include=*.{kt,kts}"), view.targetTexts()) + assertTrue(view.targetVisible(0)) + assertTrue(view.targetVisible(1)) + assertFalse(view.targetVisible(2)) + } + + fun `test header includes optional path target`() { + val view = SearchToolView(tool().also { + it.input = mapOf("path" to "/repo/src", "pattern" to "TODO", "include" to "*.kt") + }) + + assertEquals(listOf("/repo/src", "pattern=TODO", "include=*.kt"), view.targetTexts()) + } + + fun `test target labels use plain text for clipping`() { + val view = SearchToolView(tool().also { + it.input = mapOf("pattern" to "", "include" to "*.kt") + }) + + assertEquals("pattern=", view.targetComponents().first().text) + } + + fun `test completed search starts collapsed and expands output`() { + val view = SearchToolView(tool().also { it.output = "src/A.kt:1:class A" }) + + assertTrue(view.hasToggle()) + assertFalse(view.isExpanded()) + assertFalse(view.bodyVisible()) + assertEquals("src/A.kt:1:class A", view.bodyText()) + + view.toggle() + + assertTrue(view.isExpanded()) + assertTrue(view.bodyVisible()) + assertEquals("src/A.kt:1:class A", view.bodyText()) + } + + fun `test search body is lazy and reused`() { + val view = SearchToolView(tool().also { it.output = "src/A.kt" }) + + assertFalse(view.bodyCreated()) + view.toggle() + val body = view.scrollComponent() + assertNotNull(body) + + view.toggle() + assertFalse(view.bodyVisible()) + view.toggle() + + assertSame(body, view.scrollComponent()) + assertTrue(view.bodyVisible()) + } + + fun `test collapsed update keeps search body uncreated`() { + val view = SearchToolView(tool().also { it.output = "src/A.kt" }) + + view.update(tool().also { it.output = "src/B.kt" }) + + assertFalse(view.bodyCreated()) + assertEquals("src/B.kt", view.bodyText()) + } + + fun `test long targets stay horizontal and do not force header wider`() { + val view = SearchToolView(tool().also { + it.input = mapOf( + "pattern" to "a".repeat(200), + "include" to "**/*.${"b".repeat(200)}.kt", + ) + }) + val header = view.headerComponent() + header.setSize(Dimension(240, header.preferredSize.height)) + + layout(header) + + assertTrue(view.centerComponent().width <= header.width) + val labels = view.targetComponents().filter { it.isVisible } + assertEquals(labels.first().y, labels.last().y) + labels.forEach { + assertTrue(it.width <= view.centerComponent().width) + } + } + + fun `test view factory routes grep to search tool view`() { + assertTrue(ViewFactory.create(tool(), openFile = {}) is SearchToolView) + } + + fun `test should replace when search renderer changes`() { + val search = tool() + val read = Tool("p1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED } + val glob = Tool("p2", "glob", toolKind("glob")).also { it.state = ToolExecState.COMPLETED } + + assertTrue(ViewFactory.shouldReplace(ReadToolView(read), search)) + assertTrue(ViewFactory.shouldReplace(ToolView(read), search)) + assertTrue(ViewFactory.shouldReplace(SearchToolView(search), read)) + assertTrue(ViewFactory.shouldReplace(GlobToolView(glob, selection = null), search)) + assertFalse(ViewFactory.shouldReplace(SearchToolView(search), search)) + } + + private fun layout(root: Container) { + root.doLayout() + root.components.filterIsInstance().forEach { layout(it) } + } + + private fun tool() = Tool("p1", "grep", toolKind("grep")).also { it.state = ToolExecState.COMPLETED } +} From 7afccae4ec31147dce828638a4d941e684ddd992 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 8 Jun 2026 14:06:28 -0400 Subject: [PATCH 08/26] refactor(jetbrains): split tool view renderers --- .../kilocode/client/session/views/ToolView.kt | 893 ------------------ .../client/session/views/ViewFactory.kt | 4 + .../views/question/QuestionResultView.kt | 2 +- .../session/views/tool/BaseSearchToolView.kt | 150 +++ .../client/session/views/tool/GlobToolView.kt | 22 + .../client/session/views/tool/ReadToolView.kt | 144 +++ .../session/views/tool/SearchToolView.kt | 22 + .../client/session/views/tool/ToolSupport.kt | 446 +++++++++ .../client/session/views/tool/ToolView.kt | 147 +++ .../session/ui/SessionMessageListPanelTest.kt | 2 +- .../session/ui/SessionSelectionCopyTest.kt | 2 +- .../client/session/ui/SessionUiUpdateTest.kt | 8 +- .../client/session/views/GlobToolViewTest.kt | 13 +- .../client/session/views/ReadToolViewTest.kt | 3 + .../session/views/SearchToolViewTest.kt | 4 + .../client/session/views/ToolViewTest.kt | 1 + 16 files changed, 957 insertions(+), 906 deletions(-) delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/GlobToolView.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/SearchToolView.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt deleted file mode 100644 index 7192e8f5e5e..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt +++ /dev/null @@ -1,893 +0,0 @@ -@file:Suppress("TooManyFunctions") - -package ai.kilocode.client.session.views - -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.model.ToolExecState -import ai.kilocode.client.session.model.ToolKind -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.SecondarySessionPartView -import ai.kilocode.client.ui.layout.HAlign -import ai.kilocode.client.ui.layout.Stack -import ai.kilocode.client.ui.layout.VAlign -import ai.kilocode.client.ui.layout.align -import ai.kilocode.client.ui.UiStyle -import com.intellij.icons.AllIcons -import com.intellij.ui.components.JBLabel -import com.intellij.ui.components.JBScrollPane -import com.intellij.ui.components.JBTextArea -import com.intellij.util.ui.JBUI -import com.intellij.xml.util.XmlStringUtil -import java.awt.BorderLayout -import java.awt.CardLayout -import java.awt.Color -import java.awt.Cursor -import java.awt.Dimension -import java.awt.Font -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent -import javax.swing.Icon -import javax.swing.JComponent -import javax.swing.JPanel -import javax.swing.ScrollPaneConstants - -/** Renders non-read tool calls with VS Code-inspired rows/cards. */ -class ToolView( - tool: Tool, - private val selection: SessionSelection? = null, - private val parts: ToolParts = toolParts(tool), -) : - SecondarySessionPartView(parts.header, { parts.scroll(tool) }) { - - override val contentId: String = tool.id - - private var item = tool - private var style = SessionEditorStyle.current() - private var registered = false - - init { - bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) - applyStyle(style) - sync() - } - - override fun expand(): Boolean { - val changed = super.expand() - if (!changed) return false - syncBody() - applyBodyStyle() - return true - } - - override fun getPreferredSize(): Dimension { - val size = super.getPreferredSize() - if (!bodyVisible()) return size - val height = row.preferredSize.height + bodyMaxHeight() - return Dimension(size.width, minOf(size.height, height)) - } - - 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() - } - - fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text) - .filter { it.isNotBlank() } - .joinToString(" ") - - fun commandText(): String = command(item) - - fun outputText(): String = output(item) - fun bodyText(): String = body(item) - internal fun previewText(): String = parts.text?.text ?: preview(item) - fun hasToggle(): Boolean = arrow.isVisible - internal fun bodyFont() = parts.text?.font ?: style.transcriptFont - internal fun titleFont() = parts.title.font - internal fun subtitleFont() = parts.sub.font - internal fun stateFont() = parts.state.font - internal fun bodyEditable() = parts.text?.isEditable ?: false - internal fun bodyCaretVisible() = parts.text?.caret?.isVisible ?: false - internal fun bodyVisible() = parts.scroll?.parent === this - internal fun controlCount() = if (arrow.isVisible) 1 else 0 - internal fun horizontalPolicy() = parts.scroll?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - internal fun bodyWrap() = parts.text?.lineWrap ?: true - internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES - internal fun bodyCreated() = parts.bodyCreated() - - 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 - changed = applyBodyStyle() || 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 = syncLabels() || changed - val text = parts.text - if (text != null) changed = setForeground(text, bodyColor()) || changed - return changed - } - - private fun syncLabels(): Boolean { - var changed = false - 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 { - var changed = false - val text = parts.text ?: return false - val value = preview(item) - if (text.text != value) { - text.text = value - text.caretPosition = 0 - changed = true - } - changed = setForeground(text, bodyColor()) || changed - return changed - } - - private fun applyBodyStyle(): Boolean { - val text = parts.text ?: return false - if (!registered && selection != null && text.parent != null) { - registered = true - selection.register(text, this) - } - return setFont(text, style.transcriptFont) - } - - private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() - - private fun bodyMaxHeight(): Int { - val text = parts.text ?: return 0 - return text.getFontMetrics(text.font).height * bodyMaxRows() + - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_BODY_EXTRA_HEIGHT) - } - - override fun dumpLabel() = "ToolView#$contentId(${labelText()})" -} - -/** Renders read calls with secondary, borderless chrome. */ -class ReadToolView( - tool: Tool, - openFile: (String) -> Unit = {}, - private val selection: SessionSelection? = null, - private val parts: ToolParts = toolParts(tool, openFile), -) : SecondarySessionPartView(parts.header, parts.scroll(tool), expandable = false) { - - companion object { - fun canRender(tool: Tool): Boolean = tool.kind == ToolKind.READ - } - - override val contentId: String = tool.id - - private var item = tool - private var style = SessionEditorStyle.current() - - init { - parts.text?.let { selection?.register(it, this) } - bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) - parts.text?.text = preview(item) - applyStyle(style) - sync() - } - - override fun getPreferredSize(): Dimension { - val size = super.getPreferredSize() - if (!bodyVisible()) return size - val height = row.preferredSize.height + bodyMaxHeight() - return Dimension(size.width, minOf(size.height, height)) - } - - override fun update(content: Content) { - if (content !is Tool) return - item = content - var changed = sync() - changed = syncBody() || changed - if (changed) refresh() - } - - fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text) - .filter { it.isNotBlank() } - .joinToString(" ") - fun bodyText(): String = body(item) - internal fun bodyVisible() = parts.scroll?.parent === this - internal fun hasToggle() = arrow.isVisible - internal fun horizontalPolicy() = parts.scroll?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES - internal fun bodyFont() = parts.text?.font ?: style.transcriptFont - internal fun linkVisible() = parts.link.isVisible - internal fun linkText() = parts.label - internal fun linkMarkup() = parts.link.text ?: "" - internal fun linkForeground() = parts.link.foreground - internal fun linkFont() = parts.link.font - internal fun subtitleForeground() = parts.sub.foreground - internal fun subtitleFont() = parts.sub.font - internal fun linkHref() = parts.href - internal fun openLink() = parts.openLink() - - override fun applyStyle(style: SessionEditorStyle) { - this.style = style - var changed = false - changed = setFont(parts.title, style.boldEditorFont) || changed - changed = setFont(parts.sub, style.transcriptFont) || changed - changed = setFont(parts.link, style.transcriptFont) || changed - changed = setFont(parts.state, style.smallEditorFont) || changed - parts.text?.let { changed = setFont(it, style.transcriptFont) || changed } - if (changed) refresh() - } - - private fun sync(): Boolean { - var changed = false - changed = syncExpandable(false) || changed - changed = setVisible(parts.state, true) || changed - changed = setIcon(parts.glyph, icon(item)) || changed - changed = setForeground(parts.glyph, color(item)) || changed - changed = setText(parts.title, title(item)) || changed - changed = syncSubtitle() || changed - changed = setForeground(parts.title, titleColor(item)) || changed - changed = setForeground(parts.sub, UiStyle.Colors.fg()) || changed - changed = setForeground(parts.link, UiStyle.Colors.fg()) || changed - changed = setText(parts.state, stateText(item)) || changed - changed = setForeground(parts.state, color(item)) || changed - parts.text?.let { changed = setForeground(it, bodyColor()) || changed } - return changed - } - - private fun syncSubtitle(): Boolean { - val target = target(item)?.takeIf { it.type == "file" } - if (target != null) { - var changed = false - if (parts.href != target.path) { - parts.href = target.path - changed = true - } - changed = setLinkText(parts, tail(target.path).ifBlank { target.path }) || changed - changed = show(parts, true) || changed - return changed - } - - var changed = false - if (parts.href != null) { - parts.href = null - changed = true - } - changed = setText(parts.sub, subtitle(item)) || changed - changed = show(parts, false) || changed - return changed - } - - private fun syncBody(): Boolean { - val value = preview(item) - val text = parts.text ?: return false - if (text.text == value) return false - text.text = value - text.caretPosition = 0 - return true - } - - private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() - - private fun bodyMaxHeight(): Int { - val text = parts.text ?: return 0 - return text.getFontMetrics(text.font).height * bodyMaxRows() + - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_BODY_EXTRA_HEIGHT) - } - - override fun dumpLabel() = "ReadToolView#$contentId(${labelText()})" -} - -abstract class BaseSearchToolView( - tool: Tool, - private val selection: SessionSelection? = null, - private val parts: ToolParts, -) : SecondarySessionPartView(parts.header, { parts.scroll(tool) }) { - - override val contentId: String = tool.id - - protected var item = tool - private var style = SessionEditorStyle.current() - private var registered = false - - protected abstract fun toolIcon(tool: Tool): Icon - protected abstract fun toolTitle(tool: Tool): String - protected abstract fun targets(tool: Tool): List - protected abstract fun viewName(): String - - init { - bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) - parts.targets.forEach { bindHeader(it) } - applyStyle(style) - sync() - } - - override fun expand(): Boolean { - val changed = super.expand() - if (!changed) return false - syncBody() - applyBodyStyle() - return true - } - - override fun getPreferredSize(): Dimension { - val size = super.getPreferredSize() - if (!bodyVisible()) return size - val height = row.preferredSize.height + bodyMaxHeight() - return Dimension(size.width, minOf(size.height, height)) - } - - override fun update(content: Content) { - if (content !is Tool) return - item = content - var changed = sync() - changed = syncBody() || changed - if (changed) refresh() - } - - fun labelText(): String = listOf(parts.title.text).plus(targetTexts()).plus(parts.state.text) - .filter { it.isNotBlank() } - .joinToString(" ") - - fun bodyText(): String = body(item) - internal fun targetTexts(): List = parts.targets.map { it.text }.filter { it.isNotBlank() } - internal fun targetVisible(index: Int): Boolean = parts.targets.getOrNull(index)?.isVisible ?: false - internal fun bodyVisible() = parts.scroll?.parent === this - internal fun hasToggle() = arrow.isVisible - internal fun bodyFont() = parts.text?.font ?: style.transcriptFont - internal fun titleFont() = parts.title.font - internal fun targetFont(index: Int) = parts.targets.getOrNull(index)?.font ?: style.smallEditorFont - internal fun stateFont() = parts.state.font - internal fun bodyCreated() = parts.bodyCreated() - internal fun scrollComponent() = parts.scroll - internal fun headerComponent() = parts.header - internal fun centerComponent() = parts.center - internal fun targetComponents() = parts.targets - - 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 - parts.targets.forEach { changed = setFont(it, style.smallEditorFont) || changed } - changed = setFont(parts.state, style.smallEditorFont) || changed - changed = applyBodyStyle() || changed - if (changed) refresh() - } - - private fun sync(): Boolean { - val expand = canExpand(item) - var changed = false - changed = syncExpandable(expand) || changed - changed = setVisible(parts.state, item.state != ToolExecState.COMPLETED) || changed - changed = setIcon(parts.glyph, toolIcon(item)) || changed - changed = setForeground(parts.glyph, color(item)) || changed - changed = setText(parts.title, toolTitle(item)) || changed - changed = setForeground(parts.title, titleColor(item)) || changed - changed = setForeground(parts.sub, UiStyle.Colors.weak()) || changed - changed = syncTargets() || changed - changed = setText(parts.state, stateText(item)) || changed - changed = setForeground(parts.state, color(item)) || changed - parts.text?.let { changed = setForeground(it, bodyColor()) || changed } - return changed - } - - private fun syncTargets(): Boolean { - val values = targets(item) - var changed = false - parts.targets.forEachIndexed { index, label -> - val text = values.getOrNull(index) ?: "" - changed = setVisible(label, text.isNotBlank()) || changed - changed = setPlainText(label, text) || changed - changed = setForeground(label, UiStyle.Colors.weak()) || changed - } - return changed - } - - private fun syncBody(): Boolean { - val text = parts.text ?: return false - val value = plainBody(item) - if (text.text != value) { - text.text = value - text.caretPosition = 0 - return true - } - return false - } - - private fun applyBodyStyle(): Boolean { - val text = parts.text ?: return false - if (!registered && selection != null && text.parent != null) { - registered = true - selection.register(text, this) - } - return setFont(text, style.transcriptFont) - } - - private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() - - private fun bodyMaxHeight(): Int { - val text = parts.text ?: return 0 - return text.getFontMetrics(text.font).height * SessionUiStyle.View.Tool.BODY_LINES + - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_BODY_EXTRA_HEIGHT) - } - - override fun dumpLabel() = "${viewName()}#$contentId(${labelText()})" -} - -/** Renders glob calls with a stacked, collapsible search-result header. */ -class GlobToolView( - tool: Tool, - selection: SessionSelection? = null, - parts: ToolParts = searchParts(2), -) : BaseSearchToolView(tool, selection, parts) { - - companion object { - fun canRender(tool: Tool): Boolean = tool.name == "glob" - } - - internal fun directoryText(): String = globDirectory(item) - internal fun patternText(): String = globPattern(item) - internal fun patternVisible(): Boolean = targetVisible(1) - internal fun directoryFont() = targetFont(0) - internal fun patternFont() = targetFont(1) - - override fun toolIcon(tool: Tool) = icon(tool) - override fun toolTitle(tool: Tool) = KiloBundle.message("session.part.tool.glob") - override fun targets(tool: Tool) = listOf(globDirectory(tool), globPattern(tool)) - override fun viewName() = "GlobToolView" -} - -/** Renders grep/content-search calls with stacked, clipped search targets. */ -class SearchToolView( - tool: Tool, - selection: SessionSelection? = null, -) : BaseSearchToolView(tool, selection, searchParts(3)) { - - companion object { - fun canRender(tool: Tool): Boolean = tool.name == "grep" - } - - override fun toolIcon(tool: Tool) = AllIcons.Actions.Search - override fun toolTitle(tool: Tool) = KiloBundle.message("session.part.tool.search") - override fun targets(tool: Tool) = searchTargets(tool) - override fun viewName() = "SearchToolView" -} - -class ToolParts( - val header: JPanel, - val glyph: JBLabel, - val title: JBLabel, - val sub: JBLabel, - val link: JBLabel, - val slot: JPanel, - val state: JBLabel, - val center: JPanel, - val controls: JComponent, - private val open: ((String) -> Unit)? = null, - val extra: JBLabel? = null, - val targets: List = emptyList(), -) { - var href: String? = null - var label: String = "" - private var body: ToolBody? = null - - val text: JBTextArea? - get() = body?.text - - val scroll: JBScrollPane? - get() = body?.scroll - - fun scroll(tool: Tool): JBScrollPane = body(tool).scroll - - fun bodyCreated() = body != null - - fun openLink() { - val value = href ?: return - open?.invoke(value) - } - - private fun body(tool: Tool): ToolBody { - val item = body - if (item != null) return item - val text = JBTextArea().apply { - isEditable = false - caret.isVisible = false - caret.isSelectionVisible = true - lineWrap = true - wrapStyleWord = true - foreground = if (tool.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() - background = SessionUiStyle.View.surface() - border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING), - ) - } - val scroll = JBScrollPane(text).apply { - border = SessionUiStyle.View.topOutline() - isOpaque = true - background = SessionUiStyle.View.surface() - viewport.background = SessionUiStyle.View.surface() - horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED - } - return ToolBody(text, scroll).also { body = it } - } -} - -class ToolBody( - val text: JBTextArea, - val scroll: JBScrollPane, -) - -private const val SUB_CARD = "sub" -private const val LINK_CARD = "link" - -private fun toolParts(tool: Tool, openFile: ((String) -> Unit)? = null): ToolParts { - lateinit var parts: ToolParts - val glyph = JBLabel() - val title = JBLabel() - val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } - val link = JBLabel().apply { - isVisible = false - isFocusable = false - foreground = UiStyle.Colors.fg() - cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) - setRequestFocusEnabled(false) - addMouseListener(object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - parts.openLink() - } - }) - } - val slot = JPanel(CardLayout()).apply { - isOpaque = false - add(sub, SUB_CARD) - add(link, LINK_CARD) - } - val state = JBLabel().apply { foreground = UiStyle.Colors.weak() } - val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { isOpaque = false } - val controls = Stack.horizontal() - val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { - isOpaque = false - center.add(title, BorderLayout.WEST) - center.add(slot, BorderLayout.CENTER) - add(glyph, BorderLayout.WEST) - add(center, BorderLayout.CENTER) - add(controls, BorderLayout.EAST) - } - parts = ToolParts(header, glyph, title, sub, link, slot, state, center, controls, openFile) - return parts.also { - controls.add(it.state) - } -} - -private fun searchParts(count: Int): ToolParts { - val glyph = JBLabel() - val title = JBLabel() - val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } - val targets = List(count) { - JBLabel().apply { - foreground = UiStyle.Colors.weak() - minimumSize = Dimension(0, minimumSize.height) - } - } - val link = JBLabel().apply { isVisible = false } - val slot = JPanel(CardLayout()).apply { - isOpaque = false - add(sub, SUB_CARD) - add(link, LINK_CARD) - } - val state = JBLabel().apply { foreground = UiStyle.Colors.weak() } - val stack = Stack.fitHorizontal(UiStyle.Gap.xs()).apply { targets.forEach { next(it) } } - val target = stack.align(HAlign.TRACK, VAlign.CENTER) - val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { - isOpaque = false - minimumSize = Dimension(0, minimumSize.height) - add(title, BorderLayout.WEST) - add(target, BorderLayout.CENTER) - } - val controls = Stack.horizontal() - val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { - isOpaque = false - add(glyph, BorderLayout.WEST) - add(center, BorderLayout.CENTER) - add(controls, BorderLayout.EAST) - } - return ToolParts(header, glyph, title, sub, link, slot, state, center, controls, targets = targets).also { - controls.add(it.state) - } -} - -private fun icon(tool: Tool) = when (tool.name) { - "read" -> AllIcons.Actions.Preview - "bash" -> AllIcons.Debugger.Console - else -> when (tool.state) { - ToolExecState.PENDING -> AllIcons.Process.Step_1 - ToolExecState.RUNNING -> AllIcons.Process.Step_2 - ToolExecState.COMPLETED -> AllIcons.Actions.Checked - ToolExecState.ERROR -> AllIcons.General.Error - } -} - -private fun title(tool: Tool) = when (tool.name) { - "read" -> KiloBundle.message("session.part.tool.read") - "bash" -> KiloBundle.message("session.part.tool.shell") - else -> toolTitle(tool) -} - -private fun subtitle(tool: Tool) = when (tool.name) { - "read" -> readPath(tool) - "bash" -> shellTitle(tool) - else -> toolSubtitle(tool) -} - -private fun setText(label: JBLabel, text: String): Boolean { - val value = if (text.isBlank()) "" else XmlStringUtil.wrapInHtml(XmlStringUtil.escapeString(text)) - if (label.text == value) return false - label.text = value - return true -} - -private fun setPlainText(label: JBLabel, text: String): Boolean { - if (label.text == text) return false - label.text = text - return true -} - -private fun setLinkText(parts: ToolParts, text: String): Boolean { - val value = if (text.isBlank()) "" else XmlStringUtil.wrapInHtml("${XmlStringUtil.escapeString(text)}") - if (parts.label == text && parts.link.text == value) return false - parts.label = text - parts.link.text = value - return true -} - -private fun show(parts: ToolParts, link: Boolean): Boolean { - if (parts.link.isVisible == link && parts.sub.isVisible != link) return false - (parts.slot.layout as CardLayout).show(parts.slot, if (link) LINK_CARD else SUB_CARD) - return true -} - -private fun subtitleText(parts: ToolParts): String = if (parts.link.isVisible) parts.label else parts.sub.text - -private fun setIcon(label: JBLabel, icon: Icon): Boolean { - if (label.icon === icon) return false - label.icon = icon - return true -} - -private fun setVisible(component: JComponent, visible: Boolean): Boolean { - if (component.isVisible == visible) return false - component.isVisible = visible - return true -} - -private fun setForeground(component: JComponent, color: Color): Boolean { - if (same(component.foreground, color)) return false - component.foreground = color - return true -} - -private fun setFont(component: JComponent, font: Font): Boolean { - if (component.font == font) return false - component.font = font - return true -} - -private fun same(a: Color?, b: Color): Boolean = a?.rgb == b.rgb - -private fun color(tool: Tool) = when (tool.state) { - ToolExecState.PENDING -> SessionUiStyle.View.Tool.pending() - ToolExecState.RUNNING -> SessionUiStyle.View.Tool.running() - ToolExecState.COMPLETED -> SessionUiStyle.View.Tool.completed() - ToolExecState.ERROR -> SessionUiStyle.View.Tool.error() -} - -private fun titleColor(tool: Tool) = if (tool.state == ToolExecState.ERROR) { - UiStyle.Colors.errorLabelForeground() -} else { - UiStyle.Colors.fg() -} - -private fun stateText(tool: Tool) = when (tool.state) { - ToolExecState.PENDING -> KiloBundle.message("session.part.tool.pending") - ToolExecState.RUNNING -> KiloBundle.message("session.part.tool.running") - ToolExecState.COMPLETED -> "" - ToolExecState.ERROR -> KiloBundle.message("session.part.tool.error") -} - -private fun readPath(tool: Tool): String { - val target = target(tool) - if (target != null) { - if (target.type == "file") return tail(target.path).ifBlank { target.path } - return target.path - } - val path = tool.input["filePath"] ?: tool.input["path"] ?: tool.title ?: return tool.name - return tail(path).ifBlank { path } -} - -private fun globDirectory(tool: Tool): String = - tool.input["path"]?.takeIf { it.isNotBlank() } - ?: tool.title?.takeIf { it.isNotBlank() } - ?: "" - -private fun globPattern(tool: Tool): String = - tool.input["pattern"]?.takeIf { it.isNotBlank() }?.let { "pattern=$it" } ?: "" - -private fun searchTargets(tool: Tool): List = listOfNotNull( - tool.input["path"]?.takeIf { it.isNotBlank() }, - tool.input["pattern"]?.takeIf { it.isNotBlank() }?.let { "pattern=$it" }, - tool.input["include"]?.takeIf { it.isNotBlank() }?.let { "include=$it" }, -) - -private data class Target( - val path: String, - val type: String, -) - -private fun target(tool: Tool): Target? { - val out = output(tool) - if (out.isBlank()) return null - val path = tag(out, "path") ?: return null - val type = tag(out, "type") ?: return null - return Target(path, type.lowercase()) -} - -private fun tag(text: String, name: String): String? = - Regex("<$name>\\s*([\\s\\S]*?)\\s*") - .find(text) - ?.groupValues - ?.getOrNull(1) - ?.trim() - ?.takeIf { it.isNotBlank() } - -private fun shellTitle(tool: Tool): String = - tool.input["description"]?.takeIf { it.isNotBlank() } - ?: tool.metadata["description"]?.takeIf { it.isNotBlank() } - ?: tool.title?.takeIf { it.isNotBlank() } - ?: command(tool).lineSequence().firstOrNull { it.isNotBlank() } - ?: "" - -private fun command(tool: Tool): String = - tool.input["command"]?.takeIf { it.isNotBlank() } - ?: tool.metadata["command"]?.takeIf { it.isNotBlank() } - ?: "" - -private fun output(tool: Tool): String = - tool.output?.takeIf { it.isNotBlank() } - ?: tool.metadata["output"]?.takeIf { it.isNotBlank() } - ?: "" - -private fun preview(tool: Tool): String = if (tool.name == "bash") shellPreview(tool) else plainPreview(tool) - -private fun body(tool: Tool): String = if (tool.name == "bash") shellBody(tool) else plainBody(tool) - -private fun shellPreview(tool: Tool): String { - val cmd = command(tool) - val out = output(tool) - val err = tool.error?.takeIf { it.isNotBlank() } - return Preview().apply { - if (cmd.isNotBlank()) append("$ ").append(cmd) - if (out.isNotBlank()) { - sep() - append(out) - } - if (err != null) { - sep() - append(err) - } - }.build() -} - -private fun shellBody(tool: Tool): String { - val cmd = command(tool) - val out = output(tool) - val err = tool.error?.takeIf { it.isNotBlank() } - return buildString { - if (cmd.isNotBlank()) append("$ ").append(cmd) - if (out.isNotBlank()) { - if (isNotEmpty()) append("\n\n") - append(out) - } - if (err != null) { - if (isNotEmpty()) append("\n\n") - append(err) - } - } -} - -private fun plainPreview(tool: Tool): String { - val out = output(tool) - val err = tool.error?.takeIf { it.isNotBlank() } - return Preview().apply { - if (out.isNotBlank()) append(out) - if (err != null) { - sep() - append(err) - } - }.build() -} - -private fun plainBody(tool: Tool): String { - val out = output(tool) - val err = tool.error?.takeIf { it.isNotBlank() } - return listOf(out, err).filter { !it.isNullOrBlank() }.joinToString("\n\n") -} - -private fun canExpand(tool: Tool): Boolean { - if (tool.name == "bash") return command(tool).isNotBlank() || output(tool).isNotBlank() || !tool.error.isNullOrBlank() - return output(tool).isNotBlank() || !tool.error.isNullOrBlank() -} - -private fun toolTitle(tool: Tool): String = - tool.title?.takeIf { it.isNotBlank() } - ?: tool.name.replace('_', ' ').replaceFirstChar { it.titlecase() } - -private fun toolSubtitle(tool: Tool): String { - val base = listOf("description", "query", "url", "filePath", "path", "name") - .mapNotNull { tool.input[it]?.takeIf { value -> value.isNotBlank() } } - .firstOrNull() - val args = listOf("pattern", "include", "offset", "limit") - .mapNotNull { key -> tool.input[key]?.takeIf { it.isNotBlank() }?.let { "$key=$it" } } - return listOfNotNull(base).plus(args).joinToString(" ") -} - -private fun tail(path: String): String { - val value = path.trimEnd('/', '\\') - val index = maxOf(value.lastIndexOf('/'), value.lastIndexOf('\\')) - if (index < 0) return value - return value.substring(index + 1) -} - -private class Preview { - private val text = StringBuilder() - private var cut = false - - fun append(value: String): Preview { - if (cut) return this - val rem = SessionUiStyle.View.Tool.PREVIEW_LIMIT - text.length - if (value.length <= rem) { - text.append(value) - return this - } - if (rem > 0) text.append(value, 0, rem) - cut = true - return this - } - - fun sep(): Preview { - if (text.isNotEmpty()) append("\n\n") - return this - } - - fun build(): String { - if (!cut) return text.toString() - if (text.isNotEmpty()) text.append("\n\n") - text.append(KiloBundle.message("session.part.tool.truncated")) - return text.toString() - } -} 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 be28f79170c..96153d33b73 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 @@ -3,6 +3,10 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.views.base.GenericView import ai.kilocode.client.session.views.base.PartView 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.ToolView import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.model.Compaction import ai.kilocode.client.session.model.Content 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 6672c9cf647..e38f6548330 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 @@ -7,7 +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.PartView -import ai.kilocode.client.session.views.ToolView +import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.ui.UiStyle import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt new file mode 100644 index 00000000000..a00575a260f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt @@ -0,0 +1,150 @@ +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +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 com.intellij.util.ui.JBUI +import java.awt.Dimension +import javax.swing.Icon + +abstract class BaseSearchToolView( + tool: Tool, + private val selection: SessionSelection? = null, + private val parts: ToolParts, +) : SecondarySessionPartView(parts.header, { parts.scroll(tool) }) { + + override val contentId: String = tool.id + + protected var item = tool + private var style = SessionEditorStyle.current() + private var registered = false + + protected abstract fun toolIcon(tool: Tool): Icon + protected abstract fun toolTitle(tool: Tool): String + protected abstract fun targets(tool: Tool): List + protected abstract fun viewName(): String + + init { + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) + parts.targets.forEach { bindHeader(it) } + applyStyle(style) + sync() + } + + override fun expand(): Boolean { + val changed = super.expand() + if (!changed) return false + syncBody() + applyBodyStyle() + return true + } + + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + if (!bodyVisible()) return size + val height = row.preferredSize.height + bodyMaxHeight() + return Dimension(size.width, minOf(size.height, height)) + } + + override fun update(content: Content) { + if (content !is Tool) return + item = content + var changed = sync() + changed = syncBody() || changed + if (changed) refresh() + } + + fun labelText(): String = listOf(parts.title.text).plus(targetTexts()).plus(parts.state.text) + .filter { it.isNotBlank() } + .joinToString(" ") + + fun bodyText(): String = body(item) + internal fun targetTexts(): List = parts.targets.map { it.text }.filter { it.isNotBlank() } + internal fun targetVisible(index: Int): Boolean = parts.targets.getOrNull(index)?.isVisible ?: false + internal fun bodyVisible() = parts.scroll?.parent === this + internal fun hasToggle() = arrow.isVisible + internal fun bodyFont() = parts.text?.font ?: style.transcriptFont + internal fun titleFont() = parts.title.font + internal fun targetFont(index: Int) = parts.targets.getOrNull(index)?.font ?: style.smallEditorFont + internal fun stateFont() = parts.state.font + internal fun bodyCreated() = parts.bodyCreated() + internal fun scrollComponent() = parts.scroll + internal fun headerComponent() = parts.header + internal fun centerComponent() = parts.center + internal fun targetComponents() = parts.targets + + 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 + parts.targets.forEach { changed = setFont(it, style.smallEditorFont) || changed } + changed = setFont(parts.state, style.smallEditorFont) || changed + changed = applyBodyStyle() || changed + if (changed) refresh() + } + + private fun sync(): Boolean { + val expand = canExpand(item) + var changed = false + changed = syncExpandable(expand) || changed + changed = setVisible(parts.state, item.state != ToolExecState.COMPLETED) || changed + changed = setIcon(parts.glyph, toolIcon(item)) || changed + changed = setForeground(parts.glyph, color(item)) || changed + changed = setText(parts.title, toolTitle(item)) || changed + changed = setForeground(parts.title, titleColor(item)) || changed + changed = setForeground(parts.sub, UiStyle.Colors.weak()) || changed + changed = syncTargets() || changed + changed = setText(parts.state, stateText(item)) || changed + changed = setForeground(parts.state, color(item)) || changed + parts.text?.let { changed = setForeground(it, bodyColor()) || changed } + return changed + } + + private fun syncTargets(): Boolean { + val values = targets(item) + var changed = false + parts.targets.forEachIndexed { index, label -> + val text = values.getOrNull(index) ?: "" + changed = setVisible(label, text.isNotBlank()) || changed + changed = setPlainText(label, text) || changed + changed = setForeground(label, UiStyle.Colors.weak()) || changed + } + return changed + } + + private fun syncBody(): Boolean { + val text = parts.text ?: return false + val value = plainBody(item) + if (text.text != value) { + text.text = value + text.caretPosition = 0 + return true + } + return false + } + + private fun applyBodyStyle(): Boolean { + val text = parts.text ?: return false + if (!registered && selection != null && text.parent != null) { + registered = true + selection.register(text, this) + } + return setFont(text, style.transcriptFont) + } + + private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() + + private fun bodyMaxHeight(): Int { + val text = parts.text ?: return 0 + return text.getFontMetrics(text.font).height * SessionUiStyle.View.Tool.BODY_LINES + + JBUI.scale(SessionUiStyle.View.SESSION_VIEW_BODY_EXTRA_HEIGHT) + } + + override fun dumpLabel() = "${viewName()}#$contentId(${labelText()})" +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/GlobToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/GlobToolView.kt new file mode 100644 index 00000000000..788c4a29d03 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/GlobToolView.kt @@ -0,0 +1,22 @@ +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.ui.selection.SessionSelection + +/** Renders glob calls with a stacked, collapsible search-result header. */ +class GlobToolView( + tool: Tool, + selection: SessionSelection? = null, + parts: ToolParts = searchParts(2), +) : BaseSearchToolView(tool, selection, parts) { + + companion object { + fun canRender(tool: Tool): Boolean = tool.name == "glob" + } + + override fun toolIcon(tool: Tool) = icon(tool) + override fun toolTitle(tool: Tool) = KiloBundle.message("session.part.tool.glob") + override fun targets(tool: Tool) = listOf(globDirectory(tool), globPattern(tool)) + override fun viewName() = "GlobToolView" +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt new file mode 100644 index 00000000000..fc68a76c122 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt @@ -0,0 +1,144 @@ +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.session.model.Content +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.base.SecondarySessionPartView +import ai.kilocode.client.ui.UiStyle +import com.intellij.util.ui.JBUI +import java.awt.Dimension +import javax.swing.ScrollPaneConstants + +/** Renders read calls with secondary, borderless chrome. */ +class ReadToolView( + tool: Tool, + openFile: (String) -> Unit = {}, + private val selection: SessionSelection? = null, + private val parts: ToolParts = toolParts(tool, openFile), +) : SecondarySessionPartView(parts.header, parts.scroll(tool), expandable = false) { + + companion object { + fun canRender(tool: Tool): Boolean = tool.kind == ToolKind.READ + } + + override val contentId: String = tool.id + + private var item = tool + private var style = SessionEditorStyle.current() + + init { + parts.text?.let { selection?.register(it, this) } + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) + parts.text?.text = preview(item) + applyStyle(style) + sync() + } + + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + if (!bodyVisible()) return size + val height = row.preferredSize.height + bodyMaxHeight() + return Dimension(size.width, minOf(size.height, height)) + } + + override fun update(content: Content) { + if (content !is Tool) return + item = content + var changed = sync() + changed = syncBody() || changed + if (changed) refresh() + } + + fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text) + .filter { it.isNotBlank() } + .joinToString(" ") + fun bodyText(): String = body(item) + internal fun bodyVisible() = parts.scroll?.parent === this + internal fun hasToggle() = arrow.isVisible + internal fun horizontalPolicy() = parts.scroll?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES + internal fun bodyFont() = parts.text?.font ?: style.transcriptFont + internal fun linkVisible() = parts.link.isVisible + internal fun linkText() = parts.label + internal fun linkMarkup() = parts.link.text ?: "" + internal fun linkForeground() = parts.link.foreground + internal fun linkFont() = parts.link.font + internal fun subtitleForeground() = parts.sub.foreground + internal fun subtitleFont() = parts.sub.font + internal fun linkHref() = parts.href + internal fun openLink() = parts.openLink() + + override fun applyStyle(style: SessionEditorStyle) { + this.style = style + var changed = false + changed = setFont(parts.title, style.boldEditorFont) || changed + changed = setFont(parts.sub, style.transcriptFont) || changed + changed = setFont(parts.link, style.transcriptFont) || changed + changed = setFont(parts.state, style.smallEditorFont) || changed + parts.text?.let { changed = setFont(it, style.transcriptFont) || changed } + if (changed) refresh() + } + + private fun sync(): Boolean { + var changed = false + changed = syncExpandable(false) || changed + changed = setVisible(parts.state, true) || changed + changed = setIcon(parts.glyph, icon(item)) || changed + changed = setForeground(parts.glyph, color(item)) || changed + changed = setText(parts.title, title(item)) || changed + changed = syncSubtitle() || changed + changed = setForeground(parts.title, titleColor(item)) || changed + changed = setForeground(parts.sub, UiStyle.Colors.fg()) || changed + changed = setForeground(parts.link, UiStyle.Colors.fg()) || changed + changed = setText(parts.state, stateText(item)) || changed + changed = setForeground(parts.state, color(item)) || changed + parts.text?.let { changed = setForeground(it, bodyColor()) || changed } + return changed + } + + private fun syncSubtitle(): Boolean { + val target = target(item)?.takeIf { it.type == "file" } + if (target != null) { + var changed = false + if (parts.href != target.path) { + parts.href = target.path + changed = true + } + changed = setLinkText(parts, tail(target.path).ifBlank { target.path }) || changed + changed = show(parts, true) || changed + return changed + } + + var changed = false + if (parts.href != null) { + parts.href = null + changed = true + } + changed = setText(parts.sub, subtitle(item)) || changed + changed = show(parts, false) || changed + return changed + } + + private fun syncBody(): Boolean { + val value = preview(item) + val text = parts.text ?: return false + if (text.text == value) return false + text.text = value + text.caretPosition = 0 + return true + } + + private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() + + private fun bodyMaxHeight(): Int { + val text = parts.text ?: return 0 + return text.getFontMetrics(text.font).height * bodyMaxRows() + + JBUI.scale(SessionUiStyle.View.SESSION_VIEW_BODY_EXTRA_HEIGHT) + } + + override fun dumpLabel() = "ReadToolView#$contentId(${labelText()})" +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/SearchToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/SearchToolView.kt new file mode 100644 index 00000000000..e7b93e1351d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/SearchToolView.kt @@ -0,0 +1,22 @@ +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.ui.selection.SessionSelection +import com.intellij.icons.AllIcons + +/** Renders grep/content-search calls with stacked, clipped search targets. */ +class SearchToolView( + tool: Tool, + selection: SessionSelection? = null, +) : BaseSearchToolView(tool, selection, searchParts(3)) { + + companion object { + fun canRender(tool: Tool): Boolean = tool.name == "grep" + } + + override fun toolIcon(tool: Tool) = AllIcons.Actions.Search + override fun toolTitle(tool: Tool) = KiloBundle.message("session.part.tool.search") + override fun targets(tool: Tool) = searchTargets(tool) + override fun viewName() = "SearchToolView" +} 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 new file mode 100644 index 00000000000..9511bf9358b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt @@ -0,0 +1,446 @@ +@file:Suppress("TooManyFunctions") + +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align +import com.intellij.icons.AllIcons +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBTextArea +import com.intellij.util.ui.JBUI +import com.intellij.xml.util.XmlStringUtil +import java.awt.BorderLayout +import java.awt.CardLayout +import java.awt.Color +import java.awt.Cursor +import java.awt.Dimension +import java.awt.Font +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.Icon +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.ScrollPaneConstants + +class ToolParts( + val header: JPanel, + val glyph: JBLabel, + val title: JBLabel, + val sub: JBLabel, + val link: JBLabel, + val slot: JPanel, + val state: JBLabel, + val center: JPanel, + val controls: JComponent, + private val open: ((String) -> Unit)? = null, + val extra: JBLabel? = null, + val targets: List = emptyList(), +) { + var href: String? = null + var label: String = "" + private var body: ToolBody? = null + + val text: JBTextArea? + get() = body?.text + + val scroll: JBScrollPane? + get() = body?.scroll + + fun scroll(tool: Tool): JBScrollPane = body(tool).scroll + + fun bodyCreated() = body != null + + fun openLink() { + val value = href ?: return + open?.invoke(value) + } + + private fun body(tool: Tool): ToolBody { + val item = body + if (item != null) return item + val text = JBTextArea().apply { + isEditable = false + caret.isVisible = false + caret.isSelectionVisible = true + lineWrap = true + wrapStyleWord = true + foreground = if (tool.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() + background = SessionUiStyle.View.surface() + border = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.SESSION_VIEW_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING), + ) + } + val scroll = JBScrollPane(text).apply { + border = SessionUiStyle.View.topOutline() + isOpaque = true + background = SessionUiStyle.View.surface() + viewport.background = SessionUiStyle.View.surface() + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + } + return ToolBody(text, scroll).also { body = it } + } +} + +class ToolBody( + val text: JBTextArea, + val scroll: JBScrollPane, +) + +private const val SUB_CARD = "sub" +private const val LINK_CARD = "link" + +internal fun toolParts(tool: Tool, openFile: ((String) -> Unit)? = null): ToolParts { + lateinit var parts: ToolParts + val glyph = JBLabel() + val title = JBLabel() + val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } + val link = JBLabel().apply { + isVisible = false + isFocusable = false + foreground = UiStyle.Colors.fg() + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + setRequestFocusEnabled(false) + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + parts.openLink() + } + }) + } + val slot = JPanel(CardLayout()).apply { + isOpaque = false + add(sub, SUB_CARD) + add(link, LINK_CARD) + } + val state = JBLabel().apply { foreground = UiStyle.Colors.weak() } + val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { isOpaque = false } + val controls = Stack.horizontal() + val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { + isOpaque = false + center.add(title, BorderLayout.WEST) + center.add(slot, BorderLayout.CENTER) + add(glyph, BorderLayout.WEST) + add(center, BorderLayout.CENTER) + add(controls, BorderLayout.EAST) + } + parts = ToolParts(header, glyph, title, sub, link, slot, state, center, controls, openFile) + return parts.also { + controls.add(it.state) + } +} + +internal fun searchParts(count: Int): ToolParts { + val glyph = JBLabel() + val title = JBLabel() + val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } + val targets = List(count) { + JBLabel().apply { + foreground = UiStyle.Colors.weak() + minimumSize = Dimension(0, minimumSize.height) + } + } + val link = JBLabel().apply { isVisible = false } + val slot = JPanel(CardLayout()).apply { + isOpaque = false + add(sub, SUB_CARD) + add(link, LINK_CARD) + } + val state = JBLabel().apply { foreground = UiStyle.Colors.weak() } + val stack = Stack.fitHorizontal(UiStyle.Gap.xs()).apply { targets.forEach { next(it) } } + val target = stack.align(HAlign.TRACK, VAlign.CENTER) + val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { + isOpaque = false + minimumSize = Dimension(0, minimumSize.height) + add(title, BorderLayout.WEST) + add(target, BorderLayout.CENTER) + } + val controls = Stack.horizontal() + val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { + isOpaque = false + add(glyph, BorderLayout.WEST) + add(center, BorderLayout.CENTER) + add(controls, BorderLayout.EAST) + } + return ToolParts(header, glyph, title, sub, link, slot, state, center, controls, targets = targets).also { + controls.add(it.state) + } +} + +internal fun icon(tool: Tool) = when (tool.name) { + "read" -> AllIcons.Actions.Preview + "bash" -> AllIcons.Debugger.Console + else -> when (tool.state) { + ToolExecState.PENDING -> AllIcons.Process.Step_1 + ToolExecState.RUNNING -> AllIcons.Process.Step_2 + ToolExecState.COMPLETED -> AllIcons.Actions.Checked + ToolExecState.ERROR -> AllIcons.General.Error + } +} + +internal fun title(tool: Tool) = when (tool.name) { + "read" -> KiloBundle.message("session.part.tool.read") + "bash" -> KiloBundle.message("session.part.tool.shell") + else -> toolTitle(tool) +} + +internal fun subtitle(tool: Tool) = when (tool.name) { + "read" -> readPath(tool) + "bash" -> shellTitle(tool) + else -> toolSubtitle(tool) +} + +internal fun setText(label: JBLabel, text: String): Boolean { + val value = if (text.isBlank()) "" else XmlStringUtil.wrapInHtml(XmlStringUtil.escapeString(text)) + if (label.text == value) return false + label.text = value + return true +} + +internal fun setPlainText(label: JBLabel, text: String): Boolean { + if (label.text == text) return false + label.text = text + return true +} + +internal fun setLinkText(parts: ToolParts, text: String): Boolean { + val value = if (text.isBlank()) "" else XmlStringUtil.wrapInHtml("${XmlStringUtil.escapeString(text)}") + if (parts.label == text && parts.link.text == value) return false + parts.label = text + parts.link.text = value + return true +} + +internal fun show(parts: ToolParts, link: Boolean): Boolean { + if (parts.link.isVisible == link && parts.sub.isVisible != link) return false + (parts.slot.layout as CardLayout).show(parts.slot, if (link) LINK_CARD else SUB_CARD) + return true +} + +internal fun subtitleText(parts: ToolParts): String = if (parts.link.isVisible) parts.label else parts.sub.text + +internal fun setIcon(label: JBLabel, icon: Icon): Boolean { + if (label.icon === icon) return false + label.icon = icon + return true +} + +internal fun setVisible(component: JComponent, visible: Boolean): Boolean { + if (component.isVisible == visible) return false + component.isVisible = visible + return true +} + +internal fun setForeground(component: JComponent, color: Color): Boolean { + if (same(component.foreground, color)) return false + component.foreground = color + return true +} + +internal fun setFont(component: JComponent, font: Font): Boolean { + if (component.font == font) return false + component.font = font + return true +} + +private fun same(a: Color?, b: Color): Boolean = a?.rgb == b.rgb + +internal fun color(tool: Tool) = when (tool.state) { + ToolExecState.PENDING -> SessionUiStyle.View.Tool.pending() + ToolExecState.RUNNING -> SessionUiStyle.View.Tool.running() + ToolExecState.COMPLETED -> SessionUiStyle.View.Tool.completed() + ToolExecState.ERROR -> SessionUiStyle.View.Tool.error() +} + +internal fun titleColor(tool: Tool) = if (tool.state == ToolExecState.ERROR) { + UiStyle.Colors.errorLabelForeground() +} else { + UiStyle.Colors.fg() +} + +internal fun stateText(tool: Tool) = when (tool.state) { + ToolExecState.PENDING -> KiloBundle.message("session.part.tool.pending") + ToolExecState.RUNNING -> KiloBundle.message("session.part.tool.running") + ToolExecState.COMPLETED -> "" + ToolExecState.ERROR -> KiloBundle.message("session.part.tool.error") +} + +private fun readPath(tool: Tool): String { + val target = target(tool) + if (target != null) { + if (target.type == "file") return tail(target.path).ifBlank { target.path } + return target.path + } + val path = tool.input["filePath"] ?: tool.input["path"] ?: tool.title ?: return tool.name + return tail(path).ifBlank { path } +} + +internal fun globDirectory(tool: Tool): String = + tool.input["path"]?.takeIf { it.isNotBlank() } + ?: tool.title?.takeIf { it.isNotBlank() } + ?: "" + +internal fun globPattern(tool: Tool): String = + tool.input["pattern"]?.takeIf { it.isNotBlank() }?.let { "pattern=$it" } ?: "" + +internal fun searchTargets(tool: Tool): List = listOfNotNull( + tool.input["path"]?.takeIf { it.isNotBlank() }, + tool.input["pattern"]?.takeIf { it.isNotBlank() }?.let { "pattern=$it" }, + tool.input["include"]?.takeIf { it.isNotBlank() }?.let { "include=$it" }, +) + +internal data class Target( + val path: String, + val type: String, +) + +internal fun target(tool: Tool): Target? { + val out = output(tool) + if (out.isBlank()) return null + val path = tag(out, "path") ?: return null + val type = tag(out, "type") ?: return null + return Target(path, type.lowercase()) +} + +private fun tag(text: String, name: String): String? = + Regex("<$name>\\s*([\\s\\S]*?)\\s*") + .find(text) + ?.groupValues + ?.getOrNull(1) + ?.trim() + ?.takeIf { it.isNotBlank() } + +private fun shellTitle(tool: Tool): String = + tool.input["description"]?.takeIf { it.isNotBlank() } + ?: tool.metadata["description"]?.takeIf { it.isNotBlank() } + ?: tool.title?.takeIf { it.isNotBlank() } + ?: command(tool).lineSequence().firstOrNull { it.isNotBlank() } + ?: "" + +internal fun command(tool: Tool): String = + tool.input["command"]?.takeIf { it.isNotBlank() } + ?: tool.metadata["command"]?.takeIf { it.isNotBlank() } + ?: "" + +internal fun output(tool: Tool): String = + tool.output?.takeIf { it.isNotBlank() } + ?: tool.metadata["output"]?.takeIf { it.isNotBlank() } + ?: "" + +internal fun preview(tool: Tool): String = if (tool.name == "bash") shellPreview(tool) else plainPreview(tool) + +internal fun body(tool: Tool): String = if (tool.name == "bash") shellBody(tool) else plainBody(tool) + +private fun shellPreview(tool: Tool): String { + val cmd = command(tool) + val out = output(tool) + val err = tool.error?.takeIf { it.isNotBlank() } + return Preview().apply { + if (cmd.isNotBlank()) append("$ ").append(cmd) + if (out.isNotBlank()) { + sep() + append(out) + } + if (err != null) { + sep() + append(err) + } + }.build() +} + +private fun shellBody(tool: Tool): String { + val cmd = command(tool) + val out = output(tool) + val err = tool.error?.takeIf { it.isNotBlank() } + return buildString { + if (cmd.isNotBlank()) append("$ ").append(cmd) + if (out.isNotBlank()) { + if (isNotEmpty()) append("\n\n") + append(out) + } + if (err != null) { + if (isNotEmpty()) append("\n\n") + append(err) + } + } +} + +private fun plainPreview(tool: Tool): String { + val out = output(tool) + val err = tool.error?.takeIf { it.isNotBlank() } + return Preview().apply { + if (out.isNotBlank()) append(out) + if (err != null) { + sep() + append(err) + } + }.build() +} + +internal fun plainBody(tool: Tool): String { + val out = output(tool) + val err = tool.error?.takeIf { it.isNotBlank() } + return listOf(out, err).filter { !it.isNullOrBlank() }.joinToString("\n\n") +} + +internal fun canExpand(tool: Tool): Boolean { + if (tool.name == "bash") return command(tool).isNotBlank() || output(tool).isNotBlank() || !tool.error.isNullOrBlank() + return output(tool).isNotBlank() || !tool.error.isNullOrBlank() +} + +private fun toolTitle(tool: Tool): String = + tool.title?.takeIf { it.isNotBlank() } + ?: tool.name.replace('_', ' ').replaceFirstChar { it.titlecase() } + +private fun toolSubtitle(tool: Tool): String { + val base = listOf("description", "query", "url", "filePath", "path", "name") + .mapNotNull { tool.input[it]?.takeIf { value -> value.isNotBlank() } } + .firstOrNull() + val args = listOf("pattern", "include", "offset", "limit") + .mapNotNull { key -> tool.input[key]?.takeIf { it.isNotBlank() }?.let { "$key=$it" } } + return listOfNotNull(base).plus(args).joinToString(" ") +} + +internal fun tail(path: String): String { + val value = path.trimEnd('/', '\\') + val index = maxOf(value.lastIndexOf('/'), value.lastIndexOf('\\')) + if (index < 0) return value + return value.substring(index + 1) +} + +private class Preview { + private val text = StringBuilder() + private var cut = false + + fun append(value: String): Preview { + if (cut) return this + val rem = SessionUiStyle.View.Tool.PREVIEW_LIMIT - text.length + if (value.length <= rem) { + text.append(value) + return this + } + if (rem > 0) text.append(value, 0, rem) + cut = true + return this + } + + fun sep(): Preview { + if (text.isNotEmpty()) append("\n\n") + return this + } + + fun build(): String { + if (!cut) return text.toString() + if (text.isNotEmpty()) text.append("\n\n") + text.append(KiloBundle.message("session.part.tool.truncated")) + return text.toString() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt new file mode 100644 index 00000000000..66e57435b11 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt @@ -0,0 +1,147 @@ +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +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 com.intellij.util.ui.JBUI +import java.awt.Dimension +import javax.swing.ScrollPaneConstants + +/** Renders non-read tool calls with VS Code-inspired rows/cards. */ +class ToolView( + tool: Tool, + private val selection: SessionSelection? = null, + private val parts: ToolParts = toolParts(tool), +) : SecondarySessionPartView(parts.header, { parts.scroll(tool) }) { + + override val contentId: String = tool.id + + private var item = tool + private var style = SessionEditorStyle.current() + private var registered = false + + init { + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) + applyStyle(style) + sync() + } + + override fun expand(): Boolean { + val changed = super.expand() + if (!changed) return false + syncBody() + applyBodyStyle() + return true + } + + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + if (!bodyVisible()) return size + val height = row.preferredSize.height + bodyMaxHeight() + return Dimension(size.width, minOf(size.height, height)) + } + + 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() + } + + fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text) + .filter { it.isNotBlank() } + .joinToString(" ") + + fun commandText(): String = command(item) + fun outputText(): String = output(item) + fun bodyText(): String = body(item) + internal fun previewText(): String = parts.text?.text ?: preview(item) + fun hasToggle(): Boolean = arrow.isVisible + internal fun bodyFont() = parts.text?.font ?: style.transcriptFont + internal fun titleFont() = parts.title.font + internal fun subtitleFont() = parts.sub.font + internal fun stateFont() = parts.state.font + internal fun bodyEditable() = parts.text?.isEditable ?: false + internal fun bodyCaretVisible() = parts.text?.caret?.isVisible ?: false + internal fun bodyVisible() = parts.scroll?.parent === this + internal fun controlCount() = if (arrow.isVisible) 1 else 0 + internal fun horizontalPolicy() = parts.scroll?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + internal fun bodyWrap() = parts.text?.lineWrap ?: true + internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES + internal fun bodyCreated() = parts.bodyCreated() + + 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 + changed = applyBodyStyle() || 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 = syncLabels() || changed + val text = parts.text + if (text != null) changed = setForeground(text, bodyColor()) || changed + return changed + } + + private fun syncLabels(): Boolean { + var changed = false + 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 { + var changed = false + val text = parts.text ?: return false + val value = preview(item) + if (text.text != value) { + text.text = value + text.caretPosition = 0 + changed = true + } + changed = setForeground(text, bodyColor()) || changed + return changed + } + + private fun applyBodyStyle(): Boolean { + val text = parts.text ?: return false + if (!registered && selection != null && text.parent != null) { + registered = true + selection.register(text, this) + } + return setFont(text, style.transcriptFont) + } + + private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() + + private fun bodyMaxHeight(): Int { + val text = parts.text ?: return 0 + return text.getFontMetrics(text.font).height * bodyMaxRows() + + JBUI.scale(SessionUiStyle.View.SESSION_VIEW_BODY_EXTRA_HEIGHT) + } + + override fun dumpLabel() = "ToolView#$contentId(${labelText()})" +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index 7b13b4a5f7d..cdb0032ccb0 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -15,7 +15,7 @@ import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionResultView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.session.views.TextView -import ai.kilocode.client.session.views.ToolView +import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.session.views.todo.TodoWriteView import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto 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 0c64f021c9f..b6f7dd12f8d 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,7 +1,7 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.session.SessionUiTestBase -import ai.kilocode.client.session.views.ToolView +import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.PartDto import com.intellij.ide.CopyProvider 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 f8d832cb771..880899b007e 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 @@ -83,7 +83,7 @@ class SessionUiUpdateTest : BasePlatformTestCase() { 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.ToolView + val tv = panel.findMessage("a1")!!.part("t1") as ai.kilocode.client.session.views.tool.ToolView assertFalse(tv.labelText().contains("Running")) } @@ -92,7 +92,7 @@ class SessionUiUpdateTest : BasePlatformTestCase() { model.updateContent("a1", toolPart("t1", "a1", "read", "completed")) val tv = panel.findMessage("a1")!!.part("t1") - assertTrue(tv is ai.kilocode.client.session.views.ReadToolView) + assertTrue(tv is ai.kilocode.client.session.views.tool.ReadToolView) } fun `test glob tool renders as GlobToolView`() { @@ -100,7 +100,7 @@ class SessionUiUpdateTest : BasePlatformTestCase() { model.updateContent("a1", toolPart("t1", "a1", "glob", "completed")) val tv = panel.findMessage("a1")!!.part("t1") - assertTrue(tv is ai.kilocode.client.session.views.GlobToolView) + assertTrue(tv is ai.kilocode.client.session.views.tool.GlobToolView) } fun `test grep tool renders as SearchToolView`() { @@ -108,7 +108,7 @@ class SessionUiUpdateTest : BasePlatformTestCase() { model.updateContent("a1", toolPart("t1", "a1", "grep", "completed")) val tv = panel.findMessage("a1")!!.part("t1") - assertTrue(tv is ai.kilocode.client.session.views.SearchToolView) + assertTrue(tv is ai.kilocode.client.session.views.tool.SearchToolView) } // ------ multiple turns update correctly ------ diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt index 497d10439c9..7867e208555 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt @@ -4,6 +4,9 @@ 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.views.base.SecondarySessionPartView +import ai.kilocode.client.session.views.tool.GlobToolView +import ai.kilocode.client.session.views.tool.ReadToolView +import ai.kilocode.client.session.views.tool.ToolView import com.intellij.testFramework.fixtures.BasePlatformTestCase @Suppress("UnstableApiUsage") @@ -17,9 +20,8 @@ class GlobToolViewTest : BasePlatformTestCase() { assertTrue(base is SecondarySessionPartView) assertTrue(view.labelText().contains("Glob")) - assertEquals("/repo/src", view.directoryText()) - assertEquals("pattern=**/*.kt", view.patternText()) - assertTrue(view.patternVisible()) + assertEquals(listOf("/repo/src", "pattern=**/*.kt"), view.targetTexts()) + assertTrue(view.targetVisible(1)) } fun `test pattern row hides when pattern is absent`() { @@ -27,9 +29,8 @@ class GlobToolViewTest : BasePlatformTestCase() { it.input = mapOf("path" to "/repo/src") }) - assertEquals("/repo/src", view.directoryText()) - assertEquals("", view.patternText()) - assertFalse(view.patternVisible()) + assertEquals(listOf("/repo/src"), view.targetTexts()) + assertFalse(view.targetVisible(1)) } fun `test completed glob starts collapsed and expands output`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt index a3eddef67a7..c5d576f4e39 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt @@ -4,6 +4,9 @@ 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.views.base.SecondarySessionPartView +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.ui.UiStyle import com.intellij.testFramework.fixtures.BasePlatformTestCase import javax.swing.ScrollPaneConstants 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 604f32423a0..575516e6686 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 @@ -4,6 +4,10 @@ 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.views.base.SecondarySessionPartView +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 com.intellij.testFramework.fixtures.BasePlatformTestCase import java.awt.Container import java.awt.Dimension 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 44121d61e17..f65241d6d2c 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 @@ -7,6 +7,7 @@ import ai.kilocode.client.session.model.toolKind 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 com.intellij.testFramework.fixtures.BasePlatformTestCase import javax.swing.ScrollPaneConstants From 5518d6636c0484f5ffa02cd5ea2f32ae8ed0743e Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 8 Jun 2026 15:26:09 -0400 Subject: [PATCH 09/26] feat(jetbrains): render tool output with editor bodies --- .../session/views/tool/BaseSearchToolView.kt | 37 ++-- .../client/session/views/tool/ReadToolView.kt | 3 + .../client/session/views/tool/ToolSupport.kt | 200 ++++++++++++++++-- .../client/session/views/tool/ToolView.kt | 51 +++-- .../client/session/views/GlobToolViewTest.kt | 27 ++- .../client/session/views/ReadToolViewTest.kt | 3 + .../session/views/SearchToolViewTest.kt | 27 ++- .../session/views/ToolBodyStressTest.kt | 38 ++++ .../client/session/views/ToolViewTest.kt | 76 ++++--- 9 files changed, 389 insertions(+), 73 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolBodyStressTest.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt index a00575a260f..1c853c10673 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.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.base.SecondarySessionPartView import ai.kilocode.client.ui.UiStyle +import com.intellij.openapi.util.Disposer import com.intellij.util.ui.JBUI import java.awt.Dimension import javax.swing.Icon @@ -23,6 +24,7 @@ abstract class BaseSearchToolView( protected var item = tool private var style = SessionEditorStyle.current() private var registered = false + private var disposed = false protected abstract fun toolIcon(tool: Tool): Icon protected abstract fun toolTitle(tool: Tool): String @@ -68,12 +70,16 @@ abstract class BaseSearchToolView( internal fun targetVisible(index: Int): Boolean = parts.targets.getOrNull(index)?.isVisible ?: false internal fun bodyVisible() = parts.scroll?.parent === this internal fun hasToggle() = arrow.isVisible - internal fun bodyFont() = parts.text?.font ?: style.transcriptFont + internal fun bodyFont() = parts.content?.font ?: style.editorFont internal fun titleFont() = parts.title.font internal fun targetFont(index: Int) = parts.targets.getOrNull(index)?.font ?: style.smallEditorFont internal fun stateFont() = parts.state.font internal fun bodyCreated() = parts.bodyCreated() internal fun scrollComponent() = parts.scroll + internal fun bodyEditor() = parts.content?.editor + internal fun horizontalPolicy() = parts.scroll?.horizontalScrollBarPolicy + internal fun verticalPolicy() = parts.scroll?.verticalScrollBarPolicy + internal fun bodyWrap() = parts.content?.lineWrap ?: false internal fun headerComponent() = parts.header internal fun centerComponent() = parts.center internal fun targetComponents() = parts.targets @@ -102,7 +108,11 @@ abstract class BaseSearchToolView( changed = syncTargets() || changed changed = setText(parts.state, stateText(item)) || changed changed = setForeground(parts.state, color(item)) || changed - parts.text?.let { changed = setForeground(it, bodyColor()) || changed } + val body = parts.content + if (body != null && body.foreground != bodyColor()) { + body.foreground = bodyColor() + changed = true + } return changed } @@ -119,30 +129,33 @@ abstract class BaseSearchToolView( } private fun syncBody(): Boolean { - val text = parts.text ?: return false + val body = parts.content ?: return false val value = plainBody(item) - if (text.text != value) { - text.text = value - text.caretPosition = 0 + if (body.text != value) { + body.text = value return true } return false } private fun applyBodyStyle(): Boolean { - val text = parts.text ?: return false - if (!registered && selection != null && text.parent != null) { + val body = parts.content ?: return false + if (!disposed) { + Disposer.register(this, body) + disposed = true + } + if (!registered && selection != null && parts.scroll?.parent != null) { registered = true - selection.register(text, this) + body.register(selection, this) } - return setFont(text, style.transcriptFont) + return body.applyStyle(style) } private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() private fun bodyMaxHeight(): Int { - val text = parts.text ?: return 0 - return text.getFontMetrics(text.font).height * SessionUiStyle.View.Tool.BODY_LINES + + val body = parts.content ?: return 0 + return body.lineHeight() * SessionUiStyle.View.Tool.BODY_LINES + JBUI.scale(SessionUiStyle.View.SESSION_VIEW_BODY_EXTRA_HEIGHT) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt index fc68a76c122..e49ca80070c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt @@ -62,6 +62,9 @@ class ReadToolView( internal fun horizontalPolicy() = parts.scroll?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES internal fun bodyFont() = parts.text?.font ?: style.transcriptFont + internal fun bodyCreated() = parts.bodyCreated() + internal fun bodyWrap() = parts.text?.lineWrap ?: false + internal fun bodyEditor() = parts.content?.editor internal fun linkVisible() = parts.link.isVisible internal fun linkText() = parts.label internal fun linkMarkup() = parts.link.text ?: "" 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 9511bf9358b..98ec700ab78 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 @@ -5,13 +5,22 @@ package ai.kilocode.client.session.views.tool import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState +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.ui.UiStyle import ai.kilocode.client.ui.layout.HAlign import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.VAlign import ai.kilocode.client.ui.layout.align +import ai.kilocode.log.KiloLog import com.intellij.icons.AllIcons +import com.intellij.openapi.Disposable +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.util.Disposer +import com.intellij.ui.EditorTextField import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane import com.intellij.ui.components.JBTextArea @@ -30,6 +39,10 @@ import javax.swing.JComponent import javax.swing.JPanel import javax.swing.ScrollPaneConstants +private val LOG = KiloLog.create(ToolParts::class.java) + +enum class ToolBodyMode { EDITOR, TEXT } + class ToolParts( val header: JPanel, val glyph: JBLabel, @@ -43,13 +56,17 @@ class ToolParts( private val open: ((String) -> Unit)? = null, val extra: JBLabel? = null, val targets: List = emptyList(), + private val mode: ToolBodyMode = ToolBodyMode.EDITOR, ) { var href: String? = null var label: String = "" private var body: ToolBody? = null val text: JBTextArea? - get() = body?.text + get() = body?.area + + val content: ToolBody? + get() = body val scroll: JBScrollPane? get() = body?.scroll @@ -66,12 +83,134 @@ class ToolParts( private fun body(tool: Tool): ToolBody { val item = body if (item != null) return item - val text = JBTextArea().apply { + val body = when (mode) { + ToolBodyMode.EDITOR -> ToolBody.editor(tool) + ToolBodyMode.TEXT -> ToolBody.text(tool) + } + return body.also { this.body = it } + } +} + +class ToolBody private constructor( + val area: JBTextArea?, + val ed: EditorTextField?, + val scroll: JBScrollPane, + private val disposable: Disposable?, +) : Disposable { + var text: String + get() = area?.text ?: ed?.text ?: "" + set(value) { + if (text == value) return + area?.text = value + ed?.text = value + caretStart() + size() + } + + var font: Font + get() = area?.font ?: ed?.font ?: SessionEditorStyle.current().editorFont + set(value) { + area?.font = value + ed?.font = value + size() + } + + var foreground: Color + get() = area?.foreground ?: ed?.foreground ?: UiStyle.Colors.fg() + set(value) { + area?.foreground = value + ed?.foreground = value + } + + val editable: Boolean get() = area?.isEditable ?: false + val caretVisible: Boolean get() = area?.caret?.isVisible ?: false + val lineWrap: Boolean get() = area?.lineWrap ?: false + val editor: EditorTextField? get() = ed + + fun caretStart() { + area?.caretPosition = 0 + ed?.getEditor(false)?.caretModel?.moveToOffset(0) + } + + fun applyStyle(style: SessionEditorStyle): Boolean { + val before = font + area?.font = style.transcriptFont + ed?.font = style.editorFont + ed?.getEditor(false)?.let(style::applyToEditor) + size() + return before != font + } + + fun register(selection: SessionSelection, parent: Disposable) { + val field = ed + if (field != null) { + selection.register(field, parent) + return + } + area?.let { selection.register(it, parent) } + } + + fun lineHeight(): Int = ed?.getEditor(false)?.lineHeight ?: scroll.viewport.view.getFontMetrics(font).height + + override fun dispose() { + disposable?.let(Disposer::dispose) + } + + private fun size() { + val view = scroll.viewport.view as? JComponent ?: return + val height = height(view) + val width = width(view) + view.preferredSize = Dimension(width, height) + view.minimumSize = Dimension(0, height) + view.maximumSize = Dimension(Int.MAX_VALUE, height) + val inset = scroll.viewportBorder?.getBorderInsets(scroll) ?: JBUI.emptyInsets() + val pane = height + scroll.insets.top + scroll.insets.bottom + inset.top + inset.bottom + + scroll.horizontalScrollBar.preferredSize.height + scroll.preferredSize = Dimension(0, pane) + scroll.minimumSize = Dimension(0, pane) + scroll.maximumSize = Dimension(Int.MAX_VALUE, pane) + } + + private fun width(view: JComponent): Int { + val metrics = view.getFontMetrics(font) + return (text.lineSequence().maxOfOrNull { metrics.stringWidth(it) } ?: 0) + + JBUI.scale(SessionUiStyle.View.Code.WIDTH_PADDING) + } + + private fun height(view: JComponent): Int { + ed?.ensureWillComputePreferredSize() + val rows = text.lineSequence().count().coerceAtLeast(SessionUiStyle.View.Code.MIN_ROWS) + return maxOf(view.preferredSize.height, lineHeight() * rows) + } + + companion object { + fun editor(tool: Tool): ToolBody { + val disposable = Disposer.newDisposable("Tool body") + val body = runCatching { + val field = ToolField(preview(tool), SessionEditorStyle.current()).also { it.setDisposedWith(disposable) } + ToolBody(null, field, pane(field, true), disposable) + }.getOrElse { err -> + LOG.warn("kind=tool codeEditor=true failed message=${err.message}", err) + val area = area(tool, false) + ToolBody(area, null, pane(area, true), disposable) + } + body.size() + return body + } + + fun text(tool: Tool): ToolBody { + val area = area(tool, true) + val body = ToolBody(area, null, pane(area, false), null) + body.size() + return body + } + + private fun area(tool: Tool, wrap: Boolean) = JBTextArea().apply { isEditable = false caret.isVisible = false caret.isSelectionVisible = true - lineWrap = true - wrapStyleWord = true + lineWrap = wrap + wrapStyleWord = wrap foreground = if (tool.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() background = SessionUiStyle.View.surface() border = JBUI.Borders.empty( @@ -79,27 +218,60 @@ class ToolParts( JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING), ) } - val scroll = JBScrollPane(text).apply { + + private fun pane(view: JComponent, scrolls: Boolean) = JBScrollPane(view).apply { border = SessionUiStyle.View.topOutline() + viewportBorder = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.SESSION_VIEW_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING), + ).takeIf { scrolls } isOpaque = true background = SessionUiStyle.View.surface() viewport.background = SessionUiStyle.View.surface() - horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + horizontalScrollBarPolicy = if (scrolls) { + ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED + } else { + ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + } verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED } - return ToolBody(text, scroll).also { body = it } } } -class ToolBody( - val text: JBTextArea, - val scroll: JBScrollPane, -) +private class ToolField(value: String, private var style: SessionEditorStyle) : EditorTextField( + EditorFactory.getInstance().createDocument(value.trimEnd('\n')), + ProjectManager.getInstance().defaultProject, + PlainTextFileType.INSTANCE, + 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 = SessionUiStyle.View.surface() + ed.scrollPane.background = SessionUiStyle.View.surface() + ed.scrollPane.viewport.background = SessionUiStyle.View.surface() + ed.settings.isUseSoftWraps = false + ed.settings.isAdditionalPageAtBottom = false + ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + } + } +} private const val SUB_CARD = "sub" private const val LINK_CARD = "link" -internal fun toolParts(tool: Tool, openFile: ((String) -> Unit)? = null): ToolParts { +internal fun toolParts( + tool: Tool, + openFile: ((String) -> Unit)? = null, + mode: ToolBodyMode = ToolBodyMode.TEXT, +): ToolParts { lateinit var parts: ToolParts val glyph = JBLabel() val title = JBLabel() @@ -132,7 +304,7 @@ internal fun toolParts(tool: Tool, openFile: ((String) -> Unit)? = null): ToolPa add(center, BorderLayout.CENTER) add(controls, BorderLayout.EAST) } - parts = ToolParts(header, glyph, title, sub, link, slot, state, center, controls, openFile) + parts = ToolParts(header, glyph, title, sub, link, slot, state, center, controls, openFile, mode = mode) return parts.also { controls.add(it.state) } @@ -170,7 +342,7 @@ internal fun searchParts(count: Int): ToolParts { add(center, BorderLayout.CENTER) add(controls, BorderLayout.EAST) } - return ToolParts(header, glyph, title, sub, link, slot, state, center, controls, targets = targets).also { + return ToolParts(header, glyph, title, sub, link, slot, state, center, controls, targets = targets, mode = ToolBodyMode.EDITOR).also { controls.add(it.state) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt index 66e57435b11..c784aafdfed 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.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.base.SecondarySessionPartView import ai.kilocode.client.ui.UiStyle +import com.intellij.openapi.util.Disposer import com.intellij.util.ui.JBUI import java.awt.Dimension import javax.swing.ScrollPaneConstants @@ -16,7 +17,7 @@ import javax.swing.ScrollPaneConstants class ToolView( tool: Tool, private val selection: SessionSelection? = null, - private val parts: ToolParts = toolParts(tool), + private val parts: ToolParts = toolParts(tool, mode = ToolBodyMode.EDITOR), ) : SecondarySessionPartView(parts.header, { parts.scroll(tool) }) { override val contentId: String = tool.id @@ -24,6 +25,7 @@ class ToolView( private var item = tool private var style = SessionEditorStyle.current() private var registered = false + private var disposed = false init { bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) @@ -64,20 +66,22 @@ class ToolView( fun commandText(): String = command(item) fun outputText(): String = output(item) fun bodyText(): String = body(item) - internal fun previewText(): String = parts.text?.text ?: preview(item) + internal fun previewText(): String = parts.content?.text ?: preview(item) fun hasToggle(): Boolean = arrow.isVisible - internal fun bodyFont() = parts.text?.font ?: style.transcriptFont + internal fun bodyFont() = parts.content?.font ?: style.editorFont internal fun titleFont() = parts.title.font internal fun subtitleFont() = parts.sub.font internal fun stateFont() = parts.state.font - internal fun bodyEditable() = parts.text?.isEditable ?: false - internal fun bodyCaretVisible() = parts.text?.caret?.isVisible ?: false + internal fun bodyEditable() = parts.content?.editable ?: false + internal fun bodyCaretVisible() = parts.content?.caretVisible ?: false internal fun bodyVisible() = parts.scroll?.parent === this internal fun controlCount() = if (arrow.isVisible) 1 else 0 internal fun horizontalPolicy() = parts.scroll?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - internal fun bodyWrap() = parts.text?.lineWrap ?: true + internal fun verticalPolicy() = parts.scroll?.verticalScrollBarPolicy ?: ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + internal fun bodyWrap() = parts.content?.lineWrap ?: false internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES internal fun bodyCreated() = parts.bodyCreated() + internal fun bodyEditor() = parts.content?.editor override fun applyStyle(style: SessionEditorStyle) { this.style = style @@ -96,8 +100,11 @@ class ToolView( changed = syncExpandable(expand) || changed changed = setVisible(parts.state, !expand) || changed changed = syncLabels() || changed - val text = parts.text - if (text != null) changed = setForeground(text, bodyColor()) || changed + val body = parts.content + if (body != null && body.foreground != bodyColor()) { + body.foreground = bodyColor() + changed = true + } return changed } @@ -115,31 +122,37 @@ class ToolView( private fun syncBody(): Boolean { var changed = false - val text = parts.text ?: return false + val body = parts.content ?: return false val value = preview(item) - if (text.text != value) { - text.text = value - text.caretPosition = 0 + if (body.text != value) { + body.text = value + changed = true + } + if (body.foreground != bodyColor()) { + body.foreground = bodyColor() changed = true } - changed = setForeground(text, bodyColor()) || changed return changed } private fun applyBodyStyle(): Boolean { - val text = parts.text ?: return false - if (!registered && selection != null && text.parent != null) { + val body = parts.content ?: return false + if (!disposed) { + Disposer.register(this, body) + disposed = true + } + if (!registered && selection != null && parts.scroll?.parent != null) { registered = true - selection.register(text, this) + body.register(selection, this) } - return setFont(text, style.transcriptFont) + return body.applyStyle(style) } private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() private fun bodyMaxHeight(): Int { - val text = parts.text ?: return 0 - return text.getFontMetrics(text.font).height * bodyMaxRows() + + val body = parts.content ?: return 0 + return body.lineHeight() * bodyMaxRows() + JBUI.scale(SessionUiStyle.View.SESSION_VIEW_BODY_EXTRA_HEIGHT) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt index 7867e208555..77093402384 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt @@ -7,10 +7,22 @@ import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.session.views.tool.GlobToolView import ai.kilocode.client.session.views.tool.ReadToolView import ai.kilocode.client.session.views.tool.ToolView +import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import javax.swing.ScrollPaneConstants @Suppress("UnstableApiUsage") class GlobToolViewTest : BasePlatformTestCase() { + private val views = mutableListOf() + + override fun tearDown() { + try { + views.forEach(Disposer::dispose) + views.clear() + } finally { + super.tearDown() + } + } fun `test header renders title directory and pattern rows`() { val view = GlobToolView(tool().also { @@ -34,7 +46,7 @@ class GlobToolViewTest : BasePlatformTestCase() { } fun `test completed glob starts collapsed and expands output`() { - val view = GlobToolView(tool().also { it.output = "/repo/src/A.kt\n/repo/src/B.kt" }) + val view = track(GlobToolView(tool().also { it.output = "/repo/src/A.kt\n/repo/src/B.kt" })) assertTrue(view.hasToggle()) assertFalse(view.isExpanded()) @@ -49,18 +61,24 @@ class GlobToolViewTest : BasePlatformTestCase() { } fun `test glob body is lazy and reused`() { - val view = GlobToolView(tool().also { it.output = "/repo/src/A.kt" }) + val view = track(GlobToolView(tool().also { it.output = "/repo/src/A.kt" })) assertFalse(view.bodyCreated()) view.toggle() val body = view.scrollComponent() + val editor = view.bodyEditor() assertNotNull(body) + assertNotNull(editor) + assertFalse(view.bodyWrap()) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, view.horizontalPolicy()) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, view.verticalPolicy()) view.toggle() assertFalse(view.bodyVisible()) view.toggle() assertSame(body, view.scrollComponent()) + assertSame(editor, view.bodyEditor()) assertTrue(view.bodyVisible()) } @@ -88,4 +106,9 @@ class GlobToolViewTest : BasePlatformTestCase() { } private fun tool() = Tool("p1", "glob", toolKind("glob")).also { it.state = ToolExecState.COMPLETED } + + private fun track(view: GlobToolView): GlobToolView { + views.add(view) + return view + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt index c5d576f4e39..f04fd062a3e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt @@ -88,6 +88,9 @@ class ReadToolViewTest : BasePlatformTestCase() { assertFalse(view.isExpanded()) assertFalse(view.bodyVisible()) assertEquals("file contents", view.bodyText()) + assertTrue(view.bodyCreated()) + assertTrue(view.bodyWrap()) + assertNull(view.bodyEditor()) assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, view.horizontalPolicy()) view.toggle() 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 575516e6686..afa6421466e 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 @@ -8,12 +8,24 @@ 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 com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import java.awt.Container import java.awt.Dimension +import javax.swing.ScrollPaneConstants @Suppress("UnstableApiUsage") class SearchToolViewTest : BasePlatformTestCase() { + private val views = mutableListOf() + + override fun tearDown() { + try { + views.forEach(Disposer::dispose) + views.clear() + } finally { + super.tearDown() + } + } fun `test header renders title pattern and include targets`() { val view = SearchToolView(tool().also { @@ -46,7 +58,7 @@ class SearchToolViewTest : BasePlatformTestCase() { } fun `test completed search starts collapsed and expands output`() { - val view = SearchToolView(tool().also { it.output = "src/A.kt:1:class A" }) + val view = track(SearchToolView(tool().also { it.output = "src/A.kt:1:class A" })) assertTrue(view.hasToggle()) assertFalse(view.isExpanded()) @@ -61,18 +73,24 @@ class SearchToolViewTest : BasePlatformTestCase() { } fun `test search body is lazy and reused`() { - val view = SearchToolView(tool().also { it.output = "src/A.kt" }) + val view = track(SearchToolView(tool().also { it.output = "src/A.kt" })) assertFalse(view.bodyCreated()) view.toggle() val body = view.scrollComponent() + val editor = view.bodyEditor() assertNotNull(body) + assertNotNull(editor) + assertFalse(view.bodyWrap()) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, view.horizontalPolicy()) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, view.verticalPolicy()) view.toggle() assertFalse(view.bodyVisible()) view.toggle() assertSame(body, view.scrollComponent()) + assertSame(editor, view.bodyEditor()) assertTrue(view.bodyVisible()) } @@ -127,4 +145,9 @@ class SearchToolViewTest : BasePlatformTestCase() { } private fun tool() = Tool("p1", "grep", toolKind("grep")).also { it.state = ToolExecState.COMPLETED } + + private fun track(view: SearchToolView): SearchToolView { + 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 new file mode 100644 index 00000000000..79fb2e97dbb --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolBodyStressTest.kt @@ -0,0 +1,38 @@ +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.views.tool.ToolView +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.UIUtil + +@Suppress("UnstableApiUsage") +class ToolBodyStressTest : BasePlatformTestCase() { + + fun `test expanded tool body editors are disposed after churn`() { + val base = EditorFactory.getInstance().allEditors.size + + repeat(60) { i -> + val view = ToolView(tool(i)) + view.toggle() + view.bodyEditor()?.getEditor(true) + Disposer.dispose(view) + } + drainEdt() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + + private fun tool(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" } + } + + private fun drainEdt() { + UIUtil.dispatchAllInvocationEvents() + } +} 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 f65241d6d2c..da936ac4cbe 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,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.base.SecondarySessionPartView import ai.kilocode.client.session.views.tool.ToolView +import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import javax.swing.ScrollPaneConstants @@ -16,6 +17,16 @@ import javax.swing.ScrollPaneConstants */ @Suppress("UnstableApiUsage") class ToolViewTest : BasePlatformTestCase() { + private val views = mutableListOf() + + override fun tearDown() { + try { + views.forEach(Disposer::dispose) + views.clear() + } finally { + super.tearDown() + } + } // ---- state icons ------ @@ -48,14 +59,14 @@ class ToolViewTest : BasePlatformTestCase() { fun `test title shown instead of name when title is set`() { val t = Tool("p1", "bash", toolKind("bash")).also { it.state = ToolExecState.RUNNING; it.title = "Install deps" } - val view = ToolView(t) + val view = track(ToolView(t)) assertTrue(view.labelText().contains("Install deps")) assertTrue(view.labelText().contains("Shell")) } fun `test blank title falls back to tool name`() { val t = Tool("p1", "bash", toolKind("bash")).also { it.state = ToolExecState.COMPLETED; it.title = " " } - val view = ToolView(t) + val view = track(ToolView(t)) assertTrue(view.labelText().contains("Shell")) } @@ -65,7 +76,7 @@ class ToolViewTest : BasePlatformTestCase() { it.output = "origin git@example.com:repo.git" } - val view = ToolView(t) + val view = track(ToolView(t)) assertTrue(view.labelText().contains("Shell")) assertTrue(view.labelText().contains("View remotes")) @@ -100,7 +111,7 @@ class ToolViewTest : BasePlatformTestCase() { it.input = mapOf("command" to "git log") it.output = "one\ntwo\nthree\nfour" } - val view = ToolView(t) + val view = track(ToolView(t)) assertFalse(view.isExpanded()) view.toggle() @@ -114,7 +125,7 @@ class ToolViewTest : BasePlatformTestCase() { it.input = mapOf("command" to "git log") it.output = "one\ntwo\nthree\nfour" } - val view = ToolView(t) + val view = track(ToolView(t)) assertEquals("$ git log\n\none\ntwo\nthree\nfour", view.bodyText()) assertTrue(view.hasToggle()) @@ -128,23 +139,24 @@ class ToolViewTest : BasePlatformTestCase() { it.input = mapOf("command" to "pwd") it.output = "/tmp" } - val view = ToolView(t) + val view = track(ToolView(t)) assertFalse(view.bodyCreated()) view.toggle() - val font = view.bodyFont() + val body = view.bodyEditor() + assertNotNull(body) view.toggle() view.toggle() - assertSame(font, view.bodyFont()) + assertSame(body, view.bodyEditor()) assertTrue(view.bodyVisible()) } fun `test collapsed update keeps lazy tool body uncreated`() { - val view = ToolView(tool("p1", "bash", ToolExecState.RUNNING).also { + val view = track(ToolView(tool("p1", "bash", ToolExecState.RUNNING).also { it.input = mapOf("command" to "pwd") it.output = "/tmp" - }) + })) view.update(tool("p1", "bash", ToolExecState.COMPLETED).also { it.input = mapOf("command" to "pwd") @@ -156,10 +168,10 @@ class ToolViewTest : BasePlatformTestCase() { } fun `test collapsed update after first expand reuses tool body text`() { - val view = ToolView(tool("p1", "bash", ToolExecState.RUNNING).also { + val view = track(ToolView(tool("p1", "bash", ToolExecState.RUNNING).also { it.input = mapOf("command" to "pwd") it.output = "/tmp" - }) + })) view.toggle() view.toggle() @@ -178,7 +190,7 @@ class ToolViewTest : BasePlatformTestCase() { it.input = mapOf("command" to "pwd") it.output = "/tmp" } - val view = ToolView(t) + val view = track(ToolView(t)) assertFalse(view.isExpanded()) assertTrue(view.hasToggle()) @@ -192,7 +204,7 @@ class ToolViewTest : BasePlatformTestCase() { it.input = mapOf("path" to "/tmp", "pattern" to "**/*.kt") it.output = "/tmp/A.kt" } - val view = ToolView(t) + val view = track(ToolView(t)) assertTrue(view.labelText().contains("Glob")) assertTrue(view.labelText().contains("/tmp")) @@ -206,18 +218,21 @@ class ToolViewTest : BasePlatformTestCase() { fun `test bash output uses editor font settings`() { val style = SessionEditorStyle.current() - val view = ToolView(tool("p1", "bash", ToolExecState.COMPLETED)) + val view = track(ToolView(tool("p1", "bash", ToolExecState.COMPLETED).also { it.output = "done" })) + view.toggle() - assertEditorFont(view.bodyFont(), style) + assertCodeFont(view.bodyFont(), style) assertFalse(view.bodyEditable()) assertFalse(view.bodyCaretVisible()) - assertTrue(view.bodyWrap()) - assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, view.horizontalPolicy()) + assertFalse(view.bodyWrap()) + assertNotNull(view.bodyEditor()) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, view.horizontalPolicy()) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, view.verticalPolicy()) } fun `test tool header uses editor-derived fonts`() { val style = SessionEditorStyle.current() - val view = ToolView(tool("p1", "bash", ToolExecState.COMPLETED)) + val view = track(ToolView(tool("p1", "bash", ToolExecState.COMPLETED).also { it.output = "done" })) assertEditorFont(view.titleFont(), style) assertTrue(view.titleFont().isBold) @@ -228,10 +243,13 @@ class ToolViewTest : BasePlatformTestCase() { 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) + view.toggle() + val editor = view.bodyEditor() view.applyStyle(style) - assertEditorFont(view.bodyFont(), style) + assertSame(editor, view.bodyEditor()) + assertCodeFont(view.bodyFont(), style) assertEditorFont(view.titleFont(), style) assertTrue(view.titleFont().isBold) assertSmallEditorFont(view.subtitleFont(), style) @@ -244,7 +262,7 @@ class ToolViewTest : BasePlatformTestCase() { it.output = "/tmp" } - val view = ToolView(t) + val view = track(ToolView(t)) assertEquals(1, view.controlCount()) } @@ -254,7 +272,7 @@ class ToolViewTest : BasePlatformTestCase() { it.input = mapOf("command" to "log") it.output = (1..40).joinToString("\n") { line -> "line $line" } } - val view = ToolView(t) + val view = track(ToolView(t)) view.toggle() @@ -269,7 +287,7 @@ class ToolViewTest : BasePlatformTestCase() { it.output = out } - val view = ToolView(t) + val view = track(ToolView(t)) view.toggle() assertEquals("$ log\n\n$out", view.bodyText()) @@ -283,7 +301,7 @@ class ToolViewTest : BasePlatformTestCase() { it.output = out } - val view = ToolView(t) + val view = track(ToolView(t)) view.toggle() assertEquals(out, view.bodyText()) @@ -326,11 +344,21 @@ class ToolViewTest : BasePlatformTestCase() { private fun tool(id: String, name: String, state: ToolExecState, title: String? = null): Tool = Tool(id, name, toolKind(name)).also { it.state = state; it.title = title } + private fun track(view: ToolView): ToolView { + views.add(view) + return view + } + private fun assertEditorFont(font: java.awt.Font, style: SessionEditorStyle) { assertEquals(style.transcriptFont.name, font.name) assertEquals(style.editorSize, font.size) } + private fun assertCodeFont(font: java.awt.Font, style: SessionEditorStyle) { + assertEquals(style.editorFont.name, font.name) + assertEquals(style.editorSize, font.size) + } + private fun assertSmallEditorFont(font: java.awt.Font, style: SessionEditorStyle) { assertEquals(style.smallEditorFont.name, font.name) assertTrue(font.size < style.editorSize) From 406f92afe38e97d46b852b99e3b3ce15617b79e9 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 8 Jun 2026 16:10:52 -0400 Subject: [PATCH 10/26] fix(jetbrains): left-align search tool targets --- .../session/views/tool/BaseSearchToolView.kt | 4 +-- .../client/session/views/tool/ToolSupport.kt | 6 ++-- .../ai/kilocode/client/ui/layout/Stack.kt | 23 +++++-------- .../ai/kilocode/client/ui/layout/StackTest.kt | 33 +++++++++++++++++++ 4 files changed, 47 insertions(+), 19 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt index 1c853c10673..c5fd3162217 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt @@ -122,8 +122,8 @@ abstract class BaseSearchToolView( parts.targets.forEachIndexed { index, label -> val text = values.getOrNull(index) ?: "" changed = setVisible(label, text.isNotBlank()) || changed - changed = setPlainText(label, text) || changed - changed = setForeground(label, UiStyle.Colors.weak()) || changed + changed = setTargetText(label, text) || changed + changed = setForeground(label, UiStyle.Colors.fg()) || changed } return changed } 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 98ec700ab78..ee3484664ba 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 @@ -316,7 +316,7 @@ internal fun searchParts(count: Int): ToolParts { val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } val targets = List(count) { JBLabel().apply { - foreground = UiStyle.Colors.weak() + foreground = UiStyle.Colors.fg() minimumSize = Dimension(0, minimumSize.height) } } @@ -327,7 +327,7 @@ internal fun searchParts(count: Int): ToolParts { add(link, LINK_CARD) } val state = JBLabel().apply { foreground = UiStyle.Colors.weak() } - val stack = Stack.fitHorizontal(UiStyle.Gap.xs()).apply { targets.forEach { next(it) } } + 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.SESSION_VIEW_GAP), 0)).apply { isOpaque = false @@ -377,7 +377,7 @@ internal fun setText(label: JBLabel, text: String): Boolean { return true } -internal fun setPlainText(label: JBLabel, text: String): Boolean { +internal fun setTargetText(label: JBLabel, text: String): Boolean { if (label.text == text) return false label.text = text return true diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Stack.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Stack.kt index e496858f6b8..e043225c3ce 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Stack.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Stack.kt @@ -145,21 +145,16 @@ open class Stack( private fun fit(parent: Container, left: Int, top: Int, w: Int, h: Int) { val items = children(parent, h) - val gap = items.sumOf { it.gap } - val total = items.sumOf { it.width } + gap - val widths = if (total <= w) { - items.map { it.width } - } else { - val space = maxOf(0, w - gap) - val base = if (items.isEmpty()) 0 else space / items.size - val extra = if (items.isEmpty()) 0 else space % items.size - items.mapIndexed { index, _ -> base + if (index < extra) 1 else 0 } - } var x = left - items.forEachIndexed { index, item -> - x += item.gap - item.comp.setBounds(x, top, widths[index], h) - x += widths[index] + var rest = w + items.forEach { item -> + val gap = minOf(item.gap, rest) + x += gap + rest -= gap + val width = minOf(item.width, rest) + item.comp.setBounds(x, top, width, h) + x += width + rest -= width } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/StackTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/StackTest.kt index ec3b1a96a69..887556bfd34 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/StackTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/StackTest.kt @@ -313,6 +313,39 @@ class StackTest : BasePlatformTestCase() { assertBounds(3, 2, 10, 44, a) } + fun `test fit horizontal preserves preferred widths when there is space`() { + val a = child(pref = 10 x 5) + val b = child(pref = 20 x 7) + val stack = Stack.fitHorizontal(gap = 3).apply { + next(a) + next(b) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(0, 0, 10, 50, a) + assertBounds(13, 0, 20, 50, b) + } + + fun `test fit horizontal allocates tight space from the left`() { + val a = child(pref = 20 x 5) + val b = child(pref = 20 x 7) + val c = child(pref = 20 x 9) + val stack = Stack.fitHorizontal(gap = 3).apply { + next(a) + next(b) + next(c) + } + + stack.setBounds(0, 0, 45, 50) + stack.doLayout() + + assertBounds(0, 0, 20, 50, a) + assertBounds(23, 0, 20, 50, b) + assertBounds(45, 0, 0, 50, c) + } + fun `test vertical measures preferred height after width probe`() { val a = object : JBLabel("x") { override fun getMinimumSize() = Dimension(0, 0) From 38c774ca86d85f8102f02b7d7a33cbfd758328bb Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 8 Jun 2026 16:11:45 -0400 Subject: [PATCH 11/26] fix(jetbrains): preserve scroll position on expand --- .kilo/plans/1780527326684-sunny-knight.md | 58 ++++ .kilo/plans/1780527817231-quick-eagle.md | 304 ++++++++++++++++++ .kilo/plans/1780676598127-hidden-eagle.md | 83 +++++ .kilo/plans/1780677922045-curious-canyon.md | 64 ++++ .kilo/plans/1780936267001-gentle-star.md | 61 ++++ .kilo/plans/1780937014434-stellar-island.md | 68 ++++ .kilo/plans/1780942060646-cosmic-rocket.md | 69 ++++ .kilo/plans/1780945098899-witty-island.md | 49 +++ .../ai/kilocode/client/session/SessionUi.kt | 12 +- .../client/session/scroll/SessionScroll.kt | 27 ++ .../session/ui/SessionMessageListPanel.kt | 6 +- .../client/session/views/MessageView.kt | 4 + .../kilocode/client/session/views/TurnView.kt | 4 +- .../views/base/AbstractSessionPartView.kt | 11 +- .../client/session/views/base/PartView.kt | 3 + .../views/question/QuestionResultView.kt | 8 +- .../client/session/SessionScrollTest.kt | 83 +++++ .../client/session/views/PlanExitViewTest.kt | 1 + .../session/views/QuestionResultViewTest.kt | 1 + 19 files changed, 909 insertions(+), 7 deletions(-) create mode 100644 .kilo/plans/1780527326684-sunny-knight.md create mode 100644 .kilo/plans/1780527817231-quick-eagle.md create mode 100644 .kilo/plans/1780676598127-hidden-eagle.md create mode 100644 .kilo/plans/1780677922045-curious-canyon.md create mode 100644 .kilo/plans/1780936267001-gentle-star.md create mode 100644 .kilo/plans/1780937014434-stellar-island.md create mode 100644 .kilo/plans/1780942060646-cosmic-rocket.md create mode 100644 .kilo/plans/1780945098899-witty-island.md diff --git a/.kilo/plans/1780527326684-sunny-knight.md b/.kilo/plans/1780527326684-sunny-knight.md new file mode 100644 index 00000000000..f3c24c313ea --- /dev/null +++ b/.kilo/plans/1780527326684-sunny-knight.md @@ -0,0 +1,58 @@ +# Filter Thematic Breaks From JetBrains Hybrid Markdown + +## Goal + +Remove unwanted rendered `
      ` lines from the JetBrains hybrid markdown view, especially separators that appear after code blocks, while preserving the current improvement that coalesces consecutive prose blocks into one `JBHtmlPane`. + +## Current Context + +- Uncommitted changes in `MdViewHybrid.kt` coalesce adjacent non-code CommonMark block nodes into a single `Desc.Html` via `Visitor.run`. +- The visible top line in the screenshot is likely CommonMark `ThematicBreak` output from markdown like `---`, rendered by `JBHtmlPane`/IntelliJ CSS as `
      `. +- Filtering via CSS is not ideal because the `
      ` node can still contribute layout/spacing and keeps `view.html()` inconsistent with the intended rendered content. + +## Recommended Approach + +Filter CommonMark `ThematicBreak` nodes out of the hybrid markdown projection model before they become UI blocks or exported HTML. + +This is preferable to hiding `hr` with CSS because it removes the unwanted semantic node entirely and avoids residual spacing. + +## Implementation Steps + +1. Update `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt`. +2. Import `org.commonmark.node.ThematicBreak`. +3. Extend `Desc.Html` to carry only wanted prose HTML; do not create any `Desc` for thematic breaks. +4. In `Visitor.visitChildren`, skip `ThematicBreak` children before appending rendered block HTML. +5. Ensure `project()` builds `Projection.html` from the same filtered block descriptors used for the UI. + - The current `flush()` appends `renderer.render(doc)` directly to the projection HTML, which would still include `
      ` even if `Visitor` skips it for UI blocks. + - Adjust `flush()` to parse the markdown, collect filtered descriptions, append them to `blocks`, and append only filtered HTML/code HTML to the projection string. +6. Keep fenced and indented code behavior unchanged. +7. Preserve the new coalescing behavior for adjacent prose blocks. + +## Tests + +Update `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt` with targeted regression coverage: + +1. Add a test where a thematic break follows a fenced code block: + - Markdown: fenced code, blank line, `---`, blank line, heading/prose. + - Assert one code scroll pane remains. + - Assert one HTML pane remains for the heading/prose. + - Assert `htmls().single().text` does not contain `")) + assertTrue(html.contains("
        ")) + assertTrue(html.contains("class A")) + assertFalse(html.contains(" view.append(" more$i") } + + assertSame(intro, htmls().first()) + assertSame(tail, htmls().last()) + assertSame(editor, editors().single()) + assertEquals(2, htmls().size) + assertEquals(1, scrolls().size) + assertFalse(editor.getEditor(true)!!.isDisposed) + assertTrue(view.markdown().contains("more99")) + } + + fun `test repeated same structure set reuses single editor and stays bounded`() { + repeat(150) { i -> + view.set("```kotlin\nval x = $i\n```") + editors().single().getEditor(true) + } + val editor = editors().single() + + repeat(50) { i -> view.set("```kotlin\nval y = $i\n```") } + + assertSame(editor, editors().single()) + assertEquals(1, scrolls().size) + assertEquals(1, panel().componentCount) + assertEquals("val y = 49", editor.text) + } + + fun `test structural churn releases every editor after clear`() { + val base = EditorFactory.getInstance().allEditors.size + + repeat(60) { i -> + view.set("```kotlin\nval x = $i\n```") + editors().single().getEditor(true) + view.set("```java\nclass A$i {}\n```") + editors().single().getEditor(true) + view.set("plain prose $i") + } + + view.clear() + drainEdt() + + assertTrue(scrolls().isEmpty()) + assertTrue(htmls().isEmpty()) + assertEquals(0, panel().componentCount) + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + + fun `test streaming code body reuses one editor and keeps html in sync`() { + view.append("```java\n") + val pane = scrolls().single() + val editor = editors().single() + + val body = StringBuilder() + repeat(100) { i -> + val line = "void m$i() {}\n" + body.append(line) + view.append(line) + } + + assertSame(pane, scrolls().single()) + assertSame(editor, editors().single()) + assertEquals(body.toString().trimEnd('\n'), editor.text) + assertTrue(view.html().contains("void m0()")) + assertTrue(view.html().contains("void m99()")) + + view.append("```") + + assertSame(pane, scrolls().single()) + assertSame(editor, editors().single()) + } + + fun `test style changes during streaming do not rebuild components`() { + view.append("intro\n\n```kotlin\nval x = 1\n```\n\n") + val intro = htmls().first() + val editor = editors().single() + editor.getEditor(true) + val styled = SessionEditorStyle.create(family = "Courier New", size = 18) + val current = SessionEditorStyle.current() + + repeat(50) { i -> + view.append("line $i ") + view.applyStyle(if (i % 2 == 0) styled else current) + if (i % 5 == 0) view.resetStyles() + } + + assertSame(intro, htmls().first()) + assertSame(editor, editors().single()) + assertFalse(editor.getEditor(true)!!.isDisposed) + assertEquals(2, htmls().size) + assertEquals(1, scrolls().size) + assertTrue(view.markdown().contains("line 49")) + } + + private fun panel(): JPanel = view.component as JPanel + + private fun scrolls(): List = panel().components.filterIsInstance() + + private fun htmls(): List = panel().components.filterIsInstance() + + private fun struts(): List = panel().components.filterIsInstance() + + private fun editors(): List = scrolls().mapNotNull { it.viewport.view as? EditorTextField } + + private fun drainEdt() { + UIUtil.dispatchAllInvocationEvents() + } +} +```` + +Copy the file content verbatim. If any expected-count assertion fails, see +"If a test fails" before changing it. + +## Step 2 — Add the AGENTS.md rule + +Edit `packages/kilo-jetbrains/AGENTS.md`. In the "### Swing Component Lifecycle" section, +the bullet list under "Tests for retained Swing components should assert:" ends with this +exact line (anchor): + +``` +- No-op updates, empty deltas, repeated hover values, and toggling non-expandable cards do not repaint/revalidate the whole view. +``` + +Immediately AFTER that line (before the blank line and `### Platform Components and Utilities`), +insert: + +``` +- Streaming/rebuilding surfaces additionally require stress + leak tests (see below). + +### Stress and Leak Tests for Streaming UI + +Session/transcript UI that streams updates or rebuilds its component tree (markdown +views, code blocks, transcript parts, collapsible cards) must ship stress + leak tests in +addition to behavior tests. These tests must: + +- Drive many updates (hundreds of streamed deltas or `set` cycles) through the public API. +- Assert that retained component instances stay identical across updates (`assertSame`). +- Assert the component count stays bounded — no growth per update. +- Assert disposable-backed resources return to baseline after churn + clear/dispose. + For code editors, compare `EditorFactory.getInstance().allEditors.size` against a + baseline captured before the loop. + +See `MdViewHybridStressTest` for the reference pattern. +``` + +Do not pad markdown tables or reflow other lines; only insert the block above. + +## Step 3 — Verify + +Java 21 is required and already present. From `packages/kilo-jetbrains/`: + +1. `./gradlew :frontend:test --tests ai.kilocode.client.ui.md.MdViewHybridStressTest` +2. `./gradlew :frontend:test --tests "ai.kilocode.client.ui.md.*"` (regression for the package) +3. `./gradlew typecheck` + +All three must pass. The targeted run in (1) is the primary signal. + +## If a test fails + +A failure in the churn/leak test (`allEditors` not back to baseline) or a retention test +(`assertSame` fails) likely indicates a real defect — most plausibly an editor disposable +not being disposed on a specific removal path, or `sync()` rebuilding instead of reusing. +Fix it with the smallest possible change in `MdViewHybrid.kt`, keep the test, and note the +fix. No `kilocode_change` markers (Kilo-owned package). + +If a count assertion is off by a fixed amount because of a layout detail (e.g. an +unexpected leading/trailing strut), first confirm the real structure by reading +`addBlock`/`addGap`/`removeBlocks` (`MdViewHybrid.kt:450`–`:460`) and adjust the EXPECTED +constant in the test to match correct behavior — do not loosen the assertion to a range. + +## Constraints + +- Plan/test only; no JCEF/Compose/UI DSL (tests use plain Swing tree inspection). +- No changeset (not user-facing). +- New file is under a Kilo-owned path — no `kilocode_change` markers. diff --git a/.kilo/plans/1780676598127-hidden-eagle.md b/.kilo/plans/1780676598127-hidden-eagle.md new file mode 100644 index 00000000000..0bcaaf7bb2a --- /dev/null +++ b/.kilo/plans/1780676598127-hidden-eagle.md @@ -0,0 +1,83 @@ +# Fix JetBrains Reasoning Session UI + +## Goal +Update the JetBrains chat/session reasoning UI so that: + +- Empty reasoning blocks are not visible in the transcript. +- Streaming reasoning opens by default while content is being added. +- The expanded reasoning body is capped at 5 visible lines and follows newly streamed content inside its own scroll pane. +- Reasoning uses a vertical visual separator instead of the current horizontal top separator, and no separator is visible while collapsed. +- Consecutive reasoning parts render as one reused reasoning block instead of multiple reasoning blocks in a row. + +## Relevant Findings + +- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt` renders reasoning blocks. + - It currently extends `SecondarySessionPartView` collapsed by default. + - It creates the body lazily as a nested `JBScrollPane` and already has `SessionUiStyle.View.Reasoning.BODY_LINES = 5`. + - It currently uses `SessionUiStyle.View.topOutline()` on the scroll pane, which creates the unwanted horizontal separator. + - Blank reasoning disables the toggle but can still leave a visible header/card. +- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt` owns the ordered rendered part views for a message. + - It currently creates one `ReasoningView` per `Reasoning` content part. + - There is no adjacent reasoning coalescing, so consecutive reasoning parts can render as multiple blocks. +- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt` should not need a protocol/model change for this UI fix. + - Keeping the merge at the `MessageView`/rendering layer avoids changing controller snapshot/glue behavior for streamed deltas. + +## Implementation Plan + +1. Update `ReasoningView` behavior. + - Track the current `Reasoning.done` state in the view. + - Make the view invisible when its source is blank, not merely non-expandable. This prevents empty reasoning chrome from contributing visible layout. + - Initialize non-blank, unfinished reasoning (`done == false`) expanded by default. + - When blank unfinished reasoning receives its first non-empty delta/update, reveal and expand it automatically. + - Preserve user collapse after the body has existed; do not force-reopen an already-created body on every delta. + - Keep completed/history reasoning collapsed by default. + +2. Keep the expanded body capped to 5 visible lines. + - Reuse the existing `SessionUiStyle.View.Reasoning.BODY_LINES = 5` and existing preferred-size cap. + - Strengthen tests so the expanded preferred height stays bounded rather than only checking the constant. + +3. Add nested autoscroll for streaming reasoning. + - After `md.set(...)` or `md.append(...)` while the reasoning body is visible, schedule an EDT tail-scroll on the nested `JBScrollPane`. + - Use the scroll pane viewport/vertical scrollbar, not the global transcript scroll, so new reasoning content follows inside the block. + - Keep horizontal scrolling disabled as it is today. + +4. Replace the reasoning separator styling. + - Remove the reasoning body scroll pane’s `SessionUiStyle.View.topOutline()` border. + - Add/use a left-side line border for the reasoning body only, for example via a `SessionUiStyle.View.leftOutline()` helper or a direct `JBUI.Borders.customLine(SessionUiStyle.View.line(), 0, 1, 0, 0)` call. + - Keep the separator attached to the body/scroll pane, so collapsed reasoning has no vertical separator. + +5. Coalesce consecutive reasoning views in `MessageView`. + - Add a small rendering-layer helper that identifies adjacent rendered reasoning parts and reuses the previous `ReasoningView` when a new `Reasoning` part follows another reasoning part. + - Maintain a part-id alias map so deltas for the later reasoning part route to the reused first `ReasoningView`. + - Treat blank reasoning parts as non-visible; when they later receive content, upsert through the same helper so they either create the first visible reasoning block or merge into the previous one. + - Rebuild or resync aliases when `rebuildParts()` runs for hidden question/todo tool changes. + - Keep separate reasoning blocks when a visible non-reasoning part appears between them. + +6. Add/adjust tests. + - `ReasoningViewTest`: + - Streaming non-blank reasoning starts expanded. + - Completed reasoning remains collapsed by default. + - Blank reasoning is not visible and has no toggle. + - First content added to blank unfinished reasoning reveals/expands the block. + - Expanded reasoning remains capped to five rows. + - Appended content scrolls the nested reasoning viewport to the bottom. + - The reasoning body uses a left/vertical separator and no top separator. + - `TurnViewTest` or `SessionMessageListPanelTest`: + - Consecutive reasoning parts in one assistant message render as one reasoning block. + - Deltas sent to a later consecutive reasoning part append to the reused block. + - A text/tool part between two reasoning parts keeps them as separate blocks. + - Empty reasoning followed by another view does not produce a visible empty reasoning block. + +7. Add release note. + - Create a patch changeset under `.changeset/` for `"kilo-code"` describing the JetBrains reasoning UI fix from the user perspective. + +8. Verify. + - Run targeted JetBrains frontend tests first, for example: + - `./gradlew test --tests ai.kilocode.client.session.views.ReasoningViewTest --tests ai.kilocode.client.session.views.TurnViewTest --tests ai.kilocode.client.session.ui.SessionMessageListPanelTest` + - Run `./gradlew typecheck` from `packages/kilo-jetbrains/` after the targeted tests pass. + - If Java 21 is not active, follow the repo instruction to switch/install Java 21 before verification. + +## Notes + +- This plan intentionally avoids changing `SessionModel` or `SessionController` unless implementation uncovers a blocker. A model-level merge would risk interacting with controller delta snapshot/glue logic for aliased part IDs. +- The changes stay inside `packages/kilo-jetbrains/`, which is Kilo-owned code; `kilocode_change` markers are not needed. diff --git a/.kilo/plans/1780677922045-curious-canyon.md b/.kilo/plans/1780677922045-curious-canyon.md new file mode 100644 index 00000000000..2e8db10578c --- /dev/null +++ b/.kilo/plans/1780677922045-curious-canyon.md @@ -0,0 +1,64 @@ +# Fix JetBrains Profile 400 Load Error + +## Goal + +Prevent JetBrains backend app startup from failing when the optional `/kilo/profile` request returns `400 Bad Request`, as seen in: + +```text +ai.kilocode.jetbrains.api.infrastructure.ClientException: Client error : 400 Bad Request + at ai.kilocode.jetbrains.api.client.DefaultApi.kiloProfile(DefaultApi.kt:6048) + at ai.kilocode.backend.app.KiloBackendAppService.fetchProfile(KiloBackendAppService.kt:499) +``` + +The app should continue to `Ready` with `profile = null`, matching existing behavior for unauthenticated or temporarily unavailable profile data. + +## Findings + +- The failing code is in `packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt`. +- `load()` treats profile as optional in comments and state semantics, but currently turns `fetchProfile()` errors into a fatal `LoadFailure`: + - `fetchProfile()` returns `FetchResult.fail("profile", e)` for `ClientException` statuses other than `401`. + - `load()` adds that error and throws, causing `KiloAppState.Error`. +- `/kilo/profile` in the CLI HTTP API explicitly declares `BadRequest` and `Unauthorized` as possible errors: + - `packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts` + - The handler maps upstream gateway/profile/balance failures to `HttpApiError.BadRequest`: + - `packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts` +- The accompanying CLI stderr timeout for organization modes is logged by `packages/kilo-gateway/src/api/modes.ts`, but that function already catches the timeout and returns `[]`; it is not the direct source of the JetBrains app-load failure. +- Existing backend tests already verify that profile `401` and `500` do not prevent `Ready`; there is no test for profile `400`. + +## Implementation Plan + +1. Update `KiloBackendAppService.fetchProfile()`. + - In the `catch (e: ClientException)` branch, treat `e.statusCode == 400` as optional profile unavailability, like `401`. + - Return `FetchResult.ok(null)` for `400`. + - Log it without surfacing a full warning stack as a required app-load failure. A concise warning such as `Profile: unavailable (400)` is appropriate, with `logResponseBody("profile", e)` retained for response diagnostics if useful. + - Keep `401` as `FetchResult.ok(null)` with the existing not-logged-in info log. + - Keep other unexpected `4xx` statuses as failures unless implementation review shows the generated API can only emit `400` and `401` for this endpoint. + - Update the method comment from “401 and 5xx” to include `400`/gateway profile unavailability. + +2. Add backend test coverage in `KiloBackendAppServiceTest`. + - Add `profile 400 does not prevent Ready` near the existing `profile 401 does not prevent Ready` and `profile 500 does not prevent Ready` tests. + - Use the existing `MockCliServer` knobs: + - `mock.profileStatus = 400` + - `mock.profile = """{"error":"bad request"}"""` + - Assert: + - app reaches `KiloAppState.Ready` + - `svc.profile == null` + - the final app state is `Ready`, not `Error` + - Optionally assert the log contains the concise profile-unavailable message, but avoid brittle exact stack/body assertions. + +3. Add a patch changeset. + - Create `.changeset/.md` with package `"kilo-code": patch`. + - User-facing wording: `Keep the JetBrains plugin ready when optional Kilo profile loading returns a gateway bad request.` + +4. Verify. + - Run targeted backend test: + - `./gradlew :backend:test --tests ai.kilocode.backend.app.KiloBackendAppServiceTest` + - Run package typecheck: + - `./gradlew typecheck` + - If the targeted test task has stale Gradle incremental behavior, rerun with `--rerun-tasks`. + +## Notes + +- This plan intentionally keeps the fix in `packages/kilo-jetbrains/backend/` and does not change the CLI `/kilo/profile` API or generated client. +- No `kilocode_change` markers are needed because `packages/kilo-jetbrains/` is Kilo-owned code. +- The CLI organization modes timeout log may still appear, but it is already handled as non-fatal by the gateway package. The JetBrains app-load error is caused by treating the separate profile `400` as fatal. diff --git a/.kilo/plans/1780936267001-gentle-star.md b/.kilo/plans/1780936267001-gentle-star.md new file mode 100644 index 00000000000..de89bc5da7e --- /dev/null +++ b/.kilo/plans/1780936267001-gentle-star.md @@ -0,0 +1,61 @@ +# Plan: JetBrains Glob Tool View Parity + +## Goal +Implement a JetBrains chat tool view for `glob` that matches the VS Code behavior more closely while using the requested stacked layout: + +- Tool name row +- Directory row +- Pattern row +- Expanded body containing the glob output/content + +## Current State +- JetBrains currently classifies `glob` as `ToolKind.READ` in `frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt`. +- `ViewFactory` routes all read-kind tools to `ReadToolView`. +- `ReadToolView` is non-expandable and optimized for `read` file/directory results, so glob output is not shown as an expandable content body. +- Generic `ToolView` already supports collapsible output, lazy body creation, state labels, editor-derived fonts, and capped body height, but its header is one-line: title plus subtitle args. +- VS Code/kilo-ui renders `glob` with a compact header containing title, directory, and `pattern=...`, and expanded content is just the tool output rendered as content, not raw JSON. + +## Implementation Steps +1. Add a dedicated `GlobToolView` in `frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt`. +2. Make `GlobToolView.canRender(tool)` return true only for `tool.name == "glob"`. +3. Route `glob` before `ReadToolView` in `ViewFactory.create` so `glob` no longer falls into the read-file renderer. +4. Update `ViewFactory.shouldReplace` so streamed updates replace views correctly when a part changes into or out of `GlobToolView`. +5. Build the `GlobToolView` header as a vertical Swing stack using the existing Swing style rules: + - First row: icon plus `Glob` title and pending/running/error state where applicable. + - Second row: directory from `tool.input["path"]`, falling back to `tool.title` or blank if absent. + - Third row: `pattern=` from `tool.input["pattern"]`, hidden when absent. +6. Use existing tool-body behavior for expanded content: + - Collapsed by default. + - Expandable only when `tool.output` or `tool.error` is non-blank. + - Body text is the plain output plus error, matching existing `plainBody` behavior. + - Lazy-create the `JBTextArea`/`JBScrollPane` only on first expansion or direct body access. + - Keep the existing body max-height cap and editor-font styling. +7. Keep `include` out of the glob header for VS Code parity unless a later requirement explicitly asks for it. +8. Do not introduce JCEF, Compose, or Kotlin UI DSL. Keep the implementation in the existing retained Swing view stack. + +## Tests +1. Add `GlobToolViewTest` under `frontend/src/test/kotlin/ai/kilocode/client/session/views/`. +2. Test header layout/accessors: + - Title contains `Glob`. + - Directory row is separate from pattern row. + - Pattern row renders as `pattern=...`. +3. Test expanded content: + - Completed glob with output starts collapsed and has a toggle. + - After toggle, body is visible and contains the output exactly. +4. Test retained/lazy behavior: + - Body is not created while collapsed. + - First expansion creates it once. + - Collapse/re-expand reuses the same body component. + - Updating while collapsed does not eagerly create the body. +5. Update `ReadToolViewTest` expectations so `glob` routes to `GlobToolView`, while `read` and `grep` still route to `ReadToolView` unless a broader search-view task is requested later. +6. Add or update `ViewFactory.shouldReplace` tests if existing coverage does not catch `ReadToolView` to `GlobToolView` replacement. + +## Verification +Run the smallest relevant JetBrains checks: + +- `./gradlew typecheck` from `packages/kilo-jetbrains/` +- Targeted frontend tests covering session views, or the package test task if targeted Gradle test selection is not available. + +## Notes +- This plan intentionally scopes the change to `glob`. `grep`, `ls`, and other read-kind tools can be handled separately if the same stacked/search layout is desired later. +- No generated SDK or backend protocol changes are needed because `Tool.input`, `Tool.output`, and `Tool.error` already carry the required data. diff --git a/.kilo/plans/1780937014434-stellar-island.md b/.kilo/plans/1780937014434-stellar-island.md new file mode 100644 index 00000000000..44acfe789a4 --- /dev/null +++ b/.kilo/plans/1780937014434-stellar-island.md @@ -0,0 +1,68 @@ +# Plan: Base Search Tool View + +## Goal + +Refactor the JetBrains session tool renderers so `glob` and code search/`grep` share one retained Swing base renderer. The base owns the header layout requested by the user: icon at west, center content as a horizontal arrangement of tool name plus a vertical stack of target labels, with target labels constrained so long values clip/ellipsis instead of forcing the row wider. + +## Current State + +- `GlobToolView` is implemented in `ToolView.kt` and duplicates most of `ToolView` body/update/style behavior. +- `grep` is a read-kind tool and currently routes to `ReadToolView`, so it does not get the new search-style header. +- `ReadToolView.canRender` still matches `glob` and `grep`; current factory routing special-cases `GlobToolView` before `ReadToolView`. +- `Stack.horizontal` uses preferred widths during layout, so it is risky for the outer tool-name/targets row unless the target area is constrained. + +## Implementation Steps + +1. Add an abstract base renderer in `ToolView.kt`, tentatively `BaseSearchToolView`, extending `SecondarySessionPartView`. +2. Move the shared lazy collapsible body behavior from `GlobToolView` into the base: + - retained `ToolParts`/body handling + - `expand`, `getPreferredSize`, `update`, `applyStyle` + - `sync`, `syncBody`, `applyBodyStyle`, `bodyColor`, `bodyMaxHeight` + - completed-state hides state label; pending/running/error keeps state visible +3. Make subclasses provide only the search-specific header data: + - icon for the tool + - localized tool name + - ordered target strings for the current `Tool` + - `canRender` predicate +4. Replace `globParts` with a generic search-header builder used by the base: + - root header: `JPanel(BorderLayout(gap, 0))` + - west: icon label + - center: constrained horizontal row containing tool label and target stack + - target stack: `Stack.vertical(gap = UiStyle.Gap.xs())` with one label per target + - controls/state are retained in the common parts structure so `AbstractSessionPartView` still owns the expand arrow at the far east +5. Configure truncation/clipping for targets: + - put the target stack in a constrained center slot rather than an unconstrained preferred-width-only layout + - set the target stack and target labels to allow zero minimum width on the horizontal axis + - prefer plain single-line label text for targets so Swing/JBLabel clipping can work; avoid HTML wrapping for these labels unless needed + - hide empty target labels and preserve row height from visible targets only +6. Keep `GlobToolView` as a subclass of the base: + - `canRender(tool) = tool.name == "glob"` + - title: `session.part.tool.glob` + - icon: existing `icon(tool)` unless a more specific platform search icon is desired + - targets: directory from `input["path"]`, fallback `title`, then `pattern=` +7. Add `SearchToolView` as the code search/grep subclass: + - `canRender(tool) = tool.name == "grep"` + - title: new bundle key, likely `session.part.tool.search=Search` + - icon: `AllIcons.Actions.Search` + - targets: `path` when present, `pattern=`, `include=`, and only include non-blank values +8. Update `ViewFactory` routing: + - route `GlobToolView` before `ReadToolView` + - route `SearchToolView` before `ReadToolView` + - update `shouldReplace` transitions for entering/exiting both search subclasses and for `QuestionResultView` +9. Update tests: + - refactor `GlobToolViewTest` expectations to the new base layout while preserving lazy body/reuse/update coverage + - add `SearchToolViewTest` covering grep routing, target rows (`pattern`, `include`, optional path), lazy body behavior, style application, and replacement transitions + - update `ReadToolViewTest` so `grep` routes to `SearchToolView` while `ReadToolView.canRender` can still remain broad if the factory handles precedence + - update `SessionUiUpdateTest` with a grep/search rendering assertion + - add a focused layout test that constrains the header/view width and asserts target components do not force a larger width than the available header center area +10. Add a patch changeset if this UI change is release-note worthy for JetBrains users. +11. Run targeted verification from `packages/kilo-jetbrains`: + - `./gradlew :frontend:test --tests 'ai.kilocode.client.session.views.GlobToolViewTest' --tests 'ai.kilocode.client.session.views.SearchToolViewTest' --tests 'ai.kilocode.client.session.views.ReadToolViewTest' --tests 'ai.kilocode.client.session.ui.SessionUiUpdateTest'` + - run `./gradlew typecheck` if the targeted test compile does not cover all changed Kotlin code paths + +## Notes + +- Keep all new UI in Swing and IntelliJ platform components; no Compose, JCEF, or UI DSL. +- Keep user-visible names in `KiloBundle.properties`. +- Avoid changing `ReadToolView.canRender` unless needed; factory precedence is the smaller, lower-risk change. +- Avoid creating new public production accessors only for tests; prefer component-tree inspection or existing internal helpers where possible. diff --git a/.kilo/plans/1780942060646-cosmic-rocket.md b/.kilo/plans/1780942060646-cosmic-rocket.md new file mode 100644 index 00000000000..810ddfbb146 --- /dev/null +++ b/.kilo/plans/1780942060646-cosmic-rocket.md @@ -0,0 +1,69 @@ +# Fix JetBrains Session Expand Scroll Anchoring + +## Problem + +When a user clicks a collapsed session part to expand it, the transcript can jump to the tail and move the clicked header upward. The issue is caused by local Swing expand/collapse changing the scroll range while `SessionScroll.tail` is still true. `SessionScroll.onScroll()` treats that adjustment like streaming/model content growth and calls `followBottom(true)`. + +The desired behavior is different for local user toggles: keep the clicked header at the same viewport position. Existing bottom-follow behavior should remain for model-driven updates, streaming deltas, prompt sends, question/login docks, and initial session open. + +## Relevant Code + +- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt` + - `onScroll()` resumes tail-following when `tail == true` and the adjustment was not marked as user scroll. + - `followBottom()` / `followPass()` are correct for model updates and should not be weakened globally. +- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt` + - Header click calls `toggle()`, which adds/removes the body and `revalidate()`s locally. +- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt` + - Has its own independent `toggle()` implementation. +- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt` + - Owns `SessionScroll` and creates `SessionMessageListPanel`; best composition point for wiring scroll anchoring into part views. +- `SessionMessageListPanel`, `TurnView`, and `MessageView` + - Create/replace retained part views and can propagate an optional local-resize handler. + +## Plan + +1. Add a local resize/toggle hook to `PartView`. + - Use a small nullable callback such as `var resize: ((JComponent, () -> Unit) -> Unit)? = null`. + - Keep the default null so standalone view tests and non-session usages behave as today. + - Do not make part views depend directly on `SessionScroll`. + +2. Wrap user toggles in that hook. + - In `AbstractSessionPartView.toggle()`, move the existing body add/remove logic into a private helper and invoke it through `resize?.invoke(this) { ... }` when present. + - In `QuestionResultView.toggle()`, do the same because it does not inherit from `AbstractSessionPartView`. + - Keep direct programmatic `expand()` calls unchanged so model-driven auto-expansion, such as live reasoning, keeps existing autoscroll semantics. + +3. Propagate the hook from the session UI to created part views. + - Add an optional resize callback parameter through `SessionMessageListPanel` -> `TurnView` -> `MessageView`. + - When `MessageView` creates or replaces a `PartView`, assign `view.resize = resize` before adding it. + - Ensure rebuild and replacement paths receive the same callback. + +4. Add anchored local-resize support to `SessionScroll`. + - Add an EDT-only method such as `preserve(anchor: JComponent, action: () -> Unit)`. + - Before `action`, capture the anchor’s Y coordinate in the scroll view and its current offset from `viewport.viewPosition.y`. + - During `action` and the immediate layout/restore, suppress normal autoscroll handling by using the existing `auto` guard or a dedicated guard. + - After layout, set `viewport.viewPosition.y` / scrollbar value so the anchor keeps the same visible Y coordinate, clamped to valid scroll bounds. + - Cancel pending follow passes with `seq++`, set `tail` based on the resulting `atBottom()`, sync `value`, and update the jump button. + - This makes local user expand/collapse opt out of tail-following when it leaves the user away from the bottom. + +5. Wire `SessionUi` to use the anchored resize method. + - Construct `SessionMessageListPanel` with a callback like `{ anchor, fn -> scroll.preserve(anchor, fn) }`. + - This is safe even though `scroll` is assigned just after `messageBody` construction because the callback only runs after the UI is fully built and a user clicks a part. + +6. Add regression tests in `SessionScrollTest`. + - Add a helper to emit a completed `tool` part with large output so it renders collapsed and expands to a meaningful height. + - Test: expanding a visible collapsed tool while currently at bottom keeps that tool/header at the same viewport Y and does not jump to the new bottom. + - Test: expanding a visible collapsed tool while in the middle keeps the same header Y and keeps the jump button visible. + - Add a collapse variant if the first two do not exercise scrollbar clamping enough. + - If easy with existing helpers, add one `QuestionResultView` toggle test because it has a separate toggle path. + +7. Verify with the smallest relevant checks. + - Run targeted JetBrains frontend tests for scroll behavior, e.g. `./gradlew :frontend:test --tests ai.kilocode.client.session.SessionScrollTest` from `packages/kilo-jetbrains/` if supported by the Gradle project. + - If the targeted Gradle selector is not available, run the package test task that includes frontend tests. + - Run `./gradlew typecheck` or `bun run typecheck` from `packages/kilo-jetbrains/` after the implementation compiles locally. + +## Expected Outcome + +- Clicking expand/collapse preserves the clicked card/header position instead of jumping to the transcript tail. +- Streaming and model updates still follow the bottom when the user was already at the bottom. +- Middle-scroll anchoring remains unchanged for non-toggle updates. +- The scroll-to-bottom button appears when a local expansion leaves the user away from the bottom. diff --git a/.kilo/plans/1780945098899-witty-island.md b/.kilo/plans/1780945098899-witty-island.md new file mode 100644 index 00000000000..9464bba9788 --- /dev/null +++ b/.kilo/plans/1780945098899-witty-island.md @@ -0,0 +1,49 @@ +# Plan: JetBrains Tool Output Code-Block Body + +## Goal + +Render expandable JetBrains tool output bodies like markdown code blocks: editor-style text, no line wrapping, horizontal scrolling, and vertical scrolling inside the existing capped tool body height. Preserve lazy creation on first expand and release editor resources through the same `Disposer` ownership pattern used by markdown code blocks. + +## Current Findings + +- `ToolView` and `BaseSearchToolView` already pass `SecondarySessionPartView(parts.header, { parts.scroll(tool) })`, so expandable bodies are lazy through `AbstractSessionPartView.body()` and are first created by `expand()`. +- Current tool bodies are `JBTextArea` instances created in `ToolParts.body()` with `lineWrap = true` and horizontal scrolling disabled. +- Markdown code blocks in `MdViewHybrid` create an `EditorTextField`, call `setDisposedWith(blockDisposable)`, register selection under that disposable, and dispose stale blocks from `removeBlocks()`, `clearBlocks()`, and `dispose()`. +- `ReadToolView` currently creates `parts.scroll(tool)` eagerly because it is non-expandable. Avoid turning that path into an eager editor-backed body unless it is deliberately refactored. + +## Implementation Steps + +1. Add an editor-backed tool output body helper in the JetBrains frontend, likely under `frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/`. + - Own a `Disposable` created with `Disposer.newDisposable("Tool body")`. + - Create an `EditorTextField` with a plain-text document and `PlainTextFileType.INSTANCE`. + - Call `setDisposedWith(disposable)` on the field, matching `MdViewHybrid.CodeField`. + - Use a `runCatching` fallback to a `JBTextArea(lineWrap = false)` if editor creation fails, mirroring markdown code block resilience. + - Disable soft wraps and inner editor scrollbars; let the outer `JBScrollPane` own scrolling. + - Set outer policies to `HORIZONTAL_SCROLLBAR_AS_NEEDED` and `VERTICAL_SCROLLBAR_AS_NEEDED`. + - Size the inner component from full text width/height so the outer scroll pane can scroll both axes. + +2. Integrate the helper into expandable tool bodies only. + - Keep `ToolView` and `BaseSearchToolView` using lazy `parts.scroll(tool)` so collapsed updates do not instantiate the editor. + - Avoid direct `parts.text` access that creates a body. Keep body access nullable and no-op when collapsed. + - Keep `ReadToolView` on the existing summary path or give it an explicit text-area body mode so it does not eagerly create editor resources. + - Register the tool body disposable under the owning `ToolView`/`BaseSearchToolView` on first body creation, so `Disposer.dispose(view)` releases the editor just like markdown blocks. + - Do not dispose on ordinary collapse; collapse should detach and re-expand should reuse the same body, matching existing retained Swing behavior. Disposal happens when the part view is removed, replaced, cleared, or disposed. + +3. Update styling and behavior in place. + - Use editor-derived font/colors for the body to match markdown code blocks. + - Preserve existing error foreground behavior where practical, including the fallback text area path. + - Keep existing `ToolView.getPreferredSize()` and `BaseSearchToolView.getPreferredSize()` height caps, so long output scrolls vertically within `SessionUiStyle.View.Tool.BODY_LINES`. + - Keep header fonts, labels, icons, and collapse/expand behavior unchanged. + +4. Update tests. + - `ToolViewTest`: change wrapping/scroll assertions to no wrap, horizontal `AS_NEEDED`, vertical `AS_NEEDED`; assert first expand creates one editor-backed body; collapse/re-expand reuses the same scroll/editor; collapsed updates do not create the body; expanded updates mutate the same editor text. + - `SearchToolViewTest` and `GlobToolViewTest`: assert lazy creation, reuse, and no-wrap horizontal/vertical scrolling for shared search body behavior. + - `ReadToolViewTest`: assert non-expandable read summary behavior is unchanged and does not accidentally become an expandable editor body. + - Add a tool body leak/stress test similar to `MdViewHybridStressTest`: capture `EditorFactory.getInstance().allEditors.size`, expand/dispose or replace many tool views after forcing editor creation, drain the EDT, and assert editor count returns to baseline. + - Add a style update assertion that applying `SessionEditorStyle` changes the retained body in place without rebuilding the editor. + +5. Verification + +- Run targeted JetBrains frontend tests for the affected views. +- Run the new/updated leak test. +- Run `./gradlew typecheck` from `packages/kilo-jetbrains/` before marking the implementation ready. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 99641c5e599..e971d09f3af 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -287,7 +287,17 @@ class SessionUi( dismiss = { controller.dismissLoginRequired() }, selection = selection, ) - messageBody = SessionMessageListPanel(controller.model, this, question, permission, login, ::openFile, ::openUrl, selection) + messageBody = SessionMessageListPanel( + controller.model, + this, + question, + permission, + login, + ::openFile, + ::openUrl, + selection, + resize = { anchor, fn -> scroll.preserve(anchor, fn) }, + ) header = SessionHeaderPanel(controller, this) scroll = SessionScroll(root, sessionContent, messageBody, blankBody) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt index d320d179b09..80043e8c264 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt @@ -139,6 +139,33 @@ internal class SessionScroll( return component.viewport.view === messages && tail } + @RequiresEdt + fun preserve(anchor: JComponent, action: () -> Unit) { + if (component.viewport.view !== messages) { + action() + return + } + val pos = SwingUtilities.convertPoint(anchor, Point(0, 0), messages) + val delta = pos.y - component.viewport.viewPosition.y + seq++ + stable = -1 + user = false + auto = true + try { + action() + layoutScroll() + val next = SwingUtilities.convertPoint(anchor, Point(0, 0), messages) + val y = (next.y - delta).coerceIn(0, bottom()) + component.viewport.viewPosition = Point(0, y) + bar.value = y + } finally { + auto = false + } + tail = atBottom() + syncValue() + updateJump() + } + @RequiresEdt fun openBottom(done: () -> Unit) { opening = true 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 ec87a14abe7..3af80c81b74 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 @@ -16,6 +16,7 @@ import ai.kilocode.client.session.views.TurnView import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.util.ui.JBUI +import javax.swing.JComponent /** * Scrollable transcript panel that maps the model's turn grouping to @@ -51,6 +52,7 @@ class SessionMessageListPanel( private val openFile: (String) -> Unit, private val openUrl: (String) -> Unit = {}, private val selection: SessionSelection? = null, + private val resize: ((JComponent, () -> Unit) -> Unit)? = null, ) : SessionLayoutPanel( JBUI.scale(SessionUiStyle.SessionLayout.GAP), JBUI.insets( @@ -177,7 +179,7 @@ class SessionMessageListPanel( // ------ private event handlers ------ private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) { - val tv = TurnView(turn.id, openFile, style, openUrl, selection) + val tv = TurnView(turn.id, openFile, style, openUrl, selection, resize) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue @@ -233,7 +235,7 @@ class SessionMessageListPanel( removeAll() for (turn in model.turns()) { - val tv = TurnView(turn.id, openFile, style, openUrl, selection) + val tv = TurnView(turn.id, openFile, style, openUrl, selection, resize) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue 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 8075b25e918..2148e86b2ef 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 @@ -19,6 +19,7 @@ import com.intellij.util.ui.JBUI import java.awt.Graphics import java.awt.Graphics2D import java.awt.RenderingHints +import javax.swing.JComponent /** * A single message container inside a [TurnView]. @@ -37,6 +38,7 @@ class MessageView( private var style: SessionEditorStyle = SessionEditorStyle.current(), private val openUrl: (String) -> Unit = {}, private val selection: SessionSelection? = null, + private val resize: ((JComponent, () -> Unit) -> Unit)? = null, ) : ai.kilocode.client.session.ui.SessionLayoutPanel( JBUI.scale(SessionUiStyle.SessionLayout.GAP), ), Disposable, SessionEditorStyleTarget, SessionView { @@ -128,6 +130,7 @@ class MessageView( } } val view = view(content) + view.resize = resize view.applyStyle(style) parts[content.id] = view add(view) @@ -157,6 +160,7 @@ class MessageView( remove(existing) Disposer.dispose(existing) val view = view(content) + view.resize = resize view.applyStyle(style) parts[content.id] = view add(view, at) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt index 5320b21dbf0..d2cbed089de 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt @@ -9,6 +9,7 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.util.ui.JBUI +import javax.swing.JComponent /** * Top-level transcript item representing one conversational turn. @@ -25,6 +26,7 @@ class TurnView( private var style: SessionEditorStyle = SessionEditorStyle.current(), private val openUrl: (String) -> Unit = {}, private val selection: SessionSelection? = null, + private val resize: ((JComponent, () -> Unit) -> Unit)? = null, ) : SessionLayoutPanel(JBUI.scale(SessionUiStyle.SessionLayout.GAP)), Disposable, SessionEditorStyleTarget { constructor(id: String, openFile: (String) -> Unit) : this(id, openFile, SessionEditorStyle.current()) @@ -37,7 +39,7 @@ class TurnView( /** Add a new [MessageView] for [msg] at the end of this turn. */ fun addMessage(msg: Message): MessageView { - val view = MessageView(msg, openFile, style, openUrl, selection) + val view = MessageView(msg, openFile, style, openUrl, selection, resize) messages[msg.info.id] = view add(view) revalidate() 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 e5cb30d95a3..a05bad9d898 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 @@ -65,7 +65,7 @@ abstract class AbstractSessionPartView( fun toggle() { if (!expandable || !arrow.isVisible) return - val changed = if (isExpanded()) collapse() else expand() + val changed = toggleLocal() if (!changed) return syncArrow() refresh() @@ -89,6 +89,15 @@ abstract class AbstractSessionPartView( protected fun bodyComponent(): JComponent = body() + private fun toggleLocal(): Boolean { + val fn = resize ?: return toggleBody() + val expanded = isExpanded() + fn(this) { toggleBody() } + return expanded != isExpanded() + } + + private fun toggleBody(): Boolean = if (isExpanded()) collapse() else expand() + fun syncExpandable(expandable: Boolean): Boolean { val active = this.expandable && expandable val changed = setVisible(arrow, active) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt index 41d4d57853a..35d03606660 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import com.intellij.openapi.Disposable +import javax.swing.JComponent import javax.swing.JPanel /** @@ -20,6 +21,8 @@ abstract class PartView : JPanel(), Disposable, SessionEditorStyleTarget { /** Stable [Content.id] this renderer was created for. */ abstract val contentId: String + var resize: ((JComponent, () -> Unit) -> Unit)? = null + /** * Apply a full content update — replace, not append. * Called when [ai.kilocode.client.session.model.SessionModelEvent.ContentUpdated] fires. 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 e38f6548330..88150be84c7 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 @@ -122,13 +122,17 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = } fun toggle() { + resize?.invoke(this) { toggleBody() } ?: toggleBody() + syncArrow() + refresh() + } + + private fun toggleBody() { if (isExpanded()) { pane?.let { root.remove(it) } } else { root.add(body(), BorderLayout.CENTER) } - syncArrow() - refresh() } fun isExpanded(): Boolean = pane?.parent === root 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 3422c423fde..5eabc89c1b5 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 @@ -4,24 +4,30 @@ import ai.kilocode.client.session.ui.SessionMessageListPanel import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.MessageErrorDto +import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.QuestionInfoDto import ai.kilocode.rpc.dto.QuestionOptionDto 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.ToolView import ai.kilocode.client.plugin.KiloBundle import com.intellij.ui.EditorTextField import com.intellij.ui.components.JBScrollPane import com.intellij.ui.components.JBRadioButton import com.intellij.util.ui.JBUI import java.awt.Container +import java.awt.Point import javax.swing.AbstractButton import javax.swing.JButton +import javax.swing.JComponent import javax.swing.Scrollable import javax.swing.SwingConstants import javax.swing.JTextArea +import javax.swing.SwingUtilities import kotlinx.coroutines.CompletableDeferred @Suppress("UnstableApiUsage") @@ -221,6 +227,51 @@ class SessionScrollTest : SessionUiTestBase() { assertEquals(value, bar.value) } + fun `test expanding tool at bottom preserves clicked header position`() { + val mid = "tool_expand_bottom" + val pid = "tool_expand_bottom_part" + rpc.history.addAll(history(23) + toolHistory(mid, pid) + historyRange(1, start = 23)) + ui = newUi(id = "ses_test") + settle() + drainScroll() + val bar = scrollBar() + setBottom(bar) + drainScroll() + val view = toolView(mid, pid) + assertFalse(view.bodyVisible()) + val y = visibleY(view) + val value = bar.value + + view.toggle() + drainScroll() + + assertTrue(view.bodyVisible()) + assertEquals(y, visibleY(view)) + assertEquals(value, bar.value) + } + + fun `test expanding tool in middle preserves clicked header position`() { + val mid = "tool_expand_middle" + val pid = "tool_expand_middle_part" + rpc.history.addAll(history(12) + toolHistory(mid, pid) + historyRange(12, start = 12)) + ui = newUi(id = "ses_test") + settle() + drainScroll() + val bar = scrollBar() + val view = toolView(mid, pid) + val top = SwingUtilities.convertPoint(view, Point(0, 0), scrollView()).y + setValue(bar, top - 80) + drainScroll() + val y = visibleY(view) + + view.toggle() + drainScroll() + + assertTrue(view.bodyVisible()) + assertEquals(y, visibleY(view)) + assertTrue(jumpButton().isVisible) + } + fun `test long prompt message follows when transcript is at bottom`() { showMessages() fillTranscript(24) @@ -874,6 +925,15 @@ 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 { + val messages = find(ui) + return messages.findMessage(mid)?.part(pid) as? ToolView + ?: error("missing tool $mid/$pid\n${messages.dumpDetailed()}") + } + + private fun visibleY(component: JComponent): Int = + SwingUtilities.convertPoint(component, Point(0, 0), scrollComponent()).y + private inline fun findAll(root: Container = ui): List = findAll(root, T::class.java) private fun findAll(root: Container, cls: Class): List { @@ -970,4 +1030,27 @@ class SessionScrollTest : SessionUiTestBase() { ), tool = ToolRefDto("msg1", "call1"), ) + + private fun toolPart(id: String, mid: String) = PartDto( + id = id, + sessionID = "ses_test", + messageID = mid, + type = "tool", + tool = "bash", + callID = "call_$id", + state = "completed", + title = "print output", + output = "output line\n".repeat(160), + ) + + private fun toolHistory(mid: String, pid: String) = MessageWithPartsDto( + message(mid).copy(role = "assistant"), + listOf(toolPart(pid, mid)), + ) + + private fun historyRange(count: Int, start: Int) = List(count) { offset -> + val i = start + offset + val id = "hist_range_$i" + MessageWithPartsDto(message(id), listOf(part("hist_range_part_$i", id, "text", text(i)))) + } } 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 cc7a2811cf2..93a3d2c173a 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,6 +3,7 @@ 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.views.tool.ToolView import com.intellij.testFramework.fixtures.BasePlatformTestCase @Suppress("UnstableApiUsage") 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 1d2c8a8e705..1cb08911d5a 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 @@ -6,6 +6,7 @@ import ai.kilocode.client.session.model.toolKind import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.question.QuestionResultView +import ai.kilocode.client.session.views.tool.ToolView import com.intellij.testFramework.fixtures.BasePlatformTestCase import java.awt.Color import java.awt.Component From e56e0f24504efb59129e2070c846b4d70cc1a8ac Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 8 Jun 2026 16:12:42 -0400 Subject: [PATCH 12/26] chore: remove local plan files --- .kilo/plans/1780527326684-sunny-knight.md | 58 ---- .kilo/plans/1780527817231-quick-eagle.md | 304 -------------------- .kilo/plans/1780676598127-hidden-eagle.md | 83 ------ .kilo/plans/1780677922045-curious-canyon.md | 64 ----- .kilo/plans/1780936267001-gentle-star.md | 61 ---- .kilo/plans/1780937014434-stellar-island.md | 68 ----- .kilo/plans/1780942060646-cosmic-rocket.md | 69 ----- .kilo/plans/1780945098899-witty-island.md | 49 ---- 8 files changed, 756 deletions(-) delete mode 100644 .kilo/plans/1780527326684-sunny-knight.md delete mode 100644 .kilo/plans/1780527817231-quick-eagle.md delete mode 100644 .kilo/plans/1780676598127-hidden-eagle.md delete mode 100644 .kilo/plans/1780677922045-curious-canyon.md delete mode 100644 .kilo/plans/1780936267001-gentle-star.md delete mode 100644 .kilo/plans/1780937014434-stellar-island.md delete mode 100644 .kilo/plans/1780942060646-cosmic-rocket.md delete mode 100644 .kilo/plans/1780945098899-witty-island.md diff --git a/.kilo/plans/1780527326684-sunny-knight.md b/.kilo/plans/1780527326684-sunny-knight.md deleted file mode 100644 index f3c24c313ea..00000000000 --- a/.kilo/plans/1780527326684-sunny-knight.md +++ /dev/null @@ -1,58 +0,0 @@ -# Filter Thematic Breaks From JetBrains Hybrid Markdown - -## Goal - -Remove unwanted rendered `
        ` lines from the JetBrains hybrid markdown view, especially separators that appear after code blocks, while preserving the current improvement that coalesces consecutive prose blocks into one `JBHtmlPane`. - -## Current Context - -- Uncommitted changes in `MdViewHybrid.kt` coalesce adjacent non-code CommonMark block nodes into a single `Desc.Html` via `Visitor.run`. -- The visible top line in the screenshot is likely CommonMark `ThematicBreak` output from markdown like `---`, rendered by `JBHtmlPane`/IntelliJ CSS as `
        `. -- Filtering via CSS is not ideal because the `
        ` node can still contribute layout/spacing and keeps `view.html()` inconsistent with the intended rendered content. - -## Recommended Approach - -Filter CommonMark `ThematicBreak` nodes out of the hybrid markdown projection model before they become UI blocks or exported HTML. - -This is preferable to hiding `hr` with CSS because it removes the unwanted semantic node entirely and avoids residual spacing. - -## Implementation Steps - -1. Update `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt`. -2. Import `org.commonmark.node.ThematicBreak`. -3. Extend `Desc.Html` to carry only wanted prose HTML; do not create any `Desc` for thematic breaks. -4. In `Visitor.visitChildren`, skip `ThematicBreak` children before appending rendered block HTML. -5. Ensure `project()` builds `Projection.html` from the same filtered block descriptors used for the UI. - - The current `flush()` appends `renderer.render(doc)` directly to the projection HTML, which would still include `
        ` even if `Visitor` skips it for UI blocks. - - Adjust `flush()` to parse the markdown, collect filtered descriptions, append them to `blocks`, and append only filtered HTML/code HTML to the projection string. -6. Keep fenced and indented code behavior unchanged. -7. Preserve the new coalescing behavior for adjacent prose blocks. - -## Tests - -Update `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt` with targeted regression coverage: - -1. Add a test where a thematic break follows a fenced code block: - - Markdown: fenced code, blank line, `---`, blank line, heading/prose. - - Assert one code scroll pane remains. - - Assert one HTML pane remains for the heading/prose. - - Assert `htmls().single().text` does not contain `")) - assertTrue(html.contains("
          ")) - assertTrue(html.contains("class A")) - assertFalse(html.contains(" view.append(" more$i") } - - assertSame(intro, htmls().first()) - assertSame(tail, htmls().last()) - assertSame(editor, editors().single()) - assertEquals(2, htmls().size) - assertEquals(1, scrolls().size) - assertFalse(editor.getEditor(true)!!.isDisposed) - assertTrue(view.markdown().contains("more99")) - } - - fun `test repeated same structure set reuses single editor and stays bounded`() { - repeat(150) { i -> - view.set("```kotlin\nval x = $i\n```") - editors().single().getEditor(true) - } - val editor = editors().single() - - repeat(50) { i -> view.set("```kotlin\nval y = $i\n```") } - - assertSame(editor, editors().single()) - assertEquals(1, scrolls().size) - assertEquals(1, panel().componentCount) - assertEquals("val y = 49", editor.text) - } - - fun `test structural churn releases every editor after clear`() { - val base = EditorFactory.getInstance().allEditors.size - - repeat(60) { i -> - view.set("```kotlin\nval x = $i\n```") - editors().single().getEditor(true) - view.set("```java\nclass A$i {}\n```") - editors().single().getEditor(true) - view.set("plain prose $i") - } - - view.clear() - drainEdt() - - assertTrue(scrolls().isEmpty()) - assertTrue(htmls().isEmpty()) - assertEquals(0, panel().componentCount) - assertEquals(base, EditorFactory.getInstance().allEditors.size) - } - - fun `test streaming code body reuses one editor and keeps html in sync`() { - view.append("```java\n") - val pane = scrolls().single() - val editor = editors().single() - - val body = StringBuilder() - repeat(100) { i -> - val line = "void m$i() {}\n" - body.append(line) - view.append(line) - } - - assertSame(pane, scrolls().single()) - assertSame(editor, editors().single()) - assertEquals(body.toString().trimEnd('\n'), editor.text) - assertTrue(view.html().contains("void m0()")) - assertTrue(view.html().contains("void m99()")) - - view.append("```") - - assertSame(pane, scrolls().single()) - assertSame(editor, editors().single()) - } - - fun `test style changes during streaming do not rebuild components`() { - view.append("intro\n\n```kotlin\nval x = 1\n```\n\n") - val intro = htmls().first() - val editor = editors().single() - editor.getEditor(true) - val styled = SessionEditorStyle.create(family = "Courier New", size = 18) - val current = SessionEditorStyle.current() - - repeat(50) { i -> - view.append("line $i ") - view.applyStyle(if (i % 2 == 0) styled else current) - if (i % 5 == 0) view.resetStyles() - } - - assertSame(intro, htmls().first()) - assertSame(editor, editors().single()) - assertFalse(editor.getEditor(true)!!.isDisposed) - assertEquals(2, htmls().size) - assertEquals(1, scrolls().size) - assertTrue(view.markdown().contains("line 49")) - } - - private fun panel(): JPanel = view.component as JPanel - - private fun scrolls(): List = panel().components.filterIsInstance() - - private fun htmls(): List = panel().components.filterIsInstance() - - private fun struts(): List = panel().components.filterIsInstance() - - private fun editors(): List = scrolls().mapNotNull { it.viewport.view as? EditorTextField } - - private fun drainEdt() { - UIUtil.dispatchAllInvocationEvents() - } -} -```` - -Copy the file content verbatim. If any expected-count assertion fails, see -"If a test fails" before changing it. - -## Step 2 — Add the AGENTS.md rule - -Edit `packages/kilo-jetbrains/AGENTS.md`. In the "### Swing Component Lifecycle" section, -the bullet list under "Tests for retained Swing components should assert:" ends with this -exact line (anchor): - -``` -- No-op updates, empty deltas, repeated hover values, and toggling non-expandable cards do not repaint/revalidate the whole view. -``` - -Immediately AFTER that line (before the blank line and `### Platform Components and Utilities`), -insert: - -``` -- Streaming/rebuilding surfaces additionally require stress + leak tests (see below). - -### Stress and Leak Tests for Streaming UI - -Session/transcript UI that streams updates or rebuilds its component tree (markdown -views, code blocks, transcript parts, collapsible cards) must ship stress + leak tests in -addition to behavior tests. These tests must: - -- Drive many updates (hundreds of streamed deltas or `set` cycles) through the public API. -- Assert that retained component instances stay identical across updates (`assertSame`). -- Assert the component count stays bounded — no growth per update. -- Assert disposable-backed resources return to baseline after churn + clear/dispose. - For code editors, compare `EditorFactory.getInstance().allEditors.size` against a - baseline captured before the loop. - -See `MdViewHybridStressTest` for the reference pattern. -``` - -Do not pad markdown tables or reflow other lines; only insert the block above. - -## Step 3 — Verify - -Java 21 is required and already present. From `packages/kilo-jetbrains/`: - -1. `./gradlew :frontend:test --tests ai.kilocode.client.ui.md.MdViewHybridStressTest` -2. `./gradlew :frontend:test --tests "ai.kilocode.client.ui.md.*"` (regression for the package) -3. `./gradlew typecheck` - -All three must pass. The targeted run in (1) is the primary signal. - -## If a test fails - -A failure in the churn/leak test (`allEditors` not back to baseline) or a retention test -(`assertSame` fails) likely indicates a real defect — most plausibly an editor disposable -not being disposed on a specific removal path, or `sync()` rebuilding instead of reusing. -Fix it with the smallest possible change in `MdViewHybrid.kt`, keep the test, and note the -fix. No `kilocode_change` markers (Kilo-owned package). - -If a count assertion is off by a fixed amount because of a layout detail (e.g. an -unexpected leading/trailing strut), first confirm the real structure by reading -`addBlock`/`addGap`/`removeBlocks` (`MdViewHybrid.kt:450`–`:460`) and adjust the EXPECTED -constant in the test to match correct behavior — do not loosen the assertion to a range. - -## Constraints - -- Plan/test only; no JCEF/Compose/UI DSL (tests use plain Swing tree inspection). -- No changeset (not user-facing). -- New file is under a Kilo-owned path — no `kilocode_change` markers. diff --git a/.kilo/plans/1780676598127-hidden-eagle.md b/.kilo/plans/1780676598127-hidden-eagle.md deleted file mode 100644 index 0bcaaf7bb2a..00000000000 --- a/.kilo/plans/1780676598127-hidden-eagle.md +++ /dev/null @@ -1,83 +0,0 @@ -# Fix JetBrains Reasoning Session UI - -## Goal -Update the JetBrains chat/session reasoning UI so that: - -- Empty reasoning blocks are not visible in the transcript. -- Streaming reasoning opens by default while content is being added. -- The expanded reasoning body is capped at 5 visible lines and follows newly streamed content inside its own scroll pane. -- Reasoning uses a vertical visual separator instead of the current horizontal top separator, and no separator is visible while collapsed. -- Consecutive reasoning parts render as one reused reasoning block instead of multiple reasoning blocks in a row. - -## Relevant Findings - -- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt` renders reasoning blocks. - - It currently extends `SecondarySessionPartView` collapsed by default. - - It creates the body lazily as a nested `JBScrollPane` and already has `SessionUiStyle.View.Reasoning.BODY_LINES = 5`. - - It currently uses `SessionUiStyle.View.topOutline()` on the scroll pane, which creates the unwanted horizontal separator. - - Blank reasoning disables the toggle but can still leave a visible header/card. -- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt` owns the ordered rendered part views for a message. - - It currently creates one `ReasoningView` per `Reasoning` content part. - - There is no adjacent reasoning coalescing, so consecutive reasoning parts can render as multiple blocks. -- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt` should not need a protocol/model change for this UI fix. - - Keeping the merge at the `MessageView`/rendering layer avoids changing controller snapshot/glue behavior for streamed deltas. - -## Implementation Plan - -1. Update `ReasoningView` behavior. - - Track the current `Reasoning.done` state in the view. - - Make the view invisible when its source is blank, not merely non-expandable. This prevents empty reasoning chrome from contributing visible layout. - - Initialize non-blank, unfinished reasoning (`done == false`) expanded by default. - - When blank unfinished reasoning receives its first non-empty delta/update, reveal and expand it automatically. - - Preserve user collapse after the body has existed; do not force-reopen an already-created body on every delta. - - Keep completed/history reasoning collapsed by default. - -2. Keep the expanded body capped to 5 visible lines. - - Reuse the existing `SessionUiStyle.View.Reasoning.BODY_LINES = 5` and existing preferred-size cap. - - Strengthen tests so the expanded preferred height stays bounded rather than only checking the constant. - -3. Add nested autoscroll for streaming reasoning. - - After `md.set(...)` or `md.append(...)` while the reasoning body is visible, schedule an EDT tail-scroll on the nested `JBScrollPane`. - - Use the scroll pane viewport/vertical scrollbar, not the global transcript scroll, so new reasoning content follows inside the block. - - Keep horizontal scrolling disabled as it is today. - -4. Replace the reasoning separator styling. - - Remove the reasoning body scroll pane’s `SessionUiStyle.View.topOutline()` border. - - Add/use a left-side line border for the reasoning body only, for example via a `SessionUiStyle.View.leftOutline()` helper or a direct `JBUI.Borders.customLine(SessionUiStyle.View.line(), 0, 1, 0, 0)` call. - - Keep the separator attached to the body/scroll pane, so collapsed reasoning has no vertical separator. - -5. Coalesce consecutive reasoning views in `MessageView`. - - Add a small rendering-layer helper that identifies adjacent rendered reasoning parts and reuses the previous `ReasoningView` when a new `Reasoning` part follows another reasoning part. - - Maintain a part-id alias map so deltas for the later reasoning part route to the reused first `ReasoningView`. - - Treat blank reasoning parts as non-visible; when they later receive content, upsert through the same helper so they either create the first visible reasoning block or merge into the previous one. - - Rebuild or resync aliases when `rebuildParts()` runs for hidden question/todo tool changes. - - Keep separate reasoning blocks when a visible non-reasoning part appears between them. - -6. Add/adjust tests. - - `ReasoningViewTest`: - - Streaming non-blank reasoning starts expanded. - - Completed reasoning remains collapsed by default. - - Blank reasoning is not visible and has no toggle. - - First content added to blank unfinished reasoning reveals/expands the block. - - Expanded reasoning remains capped to five rows. - - Appended content scrolls the nested reasoning viewport to the bottom. - - The reasoning body uses a left/vertical separator and no top separator. - - `TurnViewTest` or `SessionMessageListPanelTest`: - - Consecutive reasoning parts in one assistant message render as one reasoning block. - - Deltas sent to a later consecutive reasoning part append to the reused block. - - A text/tool part between two reasoning parts keeps them as separate blocks. - - Empty reasoning followed by another view does not produce a visible empty reasoning block. - -7. Add release note. - - Create a patch changeset under `.changeset/` for `"kilo-code"` describing the JetBrains reasoning UI fix from the user perspective. - -8. Verify. - - Run targeted JetBrains frontend tests first, for example: - - `./gradlew test --tests ai.kilocode.client.session.views.ReasoningViewTest --tests ai.kilocode.client.session.views.TurnViewTest --tests ai.kilocode.client.session.ui.SessionMessageListPanelTest` - - Run `./gradlew typecheck` from `packages/kilo-jetbrains/` after the targeted tests pass. - - If Java 21 is not active, follow the repo instruction to switch/install Java 21 before verification. - -## Notes - -- This plan intentionally avoids changing `SessionModel` or `SessionController` unless implementation uncovers a blocker. A model-level merge would risk interacting with controller delta snapshot/glue logic for aliased part IDs. -- The changes stay inside `packages/kilo-jetbrains/`, which is Kilo-owned code; `kilocode_change` markers are not needed. diff --git a/.kilo/plans/1780677922045-curious-canyon.md b/.kilo/plans/1780677922045-curious-canyon.md deleted file mode 100644 index 2e8db10578c..00000000000 --- a/.kilo/plans/1780677922045-curious-canyon.md +++ /dev/null @@ -1,64 +0,0 @@ -# Fix JetBrains Profile 400 Load Error - -## Goal - -Prevent JetBrains backend app startup from failing when the optional `/kilo/profile` request returns `400 Bad Request`, as seen in: - -```text -ai.kilocode.jetbrains.api.infrastructure.ClientException: Client error : 400 Bad Request - at ai.kilocode.jetbrains.api.client.DefaultApi.kiloProfile(DefaultApi.kt:6048) - at ai.kilocode.backend.app.KiloBackendAppService.fetchProfile(KiloBackendAppService.kt:499) -``` - -The app should continue to `Ready` with `profile = null`, matching existing behavior for unauthenticated or temporarily unavailable profile data. - -## Findings - -- The failing code is in `packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt`. -- `load()` treats profile as optional in comments and state semantics, but currently turns `fetchProfile()` errors into a fatal `LoadFailure`: - - `fetchProfile()` returns `FetchResult.fail("profile", e)` for `ClientException` statuses other than `401`. - - `load()` adds that error and throws, causing `KiloAppState.Error`. -- `/kilo/profile` in the CLI HTTP API explicitly declares `BadRequest` and `Unauthorized` as possible errors: - - `packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts` - - The handler maps upstream gateway/profile/balance failures to `HttpApiError.BadRequest`: - - `packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts` -- The accompanying CLI stderr timeout for organization modes is logged by `packages/kilo-gateway/src/api/modes.ts`, but that function already catches the timeout and returns `[]`; it is not the direct source of the JetBrains app-load failure. -- Existing backend tests already verify that profile `401` and `500` do not prevent `Ready`; there is no test for profile `400`. - -## Implementation Plan - -1. Update `KiloBackendAppService.fetchProfile()`. - - In the `catch (e: ClientException)` branch, treat `e.statusCode == 400` as optional profile unavailability, like `401`. - - Return `FetchResult.ok(null)` for `400`. - - Log it without surfacing a full warning stack as a required app-load failure. A concise warning such as `Profile: unavailable (400)` is appropriate, with `logResponseBody("profile", e)` retained for response diagnostics if useful. - - Keep `401` as `FetchResult.ok(null)` with the existing not-logged-in info log. - - Keep other unexpected `4xx` statuses as failures unless implementation review shows the generated API can only emit `400` and `401` for this endpoint. - - Update the method comment from “401 and 5xx” to include `400`/gateway profile unavailability. - -2. Add backend test coverage in `KiloBackendAppServiceTest`. - - Add `profile 400 does not prevent Ready` near the existing `profile 401 does not prevent Ready` and `profile 500 does not prevent Ready` tests. - - Use the existing `MockCliServer` knobs: - - `mock.profileStatus = 400` - - `mock.profile = """{"error":"bad request"}"""` - - Assert: - - app reaches `KiloAppState.Ready` - - `svc.profile == null` - - the final app state is `Ready`, not `Error` - - Optionally assert the log contains the concise profile-unavailable message, but avoid brittle exact stack/body assertions. - -3. Add a patch changeset. - - Create `.changeset/.md` with package `"kilo-code": patch`. - - User-facing wording: `Keep the JetBrains plugin ready when optional Kilo profile loading returns a gateway bad request.` - -4. Verify. - - Run targeted backend test: - - `./gradlew :backend:test --tests ai.kilocode.backend.app.KiloBackendAppServiceTest` - - Run package typecheck: - - `./gradlew typecheck` - - If the targeted test task has stale Gradle incremental behavior, rerun with `--rerun-tasks`. - -## Notes - -- This plan intentionally keeps the fix in `packages/kilo-jetbrains/backend/` and does not change the CLI `/kilo/profile` API or generated client. -- No `kilocode_change` markers are needed because `packages/kilo-jetbrains/` is Kilo-owned code. -- The CLI organization modes timeout log may still appear, but it is already handled as non-fatal by the gateway package. The JetBrains app-load error is caused by treating the separate profile `400` as fatal. diff --git a/.kilo/plans/1780936267001-gentle-star.md b/.kilo/plans/1780936267001-gentle-star.md deleted file mode 100644 index de89bc5da7e..00000000000 --- a/.kilo/plans/1780936267001-gentle-star.md +++ /dev/null @@ -1,61 +0,0 @@ -# Plan: JetBrains Glob Tool View Parity - -## Goal -Implement a JetBrains chat tool view for `glob` that matches the VS Code behavior more closely while using the requested stacked layout: - -- Tool name row -- Directory row -- Pattern row -- Expanded body containing the glob output/content - -## Current State -- JetBrains currently classifies `glob` as `ToolKind.READ` in `frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt`. -- `ViewFactory` routes all read-kind tools to `ReadToolView`. -- `ReadToolView` is non-expandable and optimized for `read` file/directory results, so glob output is not shown as an expandable content body. -- Generic `ToolView` already supports collapsible output, lazy body creation, state labels, editor-derived fonts, and capped body height, but its header is one-line: title plus subtitle args. -- VS Code/kilo-ui renders `glob` with a compact header containing title, directory, and `pattern=...`, and expanded content is just the tool output rendered as content, not raw JSON. - -## Implementation Steps -1. Add a dedicated `GlobToolView` in `frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt`. -2. Make `GlobToolView.canRender(tool)` return true only for `tool.name == "glob"`. -3. Route `glob` before `ReadToolView` in `ViewFactory.create` so `glob` no longer falls into the read-file renderer. -4. Update `ViewFactory.shouldReplace` so streamed updates replace views correctly when a part changes into or out of `GlobToolView`. -5. Build the `GlobToolView` header as a vertical Swing stack using the existing Swing style rules: - - First row: icon plus `Glob` title and pending/running/error state where applicable. - - Second row: directory from `tool.input["path"]`, falling back to `tool.title` or blank if absent. - - Third row: `pattern=` from `tool.input["pattern"]`, hidden when absent. -6. Use existing tool-body behavior for expanded content: - - Collapsed by default. - - Expandable only when `tool.output` or `tool.error` is non-blank. - - Body text is the plain output plus error, matching existing `plainBody` behavior. - - Lazy-create the `JBTextArea`/`JBScrollPane` only on first expansion or direct body access. - - Keep the existing body max-height cap and editor-font styling. -7. Keep `include` out of the glob header for VS Code parity unless a later requirement explicitly asks for it. -8. Do not introduce JCEF, Compose, or Kotlin UI DSL. Keep the implementation in the existing retained Swing view stack. - -## Tests -1. Add `GlobToolViewTest` under `frontend/src/test/kotlin/ai/kilocode/client/session/views/`. -2. Test header layout/accessors: - - Title contains `Glob`. - - Directory row is separate from pattern row. - - Pattern row renders as `pattern=...`. -3. Test expanded content: - - Completed glob with output starts collapsed and has a toggle. - - After toggle, body is visible and contains the output exactly. -4. Test retained/lazy behavior: - - Body is not created while collapsed. - - First expansion creates it once. - - Collapse/re-expand reuses the same body component. - - Updating while collapsed does not eagerly create the body. -5. Update `ReadToolViewTest` expectations so `glob` routes to `GlobToolView`, while `read` and `grep` still route to `ReadToolView` unless a broader search-view task is requested later. -6. Add or update `ViewFactory.shouldReplace` tests if existing coverage does not catch `ReadToolView` to `GlobToolView` replacement. - -## Verification -Run the smallest relevant JetBrains checks: - -- `./gradlew typecheck` from `packages/kilo-jetbrains/` -- Targeted frontend tests covering session views, or the package test task if targeted Gradle test selection is not available. - -## Notes -- This plan intentionally scopes the change to `glob`. `grep`, `ls`, and other read-kind tools can be handled separately if the same stacked/search layout is desired later. -- No generated SDK or backend protocol changes are needed because `Tool.input`, `Tool.output`, and `Tool.error` already carry the required data. diff --git a/.kilo/plans/1780937014434-stellar-island.md b/.kilo/plans/1780937014434-stellar-island.md deleted file mode 100644 index 44acfe789a4..00000000000 --- a/.kilo/plans/1780937014434-stellar-island.md +++ /dev/null @@ -1,68 +0,0 @@ -# Plan: Base Search Tool View - -## Goal - -Refactor the JetBrains session tool renderers so `glob` and code search/`grep` share one retained Swing base renderer. The base owns the header layout requested by the user: icon at west, center content as a horizontal arrangement of tool name plus a vertical stack of target labels, with target labels constrained so long values clip/ellipsis instead of forcing the row wider. - -## Current State - -- `GlobToolView` is implemented in `ToolView.kt` and duplicates most of `ToolView` body/update/style behavior. -- `grep` is a read-kind tool and currently routes to `ReadToolView`, so it does not get the new search-style header. -- `ReadToolView.canRender` still matches `glob` and `grep`; current factory routing special-cases `GlobToolView` before `ReadToolView`. -- `Stack.horizontal` uses preferred widths during layout, so it is risky for the outer tool-name/targets row unless the target area is constrained. - -## Implementation Steps - -1. Add an abstract base renderer in `ToolView.kt`, tentatively `BaseSearchToolView`, extending `SecondarySessionPartView`. -2. Move the shared lazy collapsible body behavior from `GlobToolView` into the base: - - retained `ToolParts`/body handling - - `expand`, `getPreferredSize`, `update`, `applyStyle` - - `sync`, `syncBody`, `applyBodyStyle`, `bodyColor`, `bodyMaxHeight` - - completed-state hides state label; pending/running/error keeps state visible -3. Make subclasses provide only the search-specific header data: - - icon for the tool - - localized tool name - - ordered target strings for the current `Tool` - - `canRender` predicate -4. Replace `globParts` with a generic search-header builder used by the base: - - root header: `JPanel(BorderLayout(gap, 0))` - - west: icon label - - center: constrained horizontal row containing tool label and target stack - - target stack: `Stack.vertical(gap = UiStyle.Gap.xs())` with one label per target - - controls/state are retained in the common parts structure so `AbstractSessionPartView` still owns the expand arrow at the far east -5. Configure truncation/clipping for targets: - - put the target stack in a constrained center slot rather than an unconstrained preferred-width-only layout - - set the target stack and target labels to allow zero minimum width on the horizontal axis - - prefer plain single-line label text for targets so Swing/JBLabel clipping can work; avoid HTML wrapping for these labels unless needed - - hide empty target labels and preserve row height from visible targets only -6. Keep `GlobToolView` as a subclass of the base: - - `canRender(tool) = tool.name == "glob"` - - title: `session.part.tool.glob` - - icon: existing `icon(tool)` unless a more specific platform search icon is desired - - targets: directory from `input["path"]`, fallback `title`, then `pattern=` -7. Add `SearchToolView` as the code search/grep subclass: - - `canRender(tool) = tool.name == "grep"` - - title: new bundle key, likely `session.part.tool.search=Search` - - icon: `AllIcons.Actions.Search` - - targets: `path` when present, `pattern=`, `include=`, and only include non-blank values -8. Update `ViewFactory` routing: - - route `GlobToolView` before `ReadToolView` - - route `SearchToolView` before `ReadToolView` - - update `shouldReplace` transitions for entering/exiting both search subclasses and for `QuestionResultView` -9. Update tests: - - refactor `GlobToolViewTest` expectations to the new base layout while preserving lazy body/reuse/update coverage - - add `SearchToolViewTest` covering grep routing, target rows (`pattern`, `include`, optional path), lazy body behavior, style application, and replacement transitions - - update `ReadToolViewTest` so `grep` routes to `SearchToolView` while `ReadToolView.canRender` can still remain broad if the factory handles precedence - - update `SessionUiUpdateTest` with a grep/search rendering assertion - - add a focused layout test that constrains the header/view width and asserts target components do not force a larger width than the available header center area -10. Add a patch changeset if this UI change is release-note worthy for JetBrains users. -11. Run targeted verification from `packages/kilo-jetbrains`: - - `./gradlew :frontend:test --tests 'ai.kilocode.client.session.views.GlobToolViewTest' --tests 'ai.kilocode.client.session.views.SearchToolViewTest' --tests 'ai.kilocode.client.session.views.ReadToolViewTest' --tests 'ai.kilocode.client.session.ui.SessionUiUpdateTest'` - - run `./gradlew typecheck` if the targeted test compile does not cover all changed Kotlin code paths - -## Notes - -- Keep all new UI in Swing and IntelliJ platform components; no Compose, JCEF, or UI DSL. -- Keep user-visible names in `KiloBundle.properties`. -- Avoid changing `ReadToolView.canRender` unless needed; factory precedence is the smaller, lower-risk change. -- Avoid creating new public production accessors only for tests; prefer component-tree inspection or existing internal helpers where possible. diff --git a/.kilo/plans/1780942060646-cosmic-rocket.md b/.kilo/plans/1780942060646-cosmic-rocket.md deleted file mode 100644 index 810ddfbb146..00000000000 --- a/.kilo/plans/1780942060646-cosmic-rocket.md +++ /dev/null @@ -1,69 +0,0 @@ -# Fix JetBrains Session Expand Scroll Anchoring - -## Problem - -When a user clicks a collapsed session part to expand it, the transcript can jump to the tail and move the clicked header upward. The issue is caused by local Swing expand/collapse changing the scroll range while `SessionScroll.tail` is still true. `SessionScroll.onScroll()` treats that adjustment like streaming/model content growth and calls `followBottom(true)`. - -The desired behavior is different for local user toggles: keep the clicked header at the same viewport position. Existing bottom-follow behavior should remain for model-driven updates, streaming deltas, prompt sends, question/login docks, and initial session open. - -## Relevant Code - -- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt` - - `onScroll()` resumes tail-following when `tail == true` and the adjustment was not marked as user scroll. - - `followBottom()` / `followPass()` are correct for model updates and should not be weakened globally. -- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt` - - Header click calls `toggle()`, which adds/removes the body and `revalidate()`s locally. -- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt` - - Has its own independent `toggle()` implementation. -- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt` - - Owns `SessionScroll` and creates `SessionMessageListPanel`; best composition point for wiring scroll anchoring into part views. -- `SessionMessageListPanel`, `TurnView`, and `MessageView` - - Create/replace retained part views and can propagate an optional local-resize handler. - -## Plan - -1. Add a local resize/toggle hook to `PartView`. - - Use a small nullable callback such as `var resize: ((JComponent, () -> Unit) -> Unit)? = null`. - - Keep the default null so standalone view tests and non-session usages behave as today. - - Do not make part views depend directly on `SessionScroll`. - -2. Wrap user toggles in that hook. - - In `AbstractSessionPartView.toggle()`, move the existing body add/remove logic into a private helper and invoke it through `resize?.invoke(this) { ... }` when present. - - In `QuestionResultView.toggle()`, do the same because it does not inherit from `AbstractSessionPartView`. - - Keep direct programmatic `expand()` calls unchanged so model-driven auto-expansion, such as live reasoning, keeps existing autoscroll semantics. - -3. Propagate the hook from the session UI to created part views. - - Add an optional resize callback parameter through `SessionMessageListPanel` -> `TurnView` -> `MessageView`. - - When `MessageView` creates or replaces a `PartView`, assign `view.resize = resize` before adding it. - - Ensure rebuild and replacement paths receive the same callback. - -4. Add anchored local-resize support to `SessionScroll`. - - Add an EDT-only method such as `preserve(anchor: JComponent, action: () -> Unit)`. - - Before `action`, capture the anchor’s Y coordinate in the scroll view and its current offset from `viewport.viewPosition.y`. - - During `action` and the immediate layout/restore, suppress normal autoscroll handling by using the existing `auto` guard or a dedicated guard. - - After layout, set `viewport.viewPosition.y` / scrollbar value so the anchor keeps the same visible Y coordinate, clamped to valid scroll bounds. - - Cancel pending follow passes with `seq++`, set `tail` based on the resulting `atBottom()`, sync `value`, and update the jump button. - - This makes local user expand/collapse opt out of tail-following when it leaves the user away from the bottom. - -5. Wire `SessionUi` to use the anchored resize method. - - Construct `SessionMessageListPanel` with a callback like `{ anchor, fn -> scroll.preserve(anchor, fn) }`. - - This is safe even though `scroll` is assigned just after `messageBody` construction because the callback only runs after the UI is fully built and a user clicks a part. - -6. Add regression tests in `SessionScrollTest`. - - Add a helper to emit a completed `tool` part with large output so it renders collapsed and expands to a meaningful height. - - Test: expanding a visible collapsed tool while currently at bottom keeps that tool/header at the same viewport Y and does not jump to the new bottom. - - Test: expanding a visible collapsed tool while in the middle keeps the same header Y and keeps the jump button visible. - - Add a collapse variant if the first two do not exercise scrollbar clamping enough. - - If easy with existing helpers, add one `QuestionResultView` toggle test because it has a separate toggle path. - -7. Verify with the smallest relevant checks. - - Run targeted JetBrains frontend tests for scroll behavior, e.g. `./gradlew :frontend:test --tests ai.kilocode.client.session.SessionScrollTest` from `packages/kilo-jetbrains/` if supported by the Gradle project. - - If the targeted Gradle selector is not available, run the package test task that includes frontend tests. - - Run `./gradlew typecheck` or `bun run typecheck` from `packages/kilo-jetbrains/` after the implementation compiles locally. - -## Expected Outcome - -- Clicking expand/collapse preserves the clicked card/header position instead of jumping to the transcript tail. -- Streaming and model updates still follow the bottom when the user was already at the bottom. -- Middle-scroll anchoring remains unchanged for non-toggle updates. -- The scroll-to-bottom button appears when a local expansion leaves the user away from the bottom. diff --git a/.kilo/plans/1780945098899-witty-island.md b/.kilo/plans/1780945098899-witty-island.md deleted file mode 100644 index 9464bba9788..00000000000 --- a/.kilo/plans/1780945098899-witty-island.md +++ /dev/null @@ -1,49 +0,0 @@ -# Plan: JetBrains Tool Output Code-Block Body - -## Goal - -Render expandable JetBrains tool output bodies like markdown code blocks: editor-style text, no line wrapping, horizontal scrolling, and vertical scrolling inside the existing capped tool body height. Preserve lazy creation on first expand and release editor resources through the same `Disposer` ownership pattern used by markdown code blocks. - -## Current Findings - -- `ToolView` and `BaseSearchToolView` already pass `SecondarySessionPartView(parts.header, { parts.scroll(tool) })`, so expandable bodies are lazy through `AbstractSessionPartView.body()` and are first created by `expand()`. -- Current tool bodies are `JBTextArea` instances created in `ToolParts.body()` with `lineWrap = true` and horizontal scrolling disabled. -- Markdown code blocks in `MdViewHybrid` create an `EditorTextField`, call `setDisposedWith(blockDisposable)`, register selection under that disposable, and dispose stale blocks from `removeBlocks()`, `clearBlocks()`, and `dispose()`. -- `ReadToolView` currently creates `parts.scroll(tool)` eagerly because it is non-expandable. Avoid turning that path into an eager editor-backed body unless it is deliberately refactored. - -## Implementation Steps - -1. Add an editor-backed tool output body helper in the JetBrains frontend, likely under `frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/`. - - Own a `Disposable` created with `Disposer.newDisposable("Tool body")`. - - Create an `EditorTextField` with a plain-text document and `PlainTextFileType.INSTANCE`. - - Call `setDisposedWith(disposable)` on the field, matching `MdViewHybrid.CodeField`. - - Use a `runCatching` fallback to a `JBTextArea(lineWrap = false)` if editor creation fails, mirroring markdown code block resilience. - - Disable soft wraps and inner editor scrollbars; let the outer `JBScrollPane` own scrolling. - - Set outer policies to `HORIZONTAL_SCROLLBAR_AS_NEEDED` and `VERTICAL_SCROLLBAR_AS_NEEDED`. - - Size the inner component from full text width/height so the outer scroll pane can scroll both axes. - -2. Integrate the helper into expandable tool bodies only. - - Keep `ToolView` and `BaseSearchToolView` using lazy `parts.scroll(tool)` so collapsed updates do not instantiate the editor. - - Avoid direct `parts.text` access that creates a body. Keep body access nullable and no-op when collapsed. - - Keep `ReadToolView` on the existing summary path or give it an explicit text-area body mode so it does not eagerly create editor resources. - - Register the tool body disposable under the owning `ToolView`/`BaseSearchToolView` on first body creation, so `Disposer.dispose(view)` releases the editor just like markdown blocks. - - Do not dispose on ordinary collapse; collapse should detach and re-expand should reuse the same body, matching existing retained Swing behavior. Disposal happens when the part view is removed, replaced, cleared, or disposed. - -3. Update styling and behavior in place. - - Use editor-derived font/colors for the body to match markdown code blocks. - - Preserve existing error foreground behavior where practical, including the fallback text area path. - - Keep existing `ToolView.getPreferredSize()` and `BaseSearchToolView.getPreferredSize()` height caps, so long output scrolls vertically within `SessionUiStyle.View.Tool.BODY_LINES`. - - Keep header fonts, labels, icons, and collapse/expand behavior unchanged. - -4. Update tests. - - `ToolViewTest`: change wrapping/scroll assertions to no wrap, horizontal `AS_NEEDED`, vertical `AS_NEEDED`; assert first expand creates one editor-backed body; collapse/re-expand reuses the same scroll/editor; collapsed updates do not create the body; expanded updates mutate the same editor text. - - `SearchToolViewTest` and `GlobToolViewTest`: assert lazy creation, reuse, and no-wrap horizontal/vertical scrolling for shared search body behavior. - - `ReadToolViewTest`: assert non-expandable read summary behavior is unchanged and does not accidentally become an expandable editor body. - - Add a tool body leak/stress test similar to `MdViewHybridStressTest`: capture `EditorFactory.getInstance().allEditors.size`, expand/dispose or replace many tool views after forcing editor creation, drain the EDT, and assert editor count returns to baseline. - - Add a style update assertion that applying `SessionEditorStyle` changes the retained body in place without rebuilding the editor. - -5. Verification - -- Run targeted JetBrains frontend tests for the affected views. -- Run the new/updated leak test. -- Run `./gradlew typecheck` from `packages/kilo-jetbrains/` before marking the implementation ready. From 8c4c3375b9f7ba21e2c9bc806b63ad4d9f17c3ec Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 8 Jun 2026 16:56:48 -0400 Subject: [PATCH 13/26] fix(jetbrains): show search paths relative to repo --- .changeset/relative-jetbrains-search-paths.md | 5 +++ .../ai/kilocode/client/session/SessionUi.kt | 1 + .../session/ui/SessionMessageListPanel.kt | 5 +-- .../client/session/views/MessageView.kt | 5 +-- .../kilocode/client/session/views/TurnView.kt | 3 +- .../client/session/views/ViewFactory.kt | 16 ++++++---- .../session/views/tool/BaseSearchToolView.kt | 5 +-- .../client/session/views/tool/GlobToolView.kt | 5 +-- .../session/views/tool/SearchToolView.kt | 6 ++-- .../client/session/views/tool/ToolSupport.kt | 29 +++++++++++++---- .../client/session/views/GlobToolViewTest.kt | 30 +++++++++++++++++ .../session/views/SearchToolViewTest.kt | 32 +++++++++++++++++++ 12 files changed, 118 insertions(+), 24 deletions(-) create mode 100644 .changeset/relative-jetbrains-search-paths.md diff --git a/.changeset/relative-jetbrains-search-paths.md b/.changeset/relative-jetbrains-search-paths.md new file mode 100644 index 00000000000..a5e48794d10 --- /dev/null +++ b/.changeset/relative-jetbrains-search-paths.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Display JetBrains search tool paths relative to the current repository when possible. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index e971d09f3af..dd97cbabc9a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -296,6 +296,7 @@ class SessionUi( ::openFile, ::openUrl, selection, + repo = workspace.directory, resize = { anchor, fn -> scroll.preserve(anchor, fn) }, ) header = SessionHeaderPanel(controller, this) 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 3af80c81b74..dc5987f2e94 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 @@ -52,6 +52,7 @@ class SessionMessageListPanel( private val openFile: (String) -> Unit, private val openUrl: (String) -> Unit = {}, private val selection: SessionSelection? = null, + private val repo: String? = null, private val resize: ((JComponent, () -> Unit) -> Unit)? = null, ) : SessionLayoutPanel( JBUI.scale(SessionUiStyle.SessionLayout.GAP), @@ -179,7 +180,7 @@ class SessionMessageListPanel( // ------ private event handlers ------ private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) { - val tv = TurnView(turn.id, openFile, style, openUrl, selection, resize) + val tv = TurnView(turn.id, openFile, style, openUrl, selection, resize, repo) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue @@ -235,7 +236,7 @@ class SessionMessageListPanel( removeAll() for (turn in model.turns()) { - val tv = TurnView(turn.id, openFile, style, openUrl, selection, resize) + val tv = TurnView(turn.id, openFile, style, openUrl, selection, resize, repo) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue 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 2148e86b2ef..96e6ef86442 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 @@ -39,6 +39,7 @@ class MessageView( private val openUrl: (String) -> Unit = {}, private val selection: SessionSelection? = null, private val resize: ((JComponent, () -> Unit) -> Unit)? = null, + private val repo: String? = null, ) : ai.kilocode.client.session.ui.SessionLayoutPanel( JBUI.scale(SessionUiStyle.SessionLayout.GAP), ), Disposable, SessionEditorStyleTarget, SessionView { @@ -224,9 +225,9 @@ class MessageView( } private fun view(content: Content) = if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) { - ViewFactory.createUser(content, openFile, openUrl, selection) + ViewFactory.createUser(content, openFile, openUrl, selection, repo) } else { - ViewFactory.create(content, openFile, openUrl, selection) + ViewFactory.create(content, openFile, openUrl, selection, repo) } /** Append a streaming delta to the renderer for [contentId]. */ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt index d2cbed089de..252d9e3cb1b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt @@ -27,6 +27,7 @@ class TurnView( private val openUrl: (String) -> Unit = {}, private val selection: SessionSelection? = null, private val resize: ((JComponent, () -> Unit) -> Unit)? = null, + private val repo: String? = null, ) : SessionLayoutPanel(JBUI.scale(SessionUiStyle.SessionLayout.GAP)), Disposable, SessionEditorStyleTarget { constructor(id: String, openFile: (String) -> Unit) : this(id, openFile, SessionEditorStyle.current()) @@ -39,7 +40,7 @@ class TurnView( /** Add a new [MessageView] for [msg] at the end of this turn. */ fun addMessage(msg: Message): MessageView { - val view = MessageView(msg, openFile, style, openUrl, selection, resize) + val view = MessageView(msg, openFile, style, openUrl, selection, resize, repo) messages[msg.info.id] = view add(view) revalidate() 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 96153d33b73..a13dde6e1ce 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 @@ -29,19 +29,20 @@ object ViewFactory { fun create( content: Content, openFile: (String) -> Unit, - ): PartView = create(content, openFile, openUrl = {}, selection = null) + ): PartView = create(content, openFile, openUrl = {}, selection = null, repo = null) fun create( content: Content, openFile: (String) -> Unit, openUrl: (String) -> Unit, - ): PartView = create(content, openFile, openUrl, selection = null) + ): PartView = create(content, openFile, openUrl, selection = null, repo = null) fun create( content: Content, openFile: (String) -> Unit, openUrl: (String) -> Unit = {}, selection: SessionSelection? = null, + repo: String? = null, ): PartView = when (content) { is Text -> TextView(content, openUrl = openUrl, selection = selection) is Reasoning -> ReasoningView(content, openUrl = openUrl, selection = selection) @@ -49,8 +50,8 @@ object ViewFactory { TodoWriteView.canRender(content) -> TodoWriteView(content) PlanExitView.canRender(content) -> PlanExitView(content, openFile, selection) QuestionResultView.canRender(content) -> QuestionResultView(content, selection) - GlobToolView.canRender(content) -> GlobToolView(content, selection = selection) - SearchToolView.canRender(content) -> SearchToolView(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) else -> ToolView(content, selection = selection) } @@ -62,22 +63,23 @@ object ViewFactory { fun createUser( content: Content, openFile: (String) -> Unit, - ): PartView = createUser(content, openFile, openUrl = {}, selection = null) + ): PartView = createUser(content, openFile, openUrl = {}, selection = null, repo = null) fun createUser( content: Content, openFile: (String) -> Unit, openUrl: (String) -> Unit, - ): PartView = createUser(content, openFile, openUrl, selection = null) + ): PartView = createUser(content, openFile, openUrl, selection = null, repo = null) fun createUser( content: Content, openFile: (String) -> Unit, openUrl: (String) -> Unit = {}, selection: SessionSelection? = null, + repo: String? = null, ): PartView = when (content) { is Text -> PromptView(content, openUrl = openUrl, selection = selection) - else -> create(content, openFile, openUrl, selection) + else -> create(content, openFile, openUrl, selection, repo) } /** diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt index c5fd3162217..c96d00b2e61 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt @@ -17,6 +17,7 @@ abstract class BaseSearchToolView( tool: Tool, private val selection: SessionSelection? = null, private val parts: ToolParts, + private val repo: String? = null, ) : SecondarySessionPartView(parts.header, { parts.scroll(tool) }) { override val contentId: String = tool.id @@ -28,7 +29,7 @@ abstract class BaseSearchToolView( protected abstract fun toolIcon(tool: Tool): Icon protected abstract fun toolTitle(tool: Tool): String - protected abstract fun targets(tool: Tool): List + protected abstract fun targets(tool: Tool, repo: String?): List protected abstract fun viewName(): String init { @@ -117,7 +118,7 @@ abstract class BaseSearchToolView( } private fun syncTargets(): Boolean { - val values = targets(item) + val values = targets(item, repo) var changed = false parts.targets.forEachIndexed { index, label -> val text = values.getOrNull(index) ?: "" diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/GlobToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/GlobToolView.kt index 788c4a29d03..ccb204da04f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/GlobToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/GlobToolView.kt @@ -9,7 +9,8 @@ class GlobToolView( tool: Tool, selection: SessionSelection? = null, parts: ToolParts = searchParts(2), -) : BaseSearchToolView(tool, selection, parts) { + repo: String? = null, +) : BaseSearchToolView(tool, selection, parts, repo) { companion object { fun canRender(tool: Tool): Boolean = tool.name == "glob" @@ -17,6 +18,6 @@ class GlobToolView( override fun toolIcon(tool: Tool) = icon(tool) override fun toolTitle(tool: Tool) = KiloBundle.message("session.part.tool.glob") - override fun targets(tool: Tool) = listOf(globDirectory(tool), globPattern(tool)) + override fun targets(tool: Tool, repo: String?) = listOf(globDirectory(tool, repo), globPattern(tool)).filter { it.isNotBlank() } override fun viewName() = "GlobToolView" } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/SearchToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/SearchToolView.kt index e7b93e1351d..f8378f90319 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/SearchToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/SearchToolView.kt @@ -9,7 +9,9 @@ import com.intellij.icons.AllIcons class SearchToolView( tool: Tool, selection: SessionSelection? = null, -) : BaseSearchToolView(tool, selection, searchParts(3)) { + parts: ToolParts = searchParts(3), + repo: String? = null, +) : BaseSearchToolView(tool, selection, parts, repo) { companion object { fun canRender(tool: Tool): Boolean = tool.name == "grep" @@ -17,6 +19,6 @@ class SearchToolView( override fun toolIcon(tool: Tool) = AllIcons.Actions.Search override fun toolTitle(tool: Tool) = KiloBundle.message("session.part.tool.search") - override fun targets(tool: Tool) = searchTargets(tool) + override fun targets(tool: Tool, repo: String?) = searchTargets(tool, repo) override fun viewName() = "SearchToolView" } 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 ee3484664ba..0d9f9250a73 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 @@ -20,6 +20,8 @@ import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.fileTypes.PlainTextFileType import com.intellij.openapi.project.ProjectManager import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.io.FileUtil +import com.intellij.openapi.util.io.OSAgnosticPathUtil import com.intellij.ui.EditorTextField import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane @@ -455,16 +457,31 @@ private fun readPath(tool: Tool): String { return tail(path).ifBlank { path } } -internal fun globDirectory(tool: Tool): String = - tool.input["path"]?.takeIf { it.isNotBlank() } - ?: tool.title?.takeIf { it.isNotBlank() } - ?: "" +internal fun searchPath(path: String, repo: String?): String { + val text = path.takeIf { it.isNotBlank() } ?: return "" + val root = repo?.takeIf { it.isNotBlank() }?.let(::norm) + if (root == null) return text.takeUnless { it == "." } ?: "" + val full = if (OSAgnosticPathUtil.isAbsolute(text)) norm(text) else norm(FileUtil.join(root, text)) + if (full == root) return "" + if (!OSAgnosticPathUtil.startsWith(full, root)) return full + return FileUtil.getRelativePath(root, full, '/') ?: full +} + +private fun norm(path: String): String = FileUtil.toCanonicalPath(FileUtil.toSystemIndependentName(path), '/', true) + +internal fun globDirectory(tool: Tool, repo: String?): String = + searchPath( + tool.input["path"]?.takeIf { it.isNotBlank() } + ?: tool.title?.takeIf { it.isNotBlank() } + ?: "", + repo, + ) internal fun globPattern(tool: Tool): String = tool.input["pattern"]?.takeIf { it.isNotBlank() }?.let { "pattern=$it" } ?: "" -internal fun searchTargets(tool: Tool): List = listOfNotNull( - tool.input["path"]?.takeIf { it.isNotBlank() }, +internal fun searchTargets(tool: Tool, repo: String?): List = listOfNotNull( + tool.input["path"]?.takeIf { it.isNotBlank() }?.let { searchPath(it, repo) }?.takeIf { it.isNotBlank() }, tool.input["pattern"]?.takeIf { it.isNotBlank() }?.let { "pattern=$it" }, tool.input["include"]?.takeIf { it.isNotBlank() }?.let { "include=$it" }, ) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt index 77093402384..49a51b9d7cc 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt @@ -45,6 +45,36 @@ class GlobToolViewTest : BasePlatformTestCase() { assertFalse(view.targetVisible(1)) } + fun `test repo path displays relative directory`() { + val view = GlobToolView(tool().also { + it.input = mapOf("path" to "/repo/src", "pattern" to "**/*.kt") + }, repo = "/repo") + + assertEquals(listOf("src", "pattern=**/*.kt"), view.targetTexts()) + } + + fun `test repo root directory is hidden`() { + val exact = GlobToolView(tool().also { + it.input = mapOf("path" to "/repo", "pattern" to "**/*.kt") + }, repo = "/repo") + val dot = GlobToolView(tool().also { + it.input = mapOf("path" to ".", "pattern" to "**/*.kt") + }, repo = "/repo") + + assertEquals(listOf("pattern=**/*.kt"), exact.targetTexts()) + assertEquals(listOf("pattern=**/*.kt"), dot.targetTexts()) + assertFalse(exact.targetVisible(1)) + assertFalse(dot.targetVisible(1)) + } + + fun `test outside repo directory stays absolute`() { + val view = GlobToolView(tool().also { + it.input = mapOf("path" to "/other/src", "pattern" to "**/*.kt") + }, repo = "/repo") + + assertEquals(listOf("/other/src", "pattern=**/*.kt"), view.targetTexts()) + } + fun `test completed glob starts collapsed and expands output`() { val view = track(GlobToolView(tool().also { it.output = "/repo/src/A.kt\n/repo/src/B.kt" })) 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 afa6421466e..acab0d81f88 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 @@ -49,6 +49,38 @@ class SearchToolViewTest : BasePlatformTestCase() { assertEquals(listOf("/repo/src", "pattern=TODO", "include=*.kt"), view.targetTexts()) } + fun `test repo path displays relative search target`() { + val view = SearchToolView(tool().also { + it.input = mapOf("path" to "/repo/src", "pattern" to "TODO", "include" to "*.kt") + }, repo = "/repo") + + assertEquals(listOf("src", "pattern=TODO", "include=*.kt"), view.targetTexts()) + } + + fun `test repo root search path is hidden`() { + val exact = SearchToolView(tool().also { + it.input = mapOf("path" to "/repo", "pattern" to "TODO", "include" to "*.kt") + }, repo = "/repo") + val dot = SearchToolView(tool().also { + it.input = mapOf("path" to ".", "pattern" to "TODO", "include" to "*.kt") + }, repo = "/repo") + + assertEquals(listOf("pattern=TODO", "include=*.kt"), exact.targetTexts()) + assertEquals(listOf("pattern=TODO", "include=*.kt"), dot.targetTexts()) + assertTrue(exact.targetVisible(0)) + assertFalse(exact.targetVisible(2)) + assertTrue(dot.targetVisible(0)) + assertFalse(dot.targetVisible(2)) + } + + fun `test outside repo search path stays absolute`() { + val view = SearchToolView(tool().also { + it.input = mapOf("path" to "/other/src", "pattern" to "TODO", "include" to "*.kt") + }, repo = "/repo") + + assertEquals(listOf("/other/src", "pattern=TODO", "include=*.kt"), view.targetTexts()) + } + fun `test target labels use plain text for clipping`() { val view = SearchToolView(tool().also { it.input = mapOf("pattern" to "", "include" to "*.kt") From 1864e441d585d5ab10a613aeab086d364ee81f10 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 8 Jun 2026 17:01:20 -0400 Subject: [PATCH 14/26] fix(jetbrains): render search targets as regular text --- .../client/session/views/tool/BaseSearchToolView.kt | 4 ++-- .../kilocode/client/session/views/GlobToolViewTest.kt | 11 +++++++++++ .../client/session/views/SearchToolViewTest.kt | 11 +++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt index c96d00b2e61..c229decdd47 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt @@ -73,7 +73,7 @@ abstract class BaseSearchToolView( internal fun hasToggle() = arrow.isVisible internal fun bodyFont() = parts.content?.font ?: style.editorFont internal fun titleFont() = parts.title.font - internal fun targetFont(index: Int) = parts.targets.getOrNull(index)?.font ?: style.smallEditorFont + internal fun targetFont(index: Int) = parts.targets.getOrNull(index)?.font ?: style.regularFont internal fun stateFont() = parts.state.font internal fun bodyCreated() = parts.bodyCreated() internal fun scrollComponent() = parts.scroll @@ -90,7 +90,7 @@ abstract class BaseSearchToolView( var changed = false changed = setFont(parts.title, style.boldEditorFont) || changed changed = setFont(parts.sub, style.smallEditorFont) || changed - parts.targets.forEach { changed = setFont(it, style.smallEditorFont) || changed } + parts.targets.forEach { changed = setFont(it, style.regularFont) || changed } changed = setFont(parts.state, style.smallEditorFont) || changed changed = applyBodyStyle() || changed if (changed) refresh() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt index 49a51b9d7cc..67c69d9bf8a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt @@ -7,6 +7,7 @@ import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.session.views.tool.GlobToolView import ai.kilocode.client.session.views.tool.ReadToolView import ai.kilocode.client.session.views.tool.ToolView +import ai.kilocode.client.session.ui.style.SessionEditorStyle import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import javax.swing.ScrollPaneConstants @@ -75,6 +76,16 @@ class GlobToolViewTest : BasePlatformTestCase() { assertEquals(listOf("/other/src", "pattern=**/*.kt"), view.targetTexts()) } + fun `test target labels use regular font`() { + val view = GlobToolView(tool().also { + it.input = mapOf("path" to "/repo/src", "pattern" to "**/*.kt") + }) + val style = SessionEditorStyle.current() + + assertEquals(style.regularFont, view.targetFont(0)) + assertEquals(style.regularFont, view.targetFont(1)) + } + fun `test completed glob starts collapsed and expands output`() { val view = track(GlobToolView(tool().also { it.output = "/repo/src/A.kt\n/repo/src/B.kt" })) 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 acab0d81f88..eea06ce279f 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 @@ -3,6 +3,7 @@ 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.base.SecondarySessionPartView import ai.kilocode.client.session.views.tool.GlobToolView import ai.kilocode.client.session.views.tool.ReadToolView @@ -89,6 +90,16 @@ class SearchToolViewTest : BasePlatformTestCase() { assertEquals("pattern=", view.targetComponents().first().text) } + fun `test target labels use regular font`() { + val view = SearchToolView(tool().also { + it.input = mapOf("pattern" to "TODO", "include" to "*.kt") + }) + val style = SessionEditorStyle.current() + + assertEquals(style.regularFont, view.targetFont(0)) + assertEquals(style.regularFont, view.targetFont(1)) + } + fun `test completed search starts collapsed and expands output`() { val view = track(SearchToolView(tool().also { it.output = "src/A.kt:1:class A" })) From b9bff3b69cf27fc7e0d88d411eaa368616fc32d6 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 8 Jun 2026 17:35:33 -0400 Subject: [PATCH 15/26] fix(jetbrains): reset session card hover styling --- .changeset/reset-jetbrains-session-hover.md | 5 ++ .../session/ui/SessionMessageListPanel.kt | 26 ++++++- .../client/session/views/MessageView.kt | 13 ++++ .../kilocode/client/session/views/TurnView.kt | 4 +- .../views/base/AbstractSessionPartView.kt | 7 +- .../client/session/views/base/PartView.kt | 4 ++ .../views/base/PrimarySessionPartView.kt | 29 +++++++- .../views/base/SecondarySessionPartView.kt | 32 +++++++++ .../views/question/QuestionResultView.kt | 33 ++++++--- .../session/ui/SessionMessageListPanelTest.kt | 67 +++++++++++++++++++ .../session/views/QuestionResultViewTest.kt | 8 ++- .../client/session/views/ToolViewTest.kt | 26 +++++++ .../views/base/AbstractSessionPartViewTest.kt | 8 ++- 13 files changed, 240 insertions(+), 22 deletions(-) create mode 100644 .changeset/reset-jetbrains-session-hover.md diff --git a/.changeset/reset-jetbrains-session-hover.md b/.changeset/reset-jetbrains-session-hover.md new file mode 100644 index 00000000000..dc588b95f16 --- /dev/null +++ b/.changeset/reset-jetbrains-session-hover.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Reset stale hover styling when moving between JetBrains session cards and draw card outlines only while expanded. 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 dc5987f2e94..d88f65d9cc6 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 @@ -13,6 +13,7 @@ import ai.kilocode.client.session.views.MessageView import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.session.views.TurnView +import ai.kilocode.client.session.views.base.PartView import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.util.ui.JBUI @@ -69,6 +70,7 @@ class SessionMessageListPanel( private val msgToView = HashMap() private var style = SessionEditorStyle.current() private var hiddenTool: ToolCallRef? = null + private var hovered: PartView? = null /** Progress footer — always the last child inside the scroll. */ val progress = ProgressPanel(model, parent) @@ -180,7 +182,7 @@ class SessionMessageListPanel( // ------ private event handlers ------ private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) { - val tv = TurnView(turn.id, openFile, style, openUrl, selection, resize, repo) + val tv = TurnView(turn.id, openFile, style, openUrl, selection, resize, repo, ::hover) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue @@ -226,6 +228,7 @@ class SessionMessageListPanel( } private fun rebuild() { + clearHover() turnViews.values.forEach { remove(it) Disposer.dispose(it) @@ -236,7 +239,7 @@ class SessionMessageListPanel( removeAll() for (turn in model.turns()) { - val tv = TurnView(turn.id, openFile, style, openUrl, selection, resize, repo) + val tv = TurnView(turn.id, openFile, style, openUrl, selection, resize, repo, ::hover) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue @@ -252,6 +255,7 @@ class SessionMessageListPanel( } private fun clear() { + clearHover() turnViews.values.forEach { remove(it) Disposer.dispose(it) @@ -341,6 +345,23 @@ class SessionMessageListPanel( repaint() } + private fun hover(view: PartView, value: Boolean) { + if (value) { + val prev = hovered + if (prev === view) return + hovered = view + prev?.setHovered(false) + return + } + if (hovered === view) hovered = null + } + + private fun clearHover() { + val view = hovered ?: return + hovered = null + view.setHovered(false) + } + override fun applyStyle(style: SessionEditorStyle) { this.style = style background = SessionUiStyle.View.transcript() @@ -353,6 +374,7 @@ class SessionMessageListPanel( } override fun dispose() { + clearHover() turnViews.values.forEach { remove(it) Disposer.dispose(it) 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 96e6ef86442..b3a1277bc8e 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 @@ -40,6 +40,7 @@ class MessageView( private val selection: SessionSelection? = null, private val resize: ((JComponent, () -> Unit) -> Unit)? = null, private val repo: String? = null, + private val hover: ((PartView, Boolean) -> Unit)? = null, ) : ai.kilocode.client.session.ui.SessionLayoutPanel( JBUI.scale(SessionUiStyle.SessionLayout.GAP), ), Disposable, SessionEditorStyleTarget, SessionView { @@ -88,6 +89,7 @@ class MessageView( sources.remove(content.id) val stale = if (id == null) parts.remove(content.id) else null if (stale != null) { + detach(stale) remove(stale) Disposer.dispose(stale) syncBorder() @@ -132,6 +134,7 @@ class MessageView( } val view = view(content) view.resize = resize + view.hover = hover view.applyStyle(style) parts[content.id] = view add(view) @@ -158,10 +161,12 @@ class MessageView( parts.remove(content.id) aliases.values.removeAll { it == content.id } sources.keys.removeAll { it !in aliases } + detach(existing) remove(existing) Disposer.dispose(existing) val view = view(content) view.resize = resize + view.hover = hover view.applyStyle(style) parts[content.id] = view add(view, at) @@ -178,6 +183,7 @@ class MessageView( val view = parts.remove(contentId) ?: return aliases.values.removeAll { it == contentId } sources.keys.removeAll { it !in aliases } + detach(view) remove(view) Disposer.dispose(view) syncBorder() @@ -204,6 +210,7 @@ class MessageView( */ private fun rebuildParts() { parts.values.forEach { + detach(it) remove(it) Disposer.dispose(it) } @@ -257,6 +264,7 @@ class MessageView( override fun dispose() { parts.values.forEach { + detach(it) remove(it) Disposer.dispose(it) } @@ -292,5 +300,10 @@ class MessageView( repaint() } + private fun detach(view: PartView) { + view.setHovered(false) + view.hover = null + } + private fun assistantBorder() = JBUI.Borders.empty() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt index 252d9e3cb1b..459509ffc93 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.base.PartView import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.util.ui.JBUI @@ -28,6 +29,7 @@ class TurnView( private val selection: SessionSelection? = null, private val resize: ((JComponent, () -> Unit) -> Unit)? = null, private val repo: String? = null, + private val hover: ((PartView, Boolean) -> Unit)? = null, ) : SessionLayoutPanel(JBUI.scale(SessionUiStyle.SessionLayout.GAP)), Disposable, SessionEditorStyleTarget { constructor(id: String, openFile: (String) -> Unit) : this(id, openFile, SessionEditorStyle.current()) @@ -40,7 +42,7 @@ class TurnView( /** Add a new [MessageView] for [msg] at the end of this turn. */ fun addMessage(msg: Message): MessageView { - val view = MessageView(msg, openFile, style, openUrl, selection, resize, repo) + val view = MessageView(msg, openFile, style, openUrl, selection, resize, repo, hover) messages[msg.info.id] = view add(view) revalidate() 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 a05bad9d898..d307e0bfc61 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 @@ -41,12 +41,12 @@ abstract class AbstractSessionPartView( } private val mouse = object : MouseAdapter() { override fun mouseEntered(e: MouseEvent) { - setHover(true) + setHovered(true) } override fun mouseExited(e: MouseEvent) { if (inside(e)) return - setHover(false) + setHovered(false) } } @@ -121,7 +121,8 @@ abstract class AbstractSessionPartView( protected open fun applyHover(value: Boolean, color: Color) {} - private fun setHover(value: Boolean) { + override fun setHovered(value: Boolean) { + hover?.invoke(this, value) val color = hoverColor(value) ?: return if (row.background?.rgb == color.rgb) return row.background = color diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt index 35d03606660..b1b0256110f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt @@ -23,6 +23,8 @@ abstract class PartView : JPanel(), Disposable, SessionEditorStyleTarget { var resize: ((JComponent, () -> Unit) -> Unit)? = null + var hover: ((PartView, Boolean) -> Unit)? = null + /** * Apply a full content update — replace, not append. * Called when [ai.kilocode.client.session.model.SessionModelEvent.ContentUpdated] fires. @@ -36,6 +38,8 @@ abstract class PartView : JPanel(), Disposable, SessionEditorStyleTarget { */ open fun appendDelta(delta: String) {} + open fun setHovered(value: Boolean) {} + override fun applyStyle(style: SessionEditorStyle) {} override fun dispose() {} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt index 6a4a1d89f67..2f8dcd0c21c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt @@ -14,19 +14,44 @@ abstract class PrimarySessionPartView( init { isOpaque = true background = SessionUiStyle.View.surface() - border = SessionUiStyle.View.sessionView() row.isOpaque = true row.background = SessionUiStyle.View.header() row.border = JBUI.Borders.empty( JBUI.scale(SessionUiStyle.View.SESSION_VIEW_VERTICAL_PADDING), JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING), ) + syncBorder() + } + + override fun expand(): Boolean { + val changed = super.expand() + if (changed) syncBorder() + return changed + } + + override fun collapse(): Boolean { + val changed = super.collapse() + if (changed) syncBorder() + return changed } override fun hoverColor(value: Boolean) = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header() override fun applyHover(value: Boolean, color: Color) { - border = if (value) SessionUiStyle.View.sessionView(SessionUiStyle.View.hoverLine()) else SessionUiStyle.View.sessionView() + syncBorder() repaint() } + + private fun syncBorder() { + border = if (isExpanded()) { + val color = if (row.background?.rgb == SessionUiStyle.View.headerHover().rgb) { + SessionUiStyle.View.hoverLine() + } else { + SessionUiStyle.View.line() + } + SessionUiStyle.View.sessionView(color) + } else { + JBUI.Borders.empty(1) + } + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt index 8951a50d808..ab297fa71c3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.session.views.base import ai.kilocode.client.session.ui.style.SessionUiStyle import com.intellij.util.ui.JBUI +import java.awt.Color import javax.swing.JComponent abstract class SecondarySessionPartView( @@ -24,7 +25,38 @@ abstract class SecondarySessionPartView( JBUI.scale(SessionUiStyle.View.SESSION_VIEW_VERTICAL_PADDING), JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING), ) + syncBorder() + } + + override fun expand(): Boolean { + val changed = super.expand() + if (changed) syncBorder() + return changed + } + + override fun collapse(): Boolean { + val changed = super.collapse() + if (changed) syncBorder() + return changed } override fun hoverColor(value: Boolean) = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header() + + override fun applyHover(value: Boolean, color: Color) { + syncBorder() + repaint() + } + + private fun syncBorder() { + border = if (isExpanded()) { + val color = if (row.background?.rgb == SessionUiStyle.View.headerHover().rgb) { + SessionUiStyle.View.hoverLine() + } else { + SessionUiStyle.View.line() + } + SessionUiStyle.View.sessionView(color) + } else { + JBUI.Borders.empty(1) + } + } } 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 88150be84c7..aa8abb0bc31 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 @@ -42,7 +42,7 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = super.updateUI() isOpaque = true background = SessionUiStyle.View.surface() - border = SessionUiStyle.View.sessionView() + border = JBUI.Borders.empty(1) } } private val header = object : JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)) { @@ -70,10 +70,10 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = } private val mouse = object : MouseAdapter() { - override fun mouseEntered(e: MouseEvent) { setHover(true) } + override fun mouseEntered(e: MouseEvent) { setHovered(true) } override fun mouseExited(e: MouseEvent) { if (inside(e)) return - setHover(false) + setHovered(false) } } @@ -99,6 +99,7 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = add(root, BorderLayout.CENTER) syncLabels() syncArrow() + syncBorder() } override fun update(content: Content) { @@ -133,6 +134,7 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = } else { root.add(body(), BorderLayout.CENTER) } + syncBorder() } fun isExpanded(): Boolean = pane?.parent === root @@ -277,15 +279,30 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = arrow.icon = if (isExpanded()) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight } - private fun setHover(value: Boolean) { + override fun setHovered(value: Boolean) { + hover?.invoke(this, value) val color = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header() - if (header.background?.rgb == color.rgb) return - header.background = color - root.border = if (value) SessionUiStyle.View.sessionView(SessionUiStyle.View.hoverLine()) else SessionUiStyle.View.sessionView() - header.repaint() + if (header.background?.rgb != color.rgb) { + header.background = color + header.repaint() + } + syncBorder() root.repaint() } + private fun syncBorder() { + root.border = if (isExpanded()) { + val color = if (header.background?.rgb == SessionUiStyle.View.headerHover().rgb) { + SessionUiStyle.View.hoverLine() + } else { + SessionUiStyle.View.line() + } + SessionUiStyle.View.sessionView(color) + } else { + JBUI.Borders.empty(1) + } + } + private fun inside(e: MouseEvent): Boolean { val point = SwingUtilities.convertPoint(e.component, e.point, header) return header.contains(point) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index cdb0032ccb0..a96fc44bb1c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -9,6 +9,7 @@ import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.model.ToolCallRef import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.LoginRequiredView import ai.kilocode.client.session.views.PlanExitView import ai.kilocode.client.session.views.permission.PermissionView @@ -25,8 +26,13 @@ import ai.kilocode.rpc.dto.TodoDto import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Color +import java.awt.Component import java.awt.Container +import java.awt.event.MouseEvent +import java.awt.image.BufferedImage import javax.swing.JPanel +import javax.swing.border.Border /** * Tests for [SessionMessageListPanel] — structural and index integrity. @@ -537,6 +543,41 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertEquals(listOf(".kilo/plans/x.md"), opened) } + fun `test entering a second hoverable part clears stale first hover`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent( + "a1", + toolPart( + "tp1", "a1", "question", "call1", state = "completed", + input = mapOf("questions" to """[{"question":"First?"}]"""), + metadata = mapOf("answers" to """[["Yes"]]"""), + ), + ) + model.updateContent( + "a1", + toolPart( + "tp2", "a1", "question", "call2", state = "completed", + input = mapOf("questions" to """[{"question":"Second?"}]"""), + metadata = mapOf("answers" to """[["No"]]"""), + ), + ) + val first = panel.findMessage("a1")!!.part("tp1") as QuestionResultView + val second = panel.findMessage("a1")!!.part("tp2") as QuestionResultView + val firstRoot = root(first) + val secondRoot = root(second) + + first.toggle() + second.toggle() + + enter(header(first)) + assertEquals(SessionUiStyle.View.hoverLine().rgb, paint(firstRoot.border).rgb) + + enter(header(second)) + + assertEquals(SessionUiStyle.View.line().rgb, paint(firstRoot.border).rgb) + assertEquals(SessionUiStyle.View.hoverLine().rgb, paint(secondRoot.border).rgb) + } + // ------ helpers ------ private fun panelWithPrompts(): SessionMessageListPanel { @@ -610,4 +651,30 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { id = id, sessionID = "ses", messageID = mid, type = "tool", tool = tool, callID = callId, state = state, input = input, metadata = metadata, todos = todos, ) + + private fun root(view: QuestionResultView) = view.components[0] as JPanel + + private fun header(view: QuestionResultView) = root(view).components[0] as JPanel + + private fun enter(component: Component) { + component.dispatchEvent(MouseEvent( + component, + MouseEvent.MOUSE_ENTERED, + System.currentTimeMillis(), + 0, + 1, + 1, + 0, + false, + )) + } + + private fun paint(border: Border): Color { + val image = BufferedImage(3, 3, BufferedImage.TYPE_INT_ARGB) + val item = JPanel() + val graphics = image.createGraphics() + border.paintBorder(item, graphics, 0, 0, image.width, image.height) + graphics.dispose() + return Color(image.getRGB(0, 0), true) + } } 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 1cb08911d5a..f700180317a 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 @@ -137,13 +137,15 @@ class QuestionResultViewTest : BasePlatformTestCase() { metadata = mapOf("answers" to """[["A1"]]"""), )) val root = view.node(0) - val header = root.node(0) - enter(header) + assertEquals(0, paint(root.border).alpha) + view.toggle() + + view.setHovered(true) assertEquals(SessionUiStyle.View.hoverLine().rgb, paint(root.border).rgb) assertNotSameColor(SessionUiStyle.View.headerHover(), paint(root.border)) - exit(header) + view.setHovered(false) assertEquals(SessionUiStyle.View.line().rgb, paint(root.border).rgb) } 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 da936ac4cbe..6204efbca4b 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 @@ -10,7 +10,11 @@ import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.session.views.tool.ToolView import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Color +import java.awt.image.BufferedImage +import javax.swing.JPanel import javax.swing.ScrollPaneConstants +import javax.swing.border.Border /** * Tests for [ToolView]. @@ -106,6 +110,19 @@ class ToolViewTest : BasePlatformTestCase() { assertTrue(base is SecondarySessionPartView) } + fun `test tool outline is drawn only while expanded`() { + val view = track(ToolView(tool("p1", "bash", ToolExecState.COMPLETED).also { + it.input = mapOf("command" to "pwd") + it.output = "/tmp" + })) + + assertEquals(0, paint(view.border).alpha) + view.toggle() + assertEquals(SessionUiStyle.View.line().rgb, paint(view.border).rgb) + view.toggle() + assertEquals(0, paint(view.border).alpha) + } + fun `test bash toggle collapses and expands`() { val t = tool("p1", "bash", ToolExecState.COMPLETED).also { it.input = mapOf("command" to "git log") @@ -363,4 +380,13 @@ class ToolViewTest : BasePlatformTestCase() { assertEquals(style.smallEditorFont.name, font.name) assertTrue(font.size < style.editorSize) } + + private fun paint(border: Border): Color { + val image = BufferedImage(3, 3, BufferedImage.TYPE_INT_ARGB) + val item = JPanel() + val graphics = image.createGraphics() + border.paintBorder(item, graphics, 0, 0, image.width, image.height) + graphics.dispose() + return Color(image.getRGB(0, 0), true) + } } 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 523087c7a22..6c02b659b17 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 @@ -76,13 +76,15 @@ class AbstractSessionPartViewTest : BasePlatformTestCase() { fun `test primary card border follows hover color`() { val view = TestView(content = JLabel("body")) - val row = view.component(0) - enter(row) + assertEquals(0, paint(view.border).alpha) + view.expand() + + view.setHovered(true) assertEquals(SessionUiStyle.View.hoverLine().rgb, paint(view.border).rgb) assertNotSameColor(SessionUiStyle.View.headerHover(), paint(view.border)) - exit(row) + view.setHovered(false) assertEquals(SessionUiStyle.View.line().rgb, paint(view.border).rgb) } From 952241ee07eebd22717bdf54ce07b3a6c66228af Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 8 Jun 2026 18:40:38 -0400 Subject: [PATCH 16/26] fix(jetbrains): refine session card outlines --- .changeset/refine-jetbrains-card-borders.md | 5 ++ .../client/session/scroll/SessionScroll.kt | 4 +- .../client/session/ui/ConnectionPanel.kt | 2 +- .../session/ui/SessionMessageListPanel.kt | 4 +- .../ui/account/SessionAccountOverlay.kt | 4 +- .../client/session/ui/prompt/PromptPanel.kt | 4 +- .../client/session/ui/style/SessionUiStyle.kt | 67 ++++++++++++------- .../client/session/views/CompactionView.kt | 2 +- .../client/session/views/MessageView.kt | 2 +- .../client/session/views/ReasoningView.kt | 22 ++++-- .../kilocode/client/session/views/TextView.kt | 2 +- .../views/base/AbstractSessionPartView.kt | 5 +- .../session/views/base/BaseQuestionView.kt | 6 +- .../views/base/PrimarySessionPartView.kt | 30 +++------ .../views/base/SecondarySessionPartView.kt | 28 +++----- .../views/permission/PermissionView.kt | 7 +- .../views/question/QuestionResultView.kt | 50 ++++++++------ .../session/views/todo/TodoWriteView.kt | 12 +++- .../session/views/tool/BaseSearchToolView.kt | 2 +- .../client/session/views/tool/ReadToolView.kt | 2 +- .../client/session/views/tool/ToolSupport.kt | 36 +++++----- .../client/session/views/tool/ToolView.kt | 2 +- .../session/ui/SessionMessageListPanelTest.kt | 19 ++++-- .../ui/account/SessionAccountOverlayTest.kt | 4 +- .../session/views/LoginRequiredViewTest.kt | 2 +- .../session/views/QuestionResultViewTest.kt | 26 +++++-- .../client/session/views/QuestionViewTest.kt | 4 +- .../client/session/views/ToolViewTest.kt | 2 +- .../views/base/AbstractSessionPartViewTest.kt | 29 ++++++-- .../views/base/BaseQuestionViewTest.kt | 4 +- .../views/permission/PermissionViewTest.kt | 6 +- .../ai/kilocode/client/ui/UiStyleTest.kt | 6 +- 32 files changed, 230 insertions(+), 170 deletions(-) create mode 100644 .changeset/refine-jetbrains-card-borders.md diff --git a/.changeset/refine-jetbrains-card-borders.md b/.changeset/refine-jetbrains-card-borders.md new file mode 100644 index 00000000000..d2a244ee742 --- /dev/null +++ b/.changeset/refine-jetbrains-card-borders.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Refine JetBrains session card borders so prompt and question surfaces use brighter outlines while reasoning and tool cards use softer default borders. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt index 80043e8c264..bb60884b10e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt @@ -196,8 +196,8 @@ internal class SessionScroll( @RequiresEdt fun applyStyle(style: SessionEditorStyle) { this.style = style - component.background = SessionUiStyle.View.transcript() - component.viewport.background = SessionUiStyle.View.transcript() + component.background = SessionUiStyle.Transcript.bgColor() + component.viewport.background = SessionUiStyle.Transcript.bgColor() syncIcon() messages.applyStyle(style) val view = component.viewport.view diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt index 9b6beb55e8c..63d42d0cb5e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt @@ -94,7 +94,7 @@ class ConnectionPanel( // Keep the banner solid so expanded details cover transcript content beneath it. isOpaque = true background = UiStyle.Colors.bg() - border = JBUI.Borders.customLine(SessionUiStyle.View.line(), 1, 0, 0, 0) + border = JBUI.Borders.customLine(SessionUiStyle.View.Outline.color(), SessionUiStyle.View.Outline.width(), 0, 0, 0) left.add(toggle, BorderLayout.WEST) left.add(label, BorderLayout.CENTER) header.add(left, BorderLayout.CENTER) 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 d88f65d9cc6..18c7d7410c9 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 @@ -77,7 +77,7 @@ class SessionMessageListPanel( init { isOpaque = true - background = SessionUiStyle.View.transcript() + background = SessionUiStyle.Transcript.bgColor() Disposer.register(parent, this) model.addListener(parent) { event -> @@ -364,7 +364,7 @@ class SessionMessageListPanel( override fun applyStyle(style: SessionEditorStyle) { this.style = style - background = SessionUiStyle.View.transcript() + background = SessionUiStyle.Transcript.bgColor() for (view in turnViews.values) view.applyStyle(style) question?.applyStyle(style) permission?.applyStyle(style) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt index 4c71a933966..5c4f63dd14b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt @@ -194,7 +194,7 @@ internal class SessionAccountOverlay( @RequiresEdt private fun showPopup() { - val bg = SessionUiStyle.View.sessionViewBackground() + val bg = SessionUiStyle.AccountPopup.bgColor() val model = CollectionListModel(choices) val list = JBList(model).apply { selectionMode = ListSelectionModel.SINGLE_SELECTION @@ -285,7 +285,7 @@ internal class SessionAccountOverlay( internal fun choiceCount() = choices.size internal fun selectedIndex() = choices.indexOfFirst { it.org == currentOrgId }.takeIf { it >= 0 } ?: 0 internal fun panelBackground() = panel.background - internal fun panelBorderColor() = SessionUiStyle.View.sessionViewOutline() + internal fun panelBorderColor() = SessionUiStyle.AccountPopup.outlineColor() internal fun balanceVisible() = balance.isVisible internal fun balanceIcon() = balance.icon internal fun balanceText() = balanceText 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 1ebc2c0968a..329c895e4c3 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 @@ -491,10 +491,10 @@ class PromptPanel( override fun outlineColor() = if (UIUtil.isFocusAncestor(editor)) { JBUI.CurrentTheme.Focus.focusColor() } else { - SessionUiStyle.View.line() + SessionUiStyle.View.Outline.brightColor() } - override fun outlineWidth() = if (UIUtil.isFocusAncestor(editor)) focus.get() else JBUI.scale(1) + 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)) } 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 5e9af8ed76d..ed5d2efa131 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 @@ -9,6 +9,10 @@ import javax.swing.border.Border /** Static style tokens owned by the chat session UI. */ object SessionUiStyle { + object Transcript { + fun bgColor(): Color = UiStyle.Colors.bg() + } + /** Geometry for the transcript list and its scroll behavior. */ object SessionLayout { const val GAP = 4 @@ -20,43 +24,42 @@ object SessionUiStyle { /** Shared tokens for individual transcript views and session views. */ object View { - const val SESSION_VIEW_GAP = 6 - const val SESSION_VIEW_VERTICAL_PADDING = 8 - const val SESSION_VIEW_HORIZONTAL_PADDING = 12 - const val SESSION_VIEW_BODY_EXTRA_HEIGHT = 16 + object Layout { + const val GAP = 6 + const val VERTICAL_PADDING = 8 + const val HORIZONTAL_PADDING = 12 + const val BODY_EXTRA_HEIGHT = 16 + } internal const val BORDER_DELTA = 80 internal const val HOVER_BORDER_ALPHA = 0.18f internal const val HOVER_FILL_ALPHA = 0.10f - /** Creates a visible separator against editor-derived transcript surfaces. */ - fun line(): Color = JBColor.lazy { UiStyle.Colors.contrast(UiStyle.Colors.editorBackground(), BORDER_DELTA) } - - fun transcript(): Color = UiStyle.Colors.bg() - - fun sessionViewBackground(): Color = UiStyle.Colors.contentBackground() - - fun sessionViewOutline(): Color = UiStyle.Colors.contentBorder() + object Surface { + fun bgColor(): Color = UiStyle.Colors.editorBackground() - fun surface(): Color = UiStyle.Colors.editorBackground() + fun headerBgColor(): Color = UiStyle.Colors.editorBackground() - fun header(): Color = UiStyle.Colors.editorBackground() - - /** Subtle hover fill, softer than the session-view outline. */ - fun headerHover(): Color = JBColor.lazy { UiStyle.Colors.blend(header(), hoverLine(), HOVER_FILL_ALPHA) } - - /** Subtle hover outline, stronger than the hover fill. */ - fun hoverLine(): Color = JBColor.lazy { - UiStyle.Colors.blend(line(), JBUI.CurrentTheme.ActionButton.hoverBackground(), HOVER_BORDER_ALPHA) + /** Subtle hover fill, softer than the session-view outline. */ + fun headerHoverBgColor(): Color = JBColor.lazy { + UiStyle.Colors.blend(headerBgColor(), Outline.hoverColor(), HOVER_FILL_ALPHA) + } } - fun sessionView(color: Color = line()): Border = outline(color) + object Outline { + fun color(): Color = UiStyle.Colors.contentBorder() - fun outline(color: Color = line()): Border = JBUI.Borders.customLine(color, 1) + fun brightColor(): Color = JBColor.lazy { + UiStyle.Colors.contrast(UiStyle.Colors.editorBackground(), BORDER_DELTA) + } - fun topOutline(): Border = JBUI.Borders.customLineTop(line()) + /** Subtle hover outline, stronger than the hover fill. */ + fun hoverColor(): Color = JBColor.lazy { + UiStyle.Colors.blend(brightColor(), JBUI.CurrentTheme.ActionButton.hoverBackground(), HOVER_BORDER_ALPHA) + } - fun leftOutline(): Border = JBUI.Borders.customLine(line(), 0, 1, 0, 0) + fun width(): Int = JBUI.scale(1) + } /** Prompt input dimensions and chrome inside the session view. */ object Prompt { @@ -126,6 +129,12 @@ object SessionUiStyle { } } + object AccountPopup { + fun bgColor(): Color = UiStyle.Colors.contentBackground() + + fun outlineColor(): Color = UiStyle.Colors.contentBorder() + } + /** Limits for the empty-state recent sessions list. */ object RecentSessions { const val LIMIT = 5 @@ -147,7 +156,13 @@ object SessionUiStyle { /** Border presets for connection dock panel. */ object Dock { fun banner(): Border = JBUI.Borders.compound( - JBUI.Borders.customLineTop(SessionUiStyle.View.line()), + JBUI.Borders.customLine( + SessionUiStyle.View.Outline.color(), + SessionUiStyle.View.Outline.width(), + 0, + 0, + 0, + ), JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.lg(), 0, UiStyle.Gap.lg()), )!! } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt index 25435404d9b..ece0854420a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt @@ -40,7 +40,7 @@ class CompactionView(@Suppress("UNUSED_PARAMETER") compaction: Compaction) : Par applyStyle(SessionEditorStyle.current()) val line = { JPanel().apply { - background = SessionUiStyle.View.line() + background = SessionUiStyle.View.Outline.color() isOpaque = true preferredSize = JBDimension(0, JBUI.scale(1)) } } 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 b3a1277bc8e..43d81313861 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 @@ -285,7 +285,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.line() + g2.color = SessionUiStyle.View.Outline.brightColor() 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/ReasoningView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt index 10c21cf1352..8e2bc24a747 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 @@ -164,7 +164,17 @@ class ReasoningView( } private fun syncBorder() { - border = if (isExpanded()) SessionUiStyle.View.leftOutline() else JBUI.Borders.empty(0, 1, 0, 0) + if (isExpanded()) { + border = JBUI.Borders.customLine( + SessionUiStyle.View.Outline.color(), + 0, + SessionUiStyle.View.Outline.width(), + 0, + 0, + ) + return + } + border = JBUI.Borders.empty(0, 1, 0, 0) } private fun apply(md: MdView): Boolean { @@ -204,7 +214,7 @@ class ReasoningView( if (!parts.bodyCreated()) return 0 val md = md return md.component.getFontMetrics(md.font).height * bodyMaxRows() + - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_BODY_EXTRA_HEIGHT) + JBUI.scale(SessionUiStyle.View.Layout.BODY_EXTRA_HEIGHT) } private fun followTail() { @@ -246,7 +256,7 @@ class ReasoningParts( } val panel = TrackPanel().apply { isOpaque = true - background = SessionUiStyle.View.surface() + background = SessionUiStyle.View.Surface.bgColor() border = JBUI.Borders.empty( JBUI.scale(SessionUiStyle.View.Reasoning.BODY_VERTICAL_PADDING), JBUI.scale(SessionUiStyle.View.Reasoning.BODY_HORIZONTAL_PADDING), @@ -256,8 +266,8 @@ class ReasoningParts( val scroll = JBScrollPane(panel).apply { border = JBUI.Borders.empty() isOpaque = true - background = SessionUiStyle.View.surface() - viewport.background = SessionUiStyle.View.surface() + background = SessionUiStyle.View.Surface.bgColor() + viewport.background = SessionUiStyle.View.Surface.bgColor() horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED } @@ -274,7 +284,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(AllIcons.General.InspectionsEye).apply { foreground = UiStyle.Colors.weak() } - val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { + val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { isOpaque = false add(icon, BorderLayout.WEST) add(title, BorderLayout.CENTER) 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 1e4358415ef..fa56f6466c2 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 @@ -73,7 +73,7 @@ open class TextView( protected open fun styleFont(style: SessionEditorStyle) = style.transcriptFont - protected open fun styleBackground(style: SessionEditorStyle) = SessionUiStyle.View.transcript() + protected open fun styleBackground(style: SessionEditorStyle) = SessionUiStyle.Transcript.bgColor() private fun refresh() { revalidate() 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 d307e0bfc61..fc63492f38e 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 @@ -29,7 +29,7 @@ abstract class AbstractSessionPartView( ) : this(header, { body }, expanded, expandable) protected val arrow = JBLabel() - protected val row = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)) + protected val row = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)) private val bound = linkedSetOf() private var body: JComponent? = null @@ -119,14 +119,11 @@ abstract class AbstractSessionPartView( protected open fun hoverColor(value: Boolean): Color? = null - protected open fun applyHover(value: Boolean, color: Color) {} - override fun setHovered(value: Boolean) { hover?.invoke(this, value) val color = hoverColor(value) ?: return if (row.background?.rgb == color.rgb) return row.background = color - applyHover(value, color) row.repaint() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt index 5b14587de93..b3482bd4c5a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt @@ -275,9 +275,9 @@ class BaseQuestionView( // ---- contentColor override ---- - override fun contentColor(): Color = SessionUiStyle.View.surface() + override fun contentColor(): Color = SessionUiStyle.View.Surface.bgColor() - override fun outlineColor(): Color = SessionUiStyle.View.line() + override fun outlineColor(): Color = SessionUiStyle.View.Outline.brightColor() // ---- private helpers ---- @@ -383,7 +383,7 @@ class BaseQuestionView( } private fun syncBackground() { - background = SessionUiStyle.View.surface() + background = SessionUiStyle.View.Surface.bgColor() } } btn.addActionListener { actionHandlers[id]?.invoke() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt index 2f8dcd0c21c..d82a3e11628 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt @@ -2,7 +2,6 @@ package ai.kilocode.client.session.views.base import ai.kilocode.client.session.ui.style.SessionUiStyle import com.intellij.util.ui.JBUI -import java.awt.Color import javax.swing.JComponent abstract class PrimarySessionPartView( @@ -13,12 +12,12 @@ abstract class PrimarySessionPartView( ) : AbstractSessionPartView(header, content, expanded, expandable) { init { isOpaque = true - background = SessionUiStyle.View.surface() + background = SessionUiStyle.View.Surface.bgColor() row.isOpaque = true - row.background = SessionUiStyle.View.header() + row.background = SessionUiStyle.View.Surface.headerBgColor() row.border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), ) syncBorder() } @@ -35,23 +34,14 @@ abstract class PrimarySessionPartView( return changed } - override fun hoverColor(value: Boolean) = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header() - - override fun applyHover(value: Boolean, color: Color) { - syncBorder() - repaint() - } + override fun hoverColor(value: Boolean) = + if (value) SessionUiStyle.View.Surface.headerHoverBgColor() else SessionUiStyle.View.Surface.headerBgColor() private fun syncBorder() { - border = if (isExpanded()) { - val color = if (row.background?.rgb == SessionUiStyle.View.headerHover().rgb) { - SessionUiStyle.View.hoverLine() - } else { - SessionUiStyle.View.line() - } - SessionUiStyle.View.sessionView(color) - } else { - JBUI.Borders.empty(1) + if (isExpanded()) { + border = JBUI.Borders.customLine(SessionUiStyle.View.Outline.color(), SessionUiStyle.View.Outline.width()) + return } + border = JBUI.Borders.empty(1) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt index ab297fa71c3..d0a0d3808e5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt @@ -2,7 +2,6 @@ package ai.kilocode.client.session.views.base import ai.kilocode.client.session.ui.style.SessionUiStyle import com.intellij.util.ui.JBUI -import java.awt.Color import javax.swing.JComponent abstract class SecondarySessionPartView( @@ -20,10 +19,10 @@ abstract class SecondarySessionPartView( ) : this(header, { content }, expanded, expandable) init { row.isOpaque = true - row.background = SessionUiStyle.View.header() + row.background = SessionUiStyle.View.Surface.headerBgColor() row.border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), ) syncBorder() } @@ -40,23 +39,14 @@ abstract class SecondarySessionPartView( return changed } - override fun hoverColor(value: Boolean) = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header() - - override fun applyHover(value: Boolean, color: Color) { - syncBorder() - repaint() - } + override fun hoverColor(value: Boolean) = + if (value) SessionUiStyle.View.Surface.headerHoverBgColor() else SessionUiStyle.View.Surface.headerBgColor() private fun syncBorder() { - border = if (isExpanded()) { - val color = if (row.background?.rgb == SessionUiStyle.View.headerHover().rgb) { - SessionUiStyle.View.hoverLine() - } else { - SessionUiStyle.View.line() - } - SessionUiStyle.View.sessionView(color) - } else { - JBUI.Borders.empty(1) + if (isExpanded()) { + border = JBUI.Borders.customLine(SessionUiStyle.View.Outline.color(), SessionUiStyle.View.Outline.width()) + return } + border = JBUI.Borders.empty(1) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt index d6584f0f0ba..c6a698304f1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt @@ -10,7 +10,6 @@ import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle -import ai.kilocode.client.session.ui.style.SessionUiStyle.View.SESSION_VIEW_GAP import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.HAlign import ai.kilocode.client.ui.layout.Stack @@ -128,7 +127,7 @@ class PermissionView( /** Adds a three-column permission detail row: tool, target, and changes. */ private fun addDetailRow(action: String, target: String?, diffs: List) { - val row = JPanel(BorderLayout(SESSION_VIEW_GAP, 0)).apply { + val row = JPanel(BorderLayout(SessionUiStyle.View.Layout.GAP, 0)).apply { isOpaque = false } @@ -174,7 +173,7 @@ class PermissionView( private fun applyTargetPane(pane: JBHtmlPane) { pane.font = style.transcriptFont pane.foreground = style.editorForeground - pane.background = SessionUiStyle.View.headerHover() + pane.background = SessionUiStyle.View.Surface.headerHoverBgColor() pane.reloadCssStylesheets() } @@ -182,7 +181,7 @@ class PermissionView( val sheet = StyleSheet() val font = style.transcriptFont val fg = ColorUtil.toHtmlColor(style.editorForeground) - val bg = ColorUtil.toHtmlColor(SessionUiStyle.View.headerHover()) + val bg = ColorUtil.toHtmlColor(SessionUiStyle.View.Surface.headerHoverBgColor()) val family = font.name.replace("\\", "\\\\").replace("'", "\\'") sheet.addRule("body { margin: 0; padding: 0 ${UiStyle.Gap.xs()}px; color: $fg; background: $bg; font-family: '$family', monospace; font-size: ${font.size}pt }") sheet.addRule("pre { margin: 0; white-space: pre-wrap; font-family: '$family', monospace; font-size: ${font.size}pt }") 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 aa8abb0bc31..a2d21018c20 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 @@ -41,18 +41,18 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = override fun updateUI() { super.updateUI() isOpaque = true - background = SessionUiStyle.View.surface() + background = SessionUiStyle.View.Surface.bgColor() border = JBUI.Borders.empty(1) } } - private val header = object : JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)) { + private val header = object : JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)) { override fun updateUI() { super.updateUI() isOpaque = true - background = SessionUiStyle.View.header() + background = SessionUiStyle.View.Surface.headerBgColor() border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), ) } } @@ -60,7 +60,7 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = private val title = JBLabel() private val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } private val arrow = JBLabel() - private val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { + private val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { isOpaque = false } private var pane: JPanel? = null @@ -170,10 +170,19 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = override fun updateUI() { super.updateUI() isOpaque = true - background = SessionUiStyle.View.surface() - border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING), + background = SessionUiStyle.View.Surface.bgColor() + border = JBUI.Borders.compound( + JBUI.Borders.customLine( + SessionUiStyle.View.Outline.brightColor(), + SessionUiStyle.View.Outline.width(), + 0, + 0, + 0, + ), + JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), + ), ) } }.apply { @@ -281,26 +290,23 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = override fun setHovered(value: Boolean) { hover?.invoke(this, value) - val color = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header() + val color = + if (value) SessionUiStyle.View.Surface.headerHoverBgColor() else SessionUiStyle.View.Surface.headerBgColor() if (header.background?.rgb != color.rgb) { header.background = color header.repaint() } - syncBorder() - root.repaint() } private fun syncBorder() { - root.border = if (isExpanded()) { - val color = if (header.background?.rgb == SessionUiStyle.View.headerHover().rgb) { - SessionUiStyle.View.hoverLine() - } else { - SessionUiStyle.View.line() - } - SessionUiStyle.View.sessionView(color) - } else { - JBUI.Borders.empty(1) + if (isExpanded()) { + root.border = JBUI.Borders.customLine( + SessionUiStyle.View.Outline.brightColor(), + SessionUiStyle.View.Outline.width(), + ) + return } + root.border = JBUI.Borders.empty(1) } private fun inside(e: MouseEvent): Boolean { 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 7dc9cb8a400..64ab4308327 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 @@ -28,7 +28,13 @@ class TodoWriteView(tool: Tool, private val parts: TodoParts = todoParts()) : init { bindHeader(parts.glyph, parts.title, parts.sub, parts.center, parts.controls) parts.list.border = JBUI.Borders.compound( - SessionUiStyle.View.topOutline(), + JBUI.Borders.customLine( + SessionUiStyle.View.Outline.color(), + SessionUiStyle.View.Outline.width(), + 0, + 0, + 0, + ), JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.md()), ) applyStyle(style) @@ -95,13 +101,13 @@ private fun todoParts(): TodoParts { val glyph = JBLabel(AllIcons.Actions.Checked) 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.SESSION_VIEW_GAP), 0)).apply { + val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { isOpaque = false add(title, BorderLayout.WEST) add(sub, BorderLayout.CENTER) } val controls = Box.createHorizontalBox() - val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { + val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { isOpaque = false add(glyph, BorderLayout.WEST) add(center, BorderLayout.CENTER) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt index c229decdd47..cde49ceb5e6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt @@ -157,7 +157,7 @@ abstract class BaseSearchToolView( private fun bodyMaxHeight(): Int { val body = parts.content ?: return 0 return body.lineHeight() * SessionUiStyle.View.Tool.BODY_LINES + - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_BODY_EXTRA_HEIGHT) + JBUI.scale(SessionUiStyle.View.Layout.BODY_EXTRA_HEIGHT) } override fun dumpLabel() = "${viewName()}#$contentId(${labelText()})" diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt index e49ca80070c..c067bbcfcfe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt @@ -140,7 +140,7 @@ class ReadToolView( private fun bodyMaxHeight(): Int { val text = parts.text ?: return 0 return text.getFontMetrics(text.font).height * bodyMaxRows() + - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_BODY_EXTRA_HEIGHT) + JBUI.scale(SessionUiStyle.View.Layout.BODY_EXTRA_HEIGHT) } override fun dumpLabel() = "ReadToolView#$contentId(${labelText()})" 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 0d9f9250a73..859590d2b31 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 @@ -214,22 +214,28 @@ class ToolBody private constructor( lineWrap = wrap wrapStyleWord = wrap foreground = if (tool.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() - background = SessionUiStyle.View.surface() + background = SessionUiStyle.View.Surface.bgColor() border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), ) } private fun pane(view: JComponent, scrolls: Boolean) = JBScrollPane(view).apply { - border = SessionUiStyle.View.topOutline() + border = JBUI.Borders.customLine( + SessionUiStyle.View.Outline.color(), + SessionUiStyle.View.Outline.width(), + 0, + 0, + 0, + ) viewportBorder = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), ).takeIf { scrolls } isOpaque = true - background = SessionUiStyle.View.surface() - viewport.background = SessionUiStyle.View.surface() + background = SessionUiStyle.View.Surface.bgColor() + viewport.background = SessionUiStyle.View.Surface.bgColor() horizontalScrollBarPolicy = if (scrolls) { ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED } else { @@ -255,9 +261,9 @@ private class ToolField(value: String, private var style: SessionEditorStyle) : ed.setBorder(JBUI.Borders.empty()) ed.scrollPane.border = JBUI.Borders.empty() ed.scrollPane.viewportBorder = JBUI.Borders.empty() - ed.backgroundColor = SessionUiStyle.View.surface() - ed.scrollPane.background = SessionUiStyle.View.surface() - ed.scrollPane.viewport.background = SessionUiStyle.View.surface() + ed.backgroundColor = SessionUiStyle.View.Surface.bgColor() + ed.scrollPane.background = SessionUiStyle.View.Surface.bgColor() + ed.scrollPane.viewport.background = SessionUiStyle.View.Surface.bgColor() ed.settings.isUseSoftWraps = false ed.settings.isAdditionalPageAtBottom = false ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER @@ -296,9 +302,9 @@ internal fun toolParts( add(link, LINK_CARD) } val state = JBLabel().apply { foreground = UiStyle.Colors.weak() } - val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { isOpaque = false } + val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { isOpaque = false } val controls = Stack.horizontal() - val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { + val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { isOpaque = false center.add(title, BorderLayout.WEST) center.add(slot, BorderLayout.CENTER) @@ -331,14 +337,14 @@ 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.SESSION_VIEW_GAP), 0)).apply { + val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { isOpaque = false minimumSize = Dimension(0, minimumSize.height) add(title, BorderLayout.WEST) add(target, BorderLayout.CENTER) } val controls = Stack.horizontal() - val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { + val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { isOpaque = false add(glyph, BorderLayout.WEST) add(center, BorderLayout.CENTER) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt index c784aafdfed..1c141f6aeac 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt @@ -153,7 +153,7 @@ class ToolView( private fun bodyMaxHeight(): Int { val body = parts.content ?: return 0 return body.lineHeight() * bodyMaxRows() + - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_BODY_EXTRA_HEIGHT) + JBUI.scale(SessionUiStyle.View.Layout.BODY_EXTRA_HEIGHT) } override fun dumpLabel() = "ToolView#$contentId(${labelText()})" diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index a96fc44bb1c..8a9ae5ccd20 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -570,12 +570,15 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { second.toggle() enter(header(first)) - assertEquals(SessionUiStyle.View.hoverLine().rgb, paint(firstRoot.border).rgb) + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, header(first).background.rgb) + assertLine(firstRoot.border) enter(header(second)) - assertEquals(SessionUiStyle.View.line().rgb, paint(firstRoot.border).rgb) - assertEquals(SessionUiStyle.View.hoverLine().rgb, paint(secondRoot.border).rgb) + assertEquals(SessionUiStyle.View.Surface.headerBgColor().rgb, header(first).background.rgb) + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, header(second).background.rgb) + assertLine(firstRoot.border) + assertLine(secondRoot.border) } // ------ helpers ------ @@ -669,12 +672,16 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { )) } - private fun paint(border: Border): Color { - val image = BufferedImage(3, 3, BufferedImage.TYPE_INT_ARGB) + private fun assertLine(border: Border) { + val image = BufferedImage(5, 5, BufferedImage.TYPE_INT_ARGB) val item = JPanel() val graphics = image.createGraphics() border.paintBorder(item, graphics, 0, 0, image.width, image.height) graphics.dispose() - return Color(image.getRGB(0, 0), true) + val rgb = SessionUiStyle.View.Outline.brightColor().rgb + assertEquals(rgb, Color(image.getRGB(2, 0), true).rgb) + assertEquals(rgb, Color(image.getRGB(0, 2), true).rgb) + assertEquals(rgb, Color(image.getRGB(4, 2), true).rgb) + assertEquals(rgb, Color(image.getRGB(2, 4), true).rgb) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlayTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlayTest.kt index ce541a5e719..4aaa851181c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlayTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlayTest.kt @@ -205,8 +205,8 @@ class SessionAccountOverlayTest : SessionControllerTestBase() { val prof = profile(email = "user@example.com") show(snap(prof)) edt { - assertEquals(SessionUiStyle.View.sessionViewBackground(), panel.panelBackground()) - assertEquals(SessionUiStyle.View.sessionViewOutline(), panel.panelBorderColor()) + assertEquals(SessionUiStyle.AccountPopup.bgColor(), panel.panelBackground()) + assertEquals(SessionUiStyle.AccountPopup.outlineColor(), panel.panelBorderColor()) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt index 60c65070a37..5f651adcb81 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt @@ -63,7 +63,7 @@ class LoginRequiredViewTest : BasePlatformTestCase() { val view = LoginRequiredView(openProfile = {}, dismiss = {}) view.show("Sign in required.") val btn = view.openProfileButton() - assertEquals(SessionUiStyle.View.surface(), btn.background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), btn.background) } } 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 f700180317a..5c4bb2947a9 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 @@ -131,22 +131,26 @@ class QuestionResultViewTest : BasePlatformTestCase() { assertFalse("Should be collapsed after second toggle", view.isExpanded()) } - fun `test hover border differs from header fill`() { + fun `test hover only changes header background`() { val view = QuestionResultView(completedTool( input = mapOf("questions" to """[{"question":"Q1"}]"""), metadata = mapOf("answers" to """[["A1"]]"""), )) val root = view.node(0) + val header = root.node(0) assertEquals(0, paint(root.border).alpha) view.toggle() + val body = root.node(1) view.setHovered(true) - assertEquals(SessionUiStyle.View.hoverLine().rgb, paint(root.border).rgb) - assertNotSameColor(SessionUiStyle.View.headerHover(), paint(root.border)) + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, header.background.rgb) + assertLine(root.border) + assertEquals(SessionUiStyle.View.Outline.brightColor().rgb, paint(body.border).rgb) view.setHovered(false) - assertEquals(SessionUiStyle.View.line().rgb, paint(root.border).rgb) + assertEquals(SessionUiStyle.View.Surface.headerBgColor().rgb, header.background.rgb) + assertLine(root.border) } // ------ view factory routing ------ @@ -293,7 +297,17 @@ class QuestionResultViewTest : BasePlatformTestCase() { return Color(image.getRGB(0, 0), true) } - private fun assertNotSameColor(left: Color, right: Color) { - assertFalse("Expected distinct colors but both were ${left.rgb}", left.rgb == right.rgb) + private fun assertLine(border: Border) { + val image = BufferedImage(5, 5, BufferedImage.TYPE_INT_ARGB) + val panel = JPanel() + val graphics = image.createGraphics() + border.paintBorder(panel, graphics, 0, 0, image.width, image.height) + graphics.dispose() + val rgb = SessionUiStyle.View.Outline.brightColor().rgb + assertEquals(rgb, Color(image.getRGB(2, 0), true).rgb) + assertEquals(rgb, Color(image.getRGB(0, 2), true).rgb) + assertEquals(rgb, Color(image.getRGB(4, 2), true).rgb) + assertEquals(rgb, Color(image.getRGB(2, 4), true).rgb) } + } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt index 6efd3d240c1..9cfcb7195cc 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt @@ -468,8 +468,8 @@ class QuestionViewTest : BasePlatformTestCase() { val dismiss = button(view, "Dismiss") val submit = button(view, "Submit") - assertEquals(SessionUiStyle.View.surface(), dismiss.background) - assertEquals(SessionUiStyle.View.surface(), submit.background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), dismiss.background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), submit.background) } fun `test review submit and back buttons have correct primary state on review page`() { 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 6204efbca4b..1de7afe7eeb 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 @@ -118,7 +118,7 @@ class ToolViewTest : BasePlatformTestCase() { assertEquals(0, paint(view.border).alpha) view.toggle() - assertEquals(SessionUiStyle.View.line().rgb, paint(view.border).rgb) + assertEquals(SessionUiStyle.View.Outline.color().rgb, paint(view.border).rgb) view.toggle() assertEquals(0, paint(view.border).alpha) } 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 6c02b659b17..bc8e1e9ed18 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 @@ -69,23 +69,25 @@ class AbstractSessionPartViewTest : BasePlatformTestCase() { assertNull(content.parent) } - fun `test header hover is subtler than hover outline`() { - assertNotSameColor(SessionUiStyle.View.headerHover(), SessionUiStyle.View.hoverLine()) - assertNotSameColor(SessionUiStyle.View.headerHover(), SessionUiStyle.View.line()) + fun `test header hover fill differs from outline colors`() { + assertNotSameColor(SessionUiStyle.View.Surface.headerHoverBgColor(), SessionUiStyle.View.Outline.hoverColor()) + assertNotSameColor(SessionUiStyle.View.Surface.headerHoverBgColor(), SessionUiStyle.View.Outline.brightColor()) } - fun `test primary card border follows hover color`() { + fun `test primary card hover only changes header background`() { val view = TestView(content = JLabel("body")) + val row = view.component(0) as JPanel assertEquals(0, paint(view.border).alpha) view.expand() view.setHovered(true) - assertEquals(SessionUiStyle.View.hoverLine().rgb, paint(view.border).rgb) - assertNotSameColor(SessionUiStyle.View.headerHover(), paint(view.border)) + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, row.background.rgb) + assertLine(view.border) view.setHovered(false) - assertEquals(SessionUiStyle.View.line().rgb, paint(view.border).rgb) + assertEquals(SessionUiStyle.View.Surface.headerBgColor().rgb, row.background.rgb) + assertLine(view.border) } private class TestView(content: JLabel, expanded: Boolean = false, expandable: Boolean = true) : @@ -124,6 +126,19 @@ class AbstractSessionPartViewTest : BasePlatformTestCase() { return Color(image.getRGB(0, 0), true) } + private fun assertLine(border: Border) { + val image = BufferedImage(5, 5, BufferedImage.TYPE_INT_ARGB) + val panel = JPanel() + val graphics = image.createGraphics() + border.paintBorder(panel, graphics, 0, 0, image.width, image.height) + graphics.dispose() + val rgb = SessionUiStyle.View.Outline.color().rgb + assertEquals(rgb, Color(image.getRGB(2, 0), true).rgb) + assertEquals(rgb, Color(image.getRGB(0, 2), true).rgb) + assertEquals(rgb, Color(image.getRGB(4, 2), true).rgb) + assertEquals(rgb, Color(image.getRGB(2, 4), true).rgb) + } + private fun assertNotSameColor(left: Color, right: Color) { assertFalse("Expected distinct colors but both were ${left.rgb}", left.rgb == right.rgb) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt index 41e4229f45d..16d26466c83 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt @@ -242,8 +242,8 @@ class BaseQuestionViewTest : BasePlatformTestCase() { BaseQuestionView.Action("a", "A", primary = false) {}, BaseQuestionView.Action("b", "B", primary = true) {}, )) - assertEquals(SessionUiStyle.View.surface(), actionButton(panel, "A").background) - assertEquals(SessionUiStyle.View.surface(), actionButton(panel, "B").background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), actionButton(panel, "A").background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), actionButton(panel, "B").background) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt index 1e6c3a28150..18082d542a4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt @@ -434,8 +434,8 @@ class PermissionViewTest : BasePlatformTestCase() { fun `test session question buttons use question surface background`() { view.show(permission()) - assertEquals(SessionUiStyle.View.surface(), view.runButtonForTest().background) - assertEquals(SessionUiStyle.View.surface(), view.denyButtonForTest().background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), view.runButtonForTest().background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), view.denyButtonForTest().background) } // ------ code labels use transcript style ------ @@ -494,7 +494,7 @@ class PermissionViewTest : BasePlatformTestCase() { val labels = view.codeLabelsForTest() assertFalse("Expected code labels", labels.isEmpty()) - assertEquals(SessionUiStyle.View.headerHover(), labels[0].background) + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor(), labels[0].background) } private fun permission() = Permission( diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/UiStyleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/UiStyleTest.kt index 9749a587a94..b3702301c9c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/UiStyleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/UiStyleTest.kt @@ -39,9 +39,9 @@ class UiStyleTest : BasePlatformTestCase() { fun `test session layout constants provide shared geometry`() { assertTrue(JBUI.scale(SessionUiStyle.SessionLayout.GAP) > 0) - assertTrue(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP) > 0) - assertTrue(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_VERTICAL_PADDING) > 0) - assertTrue(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING) > 0) + assertTrue(JBUI.scale(SessionUiStyle.View.Layout.GAP) > 0) + assertTrue(JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING) > 0) + assertTrue(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING) > 0) assertTrue(SessionUiStyle.View.Tool.BODY_LINES > 0) assertTrue(SessionUiStyle.View.Reasoning.BODY_LINES > 0) } From a0c3f4be27519d0725f733ff236a43081026e6aa Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 8 Jun 2026 18:47:24 -0400 Subject: [PATCH 17/26] fix(jetbrains): align session progress footer --- .../ai/kilocode/client/session/ui/ProgressPanel.kt | 8 ++++++++ .../kilocode/client/session/ui/ProgressPanelTest.kt | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt index bb2ed7d8696..5a9d6d757cb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt @@ -5,12 +5,14 @@ import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.StackAxis import com.intellij.openapi.Disposable import com.intellij.ui.AnimatedIcon import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI /** * Progress footer rendered at the bottom of the session transcript while the @@ -35,6 +37,12 @@ class ProgressPanel( init { isOpaque = false isVisible = false + border = JBUI.Borders.empty( + UiStyle.Gap.sm(), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), + 0, + 0, + ) applyStyle(SessionEditorStyle.current()) next(JBLabel(AnimatedIcon.Default())) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt index 09f1d288f38..7974709b08d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt @@ -4,9 +4,12 @@ import ai.kilocode.client.session.model.Permission import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.UiStyle import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.JBUI /** * Verifies [ProgressPanel] show/hide behaviour driven by direct [SessionModel] @@ -45,6 +48,15 @@ class ProgressPanelTest : BasePlatformTestCase() { assertEquals("Thinking\u2026", panel.labelText()) } + fun `test panel uses transcript row padding`() { + val ins = panel.insets + + assertEquals(UiStyle.Gap.sm(), ins.top) + assertEquals(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), ins.left) + assertEquals(0, ins.bottom) + assertEquals(0, ins.right) + } + fun `test panel hides on Idle`() { model.setState(SessionState.Busy("Thinking\u2026")) model.setState(SessionState.Idle) From 2c0b17678c81c5b17ea4ce9ee9c7cdf9e9d865b3 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 8 Jun 2026 19:10:01 -0400 Subject: [PATCH 18/26] fix(jetbrains): tune question card spacing --- .../session/views/base/BaseQuestionView.kt | 14 ++++++- .../session/views/question/QuestionView.kt | 8 +++- .../client/session/views/QuestionViewTest.kt | 38 +++++++++++++++++++ .../views/base/BaseQuestionViewTest.kt | 14 ++++++- 4 files changed, 71 insertions(+), 3 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt index b3482bd4c5a..3a73b355a65 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt @@ -41,6 +41,8 @@ import javax.swing.JPanel class BaseQuestionView( private val selection: SessionSelection? = null, ) : RoundedContentPanel( + UiStyle.Gap.pad(), + UiStyle.Gap.pad(), UiStyle.Gap.lg(), UiStyle.Gap.pad(), ), SessionEditorStyleTarget { @@ -95,6 +97,7 @@ class BaseQuestionView( private var top: JComponent? = null private var content: JComponent? = null private var actionLeft: JComponent? = null + private var gap = UiStyle.Gap.lg() // action buttons keyed by id for retained updates private val actionButtons = mutableMapOf() @@ -184,6 +187,15 @@ class BaseQuestionView( repaint() } + @RequiresEdt + fun setSpacing(top: Int, gap: Int) { + this.gap = gap + border = JBUI.Borders.empty(top, UiStyle.Gap.pad(), UiStyle.Gap.lg(), UiStyle.Gap.pad()) + syncNorth() + revalidate() + repaint() + } + /** * Configure the action buttons shown in the card's right-aligned footer. * @@ -285,7 +297,7 @@ class BaseQuestionView( north.removeAll() top?.let { north.next(it) } north.next(header) - if (content != null) north.fill(UiStyle.Gap.md()) + if (content != null) north.fill(gap) north.revalidate() north.repaint() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt index b924a78b30f..63204e3583e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt @@ -94,7 +94,7 @@ class QuestionView( } private val topPanel = JPanel(BorderLayout()).apply { isOpaque = false - border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg()) + border = JBUI.Borders.empty() alignmentX = Component.LEFT_ALIGNMENT } private val body = JPanel().apply { @@ -207,6 +207,12 @@ class QuestionView( summary.isVisible = total > 1 nav.isVisible = total > 1 topPanel.isVisible = total > 1 + if (total > 1) { + topPanel.border = JBUI.Borders.empty(0, 0, UiStyle.Gap.sm(), 0) + card.setSpacing(UiStyle.Gap.sm(), UiStyle.Gap.pad()) + return + } + card.setSpacing(UiStyle.Gap.xl(), UiStyle.Gap.pad()) } @RequiresEdt diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt index 9cfcb7195cc..9305d3114a8 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt @@ -5,8 +5,10 @@ import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.views.base.BaseQuestionView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.ui.HoverIcon +import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.QuestionReplyDto import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI import com.intellij.testFramework.fixtures.BasePlatformTestCase @@ -15,11 +17,13 @@ import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBRadioButton import com.intellij.ui.components.JBTextArea +import java.awt.BorderLayout import java.awt.Component import java.awt.Container import kotlin.math.abs import javax.swing.AbstractButton import javax.swing.JButton +import javax.swing.JComponent import javax.swing.SwingUtilities @Suppress("UnstableApiUsage") @@ -134,6 +138,16 @@ class QuestionViewTest : BasePlatformTestCase() { assertTrue(findAll(view).none { it.text == "1 of 1 questions" && it.isVisible }) } + fun `test single question uses roomy card spacing`() { + view.show(singleSelectQuestion("q_single_spacing")) + + val card = card() + val ins = card.border.getBorderInsets(card) + + assertEquals(UiStyle.Gap.xl(), ins.top) + assertEquals(UiStyle.Gap.pad(), spacer(card).preferredSize.height) + } + fun `test single question submit sends selected answer`() { view.show(singleSelectQuestion("req_2")) @@ -294,6 +308,23 @@ class QuestionViewTest : BasePlatformTestCase() { assertEquals(listOf(listOf("Minimal"), listOf("Unit")), replies.single().second.answers) } + fun `test multi question progress header has top padding`() { + view.show(twoItemQuestion("q_progress_padding")) + + val card = card() + val outer = card.border.getBorderInsets(card) + val summary = findAll(view).first { it.text == "1 of 2 questions" } + val panel = summary.parent as JComponent + val ins = panel.border.getBorderInsets(panel) + + assertEquals(UiStyle.Gap.sm(), outer.top) + assertEquals(0, ins.top) + assertEquals(0, ins.left) + assertEquals(UiStyle.Gap.sm(), ins.bottom) + assertEquals(0, ins.right) + assertEquals(UiStyle.Gap.pad(), spacer(card).preferredSize.height) + } + fun `test multi question uses review before submit`() { view.show(twoItemQuestion("q_review")) @@ -929,6 +960,13 @@ class QuestionViewTest : BasePlatformTestCase() { private fun text(root: Container, value: String): JBTextArea = findAll(root).first { it.text == value } + private fun card(): BaseQuestionView = findAll(view).distinct().single() + + private fun spacer(card: BaseQuestionView): Component { + val north = (card.layout as BorderLayout).getLayoutComponent(BorderLayout.NORTH) as Container + return north.components.last() + } + private fun layout(root: Container, width: Int = 400) { root.setSize(width, root.preferredSize.height) layoutTree(root) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt index 16d26466c83..8136916ef49 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt @@ -164,7 +164,7 @@ class BaseQuestionViewTest : BasePlatformTestCase() { val north = region(panel, BorderLayout.NORTH) as Container val filler = north.components.last() - assertEquals(UiStyle.Gap.md(), filler.preferredSize.height) + assertEquals(UiStyle.Gap.lg(), filler.preferredSize.height) assertEquals(0, filler.preferredSize.width) } } @@ -296,6 +296,18 @@ class BaseQuestionViewTest : BasePlatformTestCase() { } } + fun `test card top padding uses next spacing step`() { + edt { + val panel = BaseQuestionView() + val ins = panel.border.getBorderInsets(panel) + + assertEquals(UiStyle.Gap.pad(), ins.top) + assertEquals(UiStyle.Gap.pad(), ins.left) + assertEquals(UiStyle.Gap.lg(), ins.bottom) + assertEquals(UiStyle.Gap.pad(), ins.right) + } + } + fun `test action left alone attaches footer west`() { edt { val panel = BaseQuestionView() From 5cf95c548e9ad97ce3849d2bd174c393e27c4a0a Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 8 Jun 2026 19:17:08 -0400 Subject: [PATCH 19/26] fix(jetbrains): align reasoning toggle --- .../client/session/ui/style/SessionUiStyle.kt | 1 - .../ai/kilocode/client/session/views/ReasoningView.kt | 2 +- .../client/session/views/ReasoningViewTest.kt | 11 +++++++++++ 3 files changed, 12 insertions(+), 2 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 ed5d2efa131..64fc44d6aa3 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 @@ -81,7 +81,6 @@ object SessionUiStyle { object Reasoning { const val BODY_LINES = 5 const val HEADER_VERTICAL_PADDING = 5 - const val HEADER_HORIZONTAL_PADDING = 10 const val BODY_VERTICAL_PADDING = 4 const val BODY_HORIZONTAL_PADDING = 8 } 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 8e2bc24a747..01b9e4a1e83 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 @@ -61,7 +61,7 @@ class ReasoningView( init { row.border = JBUI.Borders.empty( JBUI.scale(SessionUiStyle.View.Reasoning.HEADER_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.Reasoning.HEADER_HORIZONTAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), ) bindHeader(parts.title, parts.icon) applyStyle(style) 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 6589e0411eb..42c60a53158 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 @@ -5,7 +5,9 @@ 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 com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil +import javax.swing.JPanel import javax.swing.ScrollPaneConstants @Suppress("UnstableApiUsage") @@ -197,6 +199,15 @@ class ReasoningViewTest : BasePlatformTestCase() { assertEquals(SessionUiStyle.View.Reasoning.BODY_LINES, view.bodyMaxRows()) } + fun `test reasoning toggle uses shared right rail`() { + val view = ReasoningView(reasoning("p1", done = true, text = "one")) + val row = view.components.single() as JPanel + val insets = row.border.getBorderInsets(row) + + assertEquals(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), insets.left) + assertEquals(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), insets.right) + } + fun `test link opens url callback`() { val urls = mutableListOf() val view = ReasoningView(reasoning("p1", done = true, text = "[docs](https://kilocode.ai/docs)"), openUrl = { From d8b40526efcd53965d7e0444e447b962c866e614 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 8 Jun 2026 19:23:33 -0400 Subject: [PATCH 20/26] chore(jetbrains): add UI implementation plans --- .kilo/plans/1780947943088-nimble-squid.md | 104 +++++++++++++++ .kilo/plans/1780950338277-hidden-orchid.md | 139 +++++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 .kilo/plans/1780947943088-nimble-squid.md create mode 100644 .kilo/plans/1780950338277-hidden-orchid.md diff --git a/.kilo/plans/1780947943088-nimble-squid.md b/.kilo/plans/1780947943088-nimble-squid.md new file mode 100644 index 00000000000..190296d5a3f --- /dev/null +++ b/.kilo/plans/1780947943088-nimble-squid.md @@ -0,0 +1,104 @@ +# Plan: Repo-Relative Search Tool Paths + +## Goal + +Update JetBrains search-style tool headers so the path target is displayed relative to the current repo/workspace directory: + +- If the path is inside the repo, show the relative path. +- If the path resolves to the repo root (`.` or the repo directory), hide the path target entirely. +- If the path is outside the repo, show the full normalized path. +- Apply this to all current search-style tools: `glob` and `grep`/Search. +- Use IntelliJ path utilities instead of handwritten string prefix/splitting logic. + +## Findings + +- Search-style target rendering is centralized in `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt`: + - `globDirectory(tool)` currently returns raw `tool.input["path"]` or `tool.title`. + - `searchTargets(tool)` currently returns raw `tool.input["path"]`, then `pattern=...`, then `include=...`. +- `GlobToolView` and `SearchToolView` both extend `BaseSearchToolView`, which calls `targets(item)` during base initialization. +- `SessionUi` has the repo/workspace directory as `workspace.directory`, but the view creation path currently does not pass it to tool views. +- In JetBrains split mode, frontend and backend may not share OS path semantics, so local `java.nio.file.Path` interpretation alone can be risky for backend-originated paths. +- IntelliJ provides suitable path helpers in `com.intellij.openapi.util.io`: + - `FileUtil.toSystemIndependentName(...)` + - `FileUtil.toCanonicalPath(..., '/', true)` + - `FileUtil.join(...)` + - `FileUtil.getRelativePath(base, file, '/')` + - `OSAgnosticPathUtil.isAbsolute(...)` + - `OSAgnosticPathUtil.startsWith(...)` +- These helpers are string/path-text utilities and do not require VFS refreshes, file existence checks, RPC, git queries, or network calls. + +## EDT Safety Requirements + +- All search header synchronization runs on the EDT, so path display formatting must be deterministic, local, and non-blocking. +- Do not resolve the repo root during rendering. Use the already-known `Workspace.directory` captured by `SessionUi` and pass it down as an immutable string. +- Do not call any API that can touch disk, VFS, git, RPC, backend services, or the network from `BaseSearchToolView.sync()` / `targets(...)` / the path formatter. +- Specifically avoid `Files.exists`, `Path.toRealPath`, `File.getCanonicalPath`, `LocalFileSystem.refreshAndFindFileByPath`, `VfsUtil`, `Project.baseDir`, `GitRepositoryManager`, `KiloWorkspaceService`, and any coroutine/RPC call in this path. +- The formatter should only use pure string helpers such as `FileUtil.toSystemIndependentName`, `FileUtil.toCanonicalPath(path, '/', true)`, `FileUtil.getRelativePath(base, file, '/')`, and `OSAgnosticPathUtil` checks. +- The amount of work is bounded to a few short path strings per render/update, so no background dispatch or caching is needed unless implementation profiling later shows otherwise. + +## Implementation Steps + +1. Add an internal search path formatter in `ToolSupport.kt`. + - Keep it pure and UI-independent, for example `internal fun searchPath(path: String, repo: String?): String`. + - Normalize both `path` and `repo` with `FileUtil.toSystemIndependentName` and `FileUtil.toCanonicalPath(..., '/', true)`. + - Use `OSAgnosticPathUtil.isAbsolute(...)` to decide whether the tool path is absolute. + - Resolve relative tool paths against the normalized repo with `FileUtil.join(...)`, then canonicalize. + - If no repo is available, keep the existing raw display behavior except hide `.`. + - If the resolved target equals the repo root, return `""` so the target row is hidden. + - If the resolved target is under the repo root, return `FileUtil.getRelativePath(repo, target, '/')`. + - If the resolved target is outside the repo, return the full normalized target path. + - Avoid manual prefix checks, separator splitting, or homemade `../` handling. + - Keep this formatter string-only: no `java.nio.file.Files`, VFS, git, project model, service, or RPC access. + +2. Update target helper functions in `ToolSupport.kt`. + - Change `globDirectory(tool)` to accept `repo: String?` and format the path/title through `searchPath(...)`. + - Change `searchTargets(tool)` to accept `repo: String?` and format only the `path` element through `searchPath(...)`. + - Keep `pattern=...` and `include=...` unchanged. + - Filter blank formatted paths so root paths disappear and pattern/include shift left as they do today when path is absent. + +3. Pass repo context into search tool views. + - Add `repo: String? = null` to `BaseSearchToolView` and store it in the base class. + - Change the abstract target hook to use the base-owned repo, e.g. `targets(tool: Tool, repo: String?): List`. + - This avoids accessing subclass properties from `BaseSearchToolView.init`, which already calls `sync()`. + - Add optional `repo` constructor parameters to `GlobToolView` and `SearchToolView` and pass them to `BaseSearchToolView`. + - Preserve existing defaults so direct tests and call sites that do not know a repo still compile. + - Store the repo string as provided; do not lazily resolve or refresh it from IntelliJ project state inside the view. + +4. Propagate `workspace.directory` through the session view creation path. + - Add optional `repo: String? = null` parameters through: + - `SessionMessageListPanel` + - `TurnView` + - `MessageView` + - `ViewFactory.create(...)` + - `ViewFactory.createUser(...)` + - In `SessionUi.buildUi()`, pass `repo = workspace.directory` when constructing `SessionMessageListPanel`. + - In `ViewFactory`, pass the repo only to `GlobToolView` and `SearchToolView`; other tool views keep current behavior. + +5. Update tests. + - In `GlobToolViewTest`, add coverage for: + - Absolute path inside repo displays as relative, e.g. `src`. + - `.` and exact repo root hide the path row. + - Absolute path outside repo stays full/normalized. + - In `SearchToolViewTest`, add the same repo-relative/root/outside cases for the `path` target while keeping `pattern` and `include` rows unchanged. + - Keep existing no-repo tests to prove fallback behavior remains stable. + - Update any existing expectations that intentionally pass a repo. + - Prefer portable test paths built from simple normalized roots; do not depend on files actually existing. + +6. Add a patch changeset. + - This is user-visible JetBrains UI behavior. + - Add a `.changeset/.md` entry for `"kilo-code": patch`. + - Suggested wording: `Display JetBrains search tool paths relative to the current repository when possible.` + +## Verification + +Run from `packages/kilo-jetbrains/`: + +1. `./gradlew :frontend:test --tests ai.kilocode.client.session.views.GlobToolViewTest --tests ai.kilocode.client.session.views.SearchToolViewTest` +2. `./gradlew typecheck` + +If constructor propagation causes broader frontend compile errors, fix those and rerun the same checks. + +## Notes + +- No `kilocode_change` markers are needed because this is under `packages/kilo-jetbrains/`, a Kilo-owned package. +- Keep the change scoped to search-style tool header paths. Do not alter read tool filename display or tool body output formatting unless requested separately. diff --git a/.kilo/plans/1780950338277-hidden-orchid.md b/.kilo/plans/1780950338277-hidden-orchid.md new file mode 100644 index 00000000000..1c343f05def --- /dev/null +++ b/.kilo/plans/1780950338277-hidden-orchid.md @@ -0,0 +1,139 @@ +# Refactor SessionUiStyle View Tokens + +## Goal +Refactor `SessionUiStyle.View` so tokens are grouped by semantic meaning and call sites describe what they are styling. At the same time, simplify session borders so the style layer only defines two outline colors plus one border width: + +- Bright outline color: prompt/user prompt bubble, prompt input shell, and all question-style views. +- Regular outline color: reasoning and all other session card borders/separators by default. +- Border width: shared one-pixel outline width used by views when constructing their own borders. + +## Findings +- `SessionUiStyle.View` currently mixes transcript backgrounds, card layout constants, card surfaces, hover colors, outline colors, border factories, and nested component groups in one object. +- Current `View.line()` is a high-contrast editor-background-derived color and is used for both bright prompt/question borders and regular card/separator borders. +- Current `View.sessionViewOutline()` delegates to `UiStyle.Colors.contentBorder()`, which is the right softer/default outline color. +- Existing border helpers (`sessionView`, `outline`, `topOutline`, `leftOutline`) hide whether a call site wants bright or regular outline styling and mix color decisions with border-shape decisions. +- The main production call-site groups are: + - Transcript backgrounds: `SessionMessageListPanel`, `SessionScroll`, `TextView`. + - Card layout/surfaces/hover: base part views, tool/todo/question result views. + - Bright prompt/question borders: `PromptPanel.PromptShell`, `MessageView` user prompt bubble, `BaseQuestionView`, `QuestionResultView`. + - Default cards: `PrimarySessionPartView`, `SecondarySessionPartView`, `ReasoningView`, tool body panes, todo body panes. + - Popup/content panel styling: `SessionAccountOverlay`. + - Separators: `ConnectionPanel`, `Dock.banner()`, `CompactionView`. + +## Proposed API +Refactor `SessionUiStyle.kt` without keeping compatibility aliases for old messy names. + +```kotlin +object SessionUiStyle { + object Transcript { + fun bgColor(): Color + } + + object View { + object Layout { + const val GAP = 6 + const val VERTICAL_PADDING = 8 + const val HORIZONTAL_PADDING = 12 + const val BODY_EXTRA_HEIGHT = 16 + } + + object Surface { + fun bgColor(): Color + fun headerBgColor(): Color + fun headerHoverBgColor(): Color + } + + object Outline { + fun color(): Color + fun brightColor(): Color + fun hoverColor(): Color + fun width(): Int + } + + object Prompt { ... } + object Reasoning { ... } + object Message { ... } + object Code { ... } + object Permission { ... } + object Tool { ... } + } + + object AccountPopup { + fun bgColor(): Color + fun outlineColor(): Color + } +} +``` + +Concrete color mapping: +- `View.Outline.color()` returns the softer regular outline, using `UiStyle.Colors.contentBorder()`. +- `View.Outline.brightColor()` preserves the current bright `line()` behavior, using `UiStyle.Colors.contrast(UiStyle.Colors.editorBackground(), BORDER_DELTA)`. +- `View.Outline.hoverColor()` preserves the current hover outline calculation for `Surface.headerHoverBgColor()`. +- `View.Outline.width()` returns `JBUI.scale(1)`. +- `View.Surface.bgColor()` and `View.Surface.headerBgColor()` both use `UiStyle.Colors.editorBackground()`. +- `View.Surface.headerHoverBgColor()` preserves the current `headerHover()` behavior. +- `SessionUiStyle.Transcript.bgColor()` replaces `View.transcript()`. +- `SessionUiStyle.AccountPopup.bgColor()` replaces `View.sessionViewBackground()`. +- `SessionUiStyle.AccountPopup.outlineColor()` replaces `View.sessionViewOutline()`. + +Border construction rule: +- `SessionUiStyle` does not expose all-side/top/left border factories. +- Views construct borders locally from `View.Outline.color()` or `View.Outline.brightColor()` plus `View.Outline.width()` according to their layout. +- Examples: all-side cards use `JBUI.Borders.customLine(color, width)`, body separators use `JBUI.Borders.customLine(color, width, 0, 0, 0)`, reasoning uses `JBUI.Borders.customLine(color, 0, width, 0, 0)`, and rounded prompt/question shells paint using the same color and width. + +## Implementation Plan +1. Update `SessionUiStyle.kt`. + - Add `Transcript`, `View.Layout`, `View.Surface`, `View.Outline`, and `AccountPopup` groups. + - Move current `SESSION_VIEW_*` constants into `View.Layout`. + - Move current `surface`, `header`, and `headerHover` into `View.Surface` with `*BgColor` names. + - Replace `line` and `hoverLine` with the `View.Outline` color/width API above. + - Remove `sessionView`, `outline`, `topOutline`, and `leftOutline` instead of replacing them with new border factory helpers. + - Remove old methods/constants after call sites are migrated. + +2. Update regular session-card call sites to softer outlines. + - `PrimarySessionPartView.syncBorder()` constructs an all-side border from `SessionUiStyle.View.Outline.color()` and `width()`. + - `SecondarySessionPartView.syncBorder()` constructs an all-side border from `SessionUiStyle.View.Outline.color()` and `width()`. + - `ReasoningView.syncBorder()` constructs a left-only border from `SessionUiStyle.View.Outline.color()` and `width()`. + - Tool/todo body separators construct top-only borders from `SessionUiStyle.View.Outline.color()` and `width()`. + - Connection, dock banner, and compaction separators use `SessionUiStyle.View.Outline.color()` and `width()` as appropriate. + +3. Update bright prompt/question call sites. + - `PromptPanel.PromptShell.outlineColor()` -> `SessionUiStyle.View.Outline.brightColor()` when not focused. + - `MessageView.paintComponent()` user prompt bubble outline -> `brightColor()`. + - `BaseQuestionView.outlineColor()` -> `brightColor()`. + - `QuestionResultView.syncBorder()` constructs an all-side border from `brightColor()` and `width()` when expanded. + - `QuestionResultView` body separator constructs a top-only border from `brightColor()` and `width()`. + +4. Update semantic surface/layout call sites. + - Transcript backgrounds -> `SessionUiStyle.Transcript.bgColor()`. + - Card backgrounds -> `SessionUiStyle.View.Surface.bgColor()`. + - Header backgrounds -> `SessionUiStyle.View.Surface.headerBgColor()`. + - Hover header backgrounds -> `SessionUiStyle.View.Surface.headerHoverBgColor()`. + - Layout constants -> `SessionUiStyle.View.Layout.*`. + - Account popup background/border test helper -> `SessionUiStyle.AccountPopup.*`. + +5. Update tests. + - Replace direct assertions against old names with semantic new names. + - Add or adjust assertions so prompt/question borders use `View.Outline.brightColor()`. + - Add or adjust assertions so reasoning/tool/regular expanded card borders use `View.Outline.color()`. + - Keep hover tests asserting only header background changes, using `View.Surface.headerHoverBgColor()` and `headerBgColor()`. + - Finish the pending `QuestionResultViewTest` all-side border assertions and make them check the bright outline. + +6. Changeset. + - Add a patch changeset for `@kilocode/kilo-jetbrains` because the visible JetBrains session border contrast changes. + - Suggested release note: `Refine JetBrains session card borders so prompt and question surfaces use brighter outlines while reasoning and tool cards use softer default borders.` + +## Verification +Run from `packages/kilo-jetbrains/`: + +```sh +./gradlew :frontend:test --tests ai.kilocode.client.session.views.base.AbstractSessionPartViewTest --tests ai.kilocode.client.session.views.QuestionResultViewTest --tests ai.kilocode.client.session.ui.SessionMessageListPanelTest --tests ai.kilocode.client.session.views.ToolViewTest --tests ai.kilocode.client.session.views.base.BaseQuestionViewTest --tests ai.kilocode.client.session.views.question.QuestionViewTest --tests ai.kilocode.client.session.views.permission.PermissionViewTest --tests ai.kilocode.client.session.ui.account.SessionAccountOverlayTest +./gradlew typecheck +``` + +## Constraints +- This task touches only `packages/kilo-jetbrains/` and a changeset. +- Preserve unrelated worktree changes and do not revert user/agent changes from the previous hover/border work. +- Keep Swing UI mutations on the EDT. +- Do not introduce Compose, JCEF, Kotlin UI DSL, services, RPC, or broad UI rewrites. +- Prefer small, mechanical call-site updates over deeper component refactors. From d1fa4506c8b8e65b21cd08e0c6600598366aed0f Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 8 Jun 2026 20:52:36 -0400 Subject: [PATCH 21/26] fix(jetbrains): align session view icons --- .changeset/jetbrains-session-icons.md | 5 ++++ .../client/session/views/ReasoningView.kt | 3 +-- .../client/session/views/SessionViewIcons.kt | 25 +++++++++++++++++++ .../views/base/AbstractSessionPartView.kt | 4 +-- .../views/permission/PermissionView.kt | 4 +-- .../views/question/QuestionResultView.kt | 6 ++--- .../session/views/question/QuestionView.kt | 6 ++--- .../session/views/todo/TodoWriteView.kt | 4 +-- .../session/views/tool/SearchToolView.kt | 4 +-- .../client/session/views/tool/ToolSupport.kt | 22 +++++++++------- .../src/main/resources/icons/views/brain.svg | 3 +++ .../main/resources/icons/views/brain_dark.svg | 3 +++ .../main/resources/icons/views/bubble-5.svg | 3 +++ .../resources/icons/views/bubble-5_dark.svg | 3 +++ .../resources/icons/views/bullet-list.svg | 3 +++ .../icons/views/bullet-list_dark.svg | 3 +++ .../main/resources/icons/views/checklist.svg | 3 +++ .../resources/icons/views/checklist_dark.svg | 3 +++ .../resources/icons/views/chevron-down.svg | 3 +++ .../icons/views/chevron-down_dark.svg | 3 +++ .../resources/icons/views/chevron-left.svg | 3 +++ .../icons/views/chevron-left_dark.svg | 3 +++ .../resources/icons/views/chevron-right.svg | 3 +++ .../icons/views/chevron-right_dark.svg | 3 +++ .../main/resources/icons/views/code-lines.svg | 3 +++ .../resources/icons/views/code-lines_dark.svg | 3 +++ .../src/main/resources/icons/views/code.svg | 3 +++ .../main/resources/icons/views/code_dark.svg | 3 +++ .../main/resources/icons/views/console.svg | 3 +++ .../resources/icons/views/console_dark.svg | 3 +++ .../src/main/resources/icons/views/eye.svg | 4 +++ .../main/resources/icons/views/eye_dark.svg | 4 +++ .../main/resources/icons/views/glasses.svg | 3 +++ .../resources/icons/views/glasses_dark.svg | 3 +++ .../icons/views/magnifying-glass-menu.svg | 3 +++ .../views/magnifying-glass-menu_dark.svg | 3 +++ .../src/main/resources/icons/views/mcp.svg | 7 ++++++ .../main/resources/icons/views/mcp_dark.svg | 7 ++++++ .../src/main/resources/icons/views/task.svg | 3 +++ .../main/resources/icons/views/task_dark.svg | 3 +++ .../main/resources/icons/views/warning.svg | 3 +++ .../resources/icons/views/warning_dark.svg | 3 +++ .../resources/icons/views/window-cursor.svg | 4 +++ .../icons/views/window-cursor_dark.svg | 4 +++ 44 files changed, 172 insertions(+), 25 deletions(-) create mode 100644 .changeset/jetbrains-session-icons.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain_dark.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bubble-5.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bubble-5_dark.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bullet-list.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bullet-list_dark.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/checklist.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/checklist_dark.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-down.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-down_dark.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-left.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-left_dark.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-right.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-right_dark.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code-lines.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code-lines_dark.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code_dark.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/console.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/console_dark.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/eye.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/eye_dark.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/glasses.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/glasses_dark.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/magnifying-glass-menu.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/magnifying-glass-menu_dark.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/mcp.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/mcp_dark.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/task.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/task_dark.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/warning.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/warning_dark.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/window-cursor.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/views/window-cursor_dark.svg diff --git a/.changeset/jetbrains-session-icons.md b/.changeset/jetbrains-session-icons.md new file mode 100644 index 00000000000..b8e4c77dcb0 --- /dev/null +++ b/.changeset/jetbrains-session-icons.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Use matching VS Code-style icons for JetBrains session views. 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 01b9e4a1e83..7b02ee3a77b 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 @@ -12,7 +12,6 @@ import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.md.MdView import ai.kilocode.client.ui.md.MdViewFactory -import com.intellij.icons.AllIcons import com.intellij.openapi.util.Disposer import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane @@ -283,7 +282,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(AllIcons.General.InspectionsEye).apply { foreground = UiStyle.Colors.weak() } + val icon = JBLabel(SessionViewIcons.eye).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 new file mode 100644 index 00000000000..c59f5d371fb --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt @@ -0,0 +1,25 @@ +package ai.kilocode.client.session.views + +import com.intellij.openapi.util.IconLoader + +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 chevronLeft = icon("chevron-left") + val chevronRight = icon("chevron-right") + val code = icon("code") + val codeLines = icon("code-lines") + val console = icon("console") + val eye = icon("eye") + val glasses = icon("glasses") + val mcp = icon("mcp") + val search = icon("magnifying-glass-menu") + val task = icon("task") + val warning = icon("warning") + val windowCursor = icon("window-cursor") + + private fun icon(name: String) = IconLoader.getIcon("/icons/views/$name.svg", SessionViewIcons::class.java) +} 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 fc63492f38e..ac93b08e834 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 @@ -1,7 +1,7 @@ package ai.kilocode.client.session.views.base import ai.kilocode.client.session.ui.style.SessionUiStyle -import com.intellij.icons.AllIcons +import ai.kilocode.client.session.views.SessionViewIcons import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI import java.awt.BorderLayout @@ -157,7 +157,7 @@ abstract class AbstractSessionPartView( } private fun syncArrow(): Boolean { - val icon = if (isExpanded()) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight + val icon = if (isExpanded()) SessionViewIcons.chevronDown else SessionViewIcons.chevronRight 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/permission/PermissionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt index c6a698304f1..e8f52e97dec 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt @@ -10,13 +10,13 @@ import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.HAlign import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.VAlign import ai.kilocode.client.ui.layout.align import ai.kilocode.rpc.dto.PermissionReplyDto -import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.ui.ColorUtil @@ -67,7 +67,7 @@ class PermissionView( isOpaque = false isVisible = false - card.setHeaderIcon(AllIcons.General.Warning, KiloBundle.message("session.permission.title")) + card.setHeaderIcon(SessionViewIcons.warning, KiloBundle.message("session.permission.title")) card.setContent(body) card.setActions(listOf( BaseQuestionView.Action(ID_DENY, KiloBundle.message("session.permission.deny"), primary = false) { decide("reject") }, 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 a2d21018c20..f1c241141f8 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 @@ -6,10 +6,10 @@ 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.SessionViewIcons import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.ui.UiStyle -import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.ui.components.JBLabel @@ -56,7 +56,7 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = ) } } - private val glyph = JBLabel(AllIcons.General.Balloon) + private val glyph = JBLabel(SessionViewIcons.bubble) private val title = JBLabel() private val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } private val arrow = JBLabel() @@ -285,7 +285,7 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = } private fun syncArrow() { - arrow.icon = if (isExpanded()) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight + arrow.icon = if (isExpanded()) SessionViewIcons.chevronDown else SessionViewIcons.chevronRight } override fun setHovered(value: Boolean) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt index 63204e3583e..42c61bdb25d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption import ai.kilocode.client.session.ui.SessionView import ai.kilocode.client.session.ui.editor.SessionEditorTextField +import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.session.views.base.BaseQuestionView import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle @@ -13,7 +14,6 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.QuestionReplyDto -import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.project.Project @@ -79,14 +79,14 @@ class QuestionView( layout = BoxLayout(this, BoxLayout.X_AXIS) } private val back = HoverIcon().apply { - val ico = AllIcons.Actions.Back + val ico = SessionViewIcons.chevronLeft icon = ico disabledIcon = IconLoader.getDisabledIcon(ico) toolTipText = KiloBundle.message("session.question.back") addActionListener { goBack() } } private val fwd = HoverIcon().apply { - val ico = AllIcons.Actions.Forward + val ico = SessionViewIcons.chevronRight icon = ico disabledIcon = IconLoader.getDisabledIcon(ico) toolTipText = KiloBundle.message("session.question.next") 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 64ab4308327..a2dcc25d158 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 @@ -6,9 +6,9 @@ import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.session.views.base.PrimarySessionPartView import ai.kilocode.client.ui.UiStyle -import com.intellij.icons.AllIcons import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI import java.awt.BorderLayout @@ -98,7 +98,7 @@ class TodoParts( ) private fun todoParts(): TodoParts { - val glyph = JBLabel(AllIcons.Actions.Checked) + 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 { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/SearchToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/SearchToolView.kt index f8378f90319..83084495e6b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/SearchToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/SearchToolView.kt @@ -3,7 +3,7 @@ package ai.kilocode.client.session.views.tool import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.ui.selection.SessionSelection -import com.intellij.icons.AllIcons +import ai.kilocode.client.session.views.SessionViewIcons /** Renders grep/content-search calls with stacked, clipped search targets. */ class SearchToolView( @@ -17,7 +17,7 @@ class SearchToolView( fun canRender(tool: Tool): Boolean = tool.name == "grep" } - override fun toolIcon(tool: Tool) = AllIcons.Actions.Search + override fun toolIcon(tool: Tool) = SessionViewIcons.search override fun toolTitle(tool: Tool) = KiloBundle.message("session.part.tool.search") override fun targets(tool: Tool, repo: String?) = searchTargets(tool, repo) override fun viewName() = "SearchToolView" 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 859590d2b31..c8ce370eb5c 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 @@ -8,13 +8,13 @@ import ai.kilocode.client.session.model.ToolExecState 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.SessionViewIcons import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.HAlign import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.VAlign import ai.kilocode.client.ui.layout.align import ai.kilocode.log.KiloLog -import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.fileTypes.PlainTextFileType @@ -356,14 +356,18 @@ internal fun searchParts(count: Int): ToolParts { } internal fun icon(tool: Tool) = when (tool.name) { - "read" -> AllIcons.Actions.Preview - "bash" -> AllIcons.Debugger.Console - else -> when (tool.state) { - ToolExecState.PENDING -> AllIcons.Process.Step_1 - ToolExecState.RUNNING -> AllIcons.Process.Step_2 - ToolExecState.COMPLETED -> AllIcons.Actions.Checked - ToolExecState.ERROR -> AllIcons.General.Error - } + "read" -> SessionViewIcons.glasses + "list" -> SessionViewIcons.bulletList + "glob", "grep" -> SessionViewIcons.search + "webfetch", "websearch" -> SessionViewIcons.windowCursor + "codesearch" -> SessionViewIcons.code + "task" -> SessionViewIcons.task + "bash" -> SessionViewIcons.console + "edit", "write", "apply_patch" -> SessionViewIcons.codeLines + "todowrite", "todoread" -> SessionViewIcons.checklist + "question" -> SessionViewIcons.bubble + "skill" -> SessionViewIcons.brain + else -> SessionViewIcons.mcp } internal fun title(tool: Tool) = when (tool.name) { diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain.svg new file mode 100644 index 00000000000..304a93ea9c5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain_dark.svg new file mode 100644 index 00000000000..894515662bf --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bubble-5.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bubble-5.svg new file mode 100644 index 00000000000..b76605a472d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bubble-5.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bubble-5_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bubble-5_dark.svg new file mode 100644 index 00000000000..95762858b59 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bubble-5_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bullet-list.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bullet-list.svg new file mode 100644 index 00000000000..c15ecbd0e45 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bullet-list.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bullet-list_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bullet-list_dark.svg new file mode 100644 index 00000000000..bcce4c17aec --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bullet-list_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/checklist.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/checklist.svg new file mode 100644 index 00000000000..11b4cac00b4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/checklist.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/checklist_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/checklist_dark.svg new file mode 100644 index 00000000000..3997b5ec2ef --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/checklist_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-down.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-down.svg new file mode 100644 index 00000000000..b916dec2a8e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-down_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-down_dark.svg new file mode 100644 index 00000000000..9d727eb37c2 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-down_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-left.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-left.svg new file mode 100644 index 00000000000..57c07f8de87 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-left_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-left_dark.svg new file mode 100644 index 00000000000..0db8b8f5c86 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-left_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-right.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-right.svg new file mode 100644 index 00000000000..c4f66e5533a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-right_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-right_dark.svg new file mode 100644 index 00000000000..929005b5aaf --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-right_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code-lines.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code-lines.svg new file mode 100644 index 00000000000..456560f6327 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code-lines.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code-lines_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code-lines_dark.svg new file mode 100644 index 00000000000..947b66ac25c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code-lines_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code.svg new file mode 100644 index 00000000000..554b8656b03 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code_dark.svg new file mode 100644 index 00000000000..ddfe6242464 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/console.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/console.svg new file mode 100644 index 00000000000..5a27e30208e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/console.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/console_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/console_dark.svg new file mode 100644 index 00000000000..44f84beaa6d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/console_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/eye.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/eye.svg new file mode 100644 index 00000000000..45d7231e5e7 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/eye_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/eye_dark.svg new file mode 100644 index 00000000000..14983a084f4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/eye_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/glasses.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/glasses.svg new file mode 100644 index 00000000000..1f891fb11d4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/glasses.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/glasses_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/glasses_dark.svg new file mode 100644 index 00000000000..769102d69ee --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/glasses_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/magnifying-glass-menu.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/magnifying-glass-menu.svg new file mode 100644 index 00000000000..3ab3bf728cd --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/magnifying-glass-menu.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/magnifying-glass-menu_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/magnifying-glass-menu_dark.svg new file mode 100644 index 00000000000..a1eff436992 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/magnifying-glass-menu_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/mcp.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/mcp.svg new file mode 100644 index 00000000000..cee92d0c0b2 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/mcp.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/mcp_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/mcp_dark.svg new file mode 100644 index 00000000000..4eb477f0e37 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/mcp_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/task.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/task.svg new file mode 100644 index 00000000000..17a37afba86 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/task.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/task_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/task_dark.svg new file mode 100644 index 00000000000..6eaae56d332 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/task_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/warning.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/warning.svg new file mode 100644 index 00000000000..802ac7d8554 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/warning.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/warning_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/warning_dark.svg new file mode 100644 index 00000000000..d9f9e992c55 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/warning_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/window-cursor.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/window-cursor.svg new file mode 100644 index 00000000000..b26bac3f715 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/window-cursor.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/window-cursor_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/window-cursor_dark.svg new file mode 100644 index 00000000000..80ac013dd3a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/window-cursor_dark.svg @@ -0,0 +1,4 @@ + + + + From bbe6f25e21f5c322f14ddb5916872961d53f2033 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 9 Jun 2026 10:49:47 -0400 Subject: [PATCH 22/26] fix(jetbrains): bound streaming reasoning resources --- .changeset/fix-jetbrains-reasoning.md | 2 +- .gitignore | 1 + .kilo/plans/1780947943088-nimble-squid.md | 104 ------------- .kilo/plans/1780950338277-hidden-orchid.md | 139 ------------------ .../client/session/views/MessageView.kt | 3 + .../client/session/views/ReasoningView.kt | 47 +++++- .../session/views/tool/BaseSearchToolView.kt | 24 +++ .../client/session/views/tool/ReadToolView.kt | 23 +++ .../client/session/views/tool/ToolSupport.kt | 48 ++++-- .../client/session/views/tool/ToolView.kt | 25 ++++ .../session/views/ReasoningViewStressTest.kt | 63 ++++++++ .../client/session/views/ReasoningViewTest.kt | 51 +++++++ .../session/views/ToolBodyStressTest.kt | 33 +++++ .../client/session/views/TurnViewTest.kt | 33 +++++ 14 files changed, 338 insertions(+), 258 deletions(-) delete mode 100644 .kilo/plans/1780947943088-nimble-squid.md delete mode 100644 .kilo/plans/1780950338277-hidden-orchid.md create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewStressTest.kt diff --git a/.changeset/fix-jetbrains-reasoning.md b/.changeset/fix-jetbrains-reasoning.md index 5356a2175c8..22e7c73ec3e 100644 --- a/.changeset/fix-jetbrains-reasoning.md +++ b/.changeset/fix-jetbrains-reasoning.md @@ -2,4 +2,4 @@ "kilo-code": patch --- -Improve JetBrains reasoning blocks so active reasoning opens while streaming, empty blocks stay hidden, and adjacent reasoning renders as one block. +Improve JetBrains reasoning blocks so active reasoning opens while streaming, completed reasoning collapses automatically, empty blocks stay hidden, and adjacent reasoning renders as one block. diff --git a/.gitignore b/.gitignore index 5611c39a766..74c51f4cea8 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ tsconfig.tsbuildinfo .kilo/bun.lock .kilo/yarn.lock .kilo/node_modules +.kilo/plans/ .kilo/plans/*upstream-merge-report-*.md .kilocode/.gitignore .kilocode/package.json diff --git a/.kilo/plans/1780947943088-nimble-squid.md b/.kilo/plans/1780947943088-nimble-squid.md deleted file mode 100644 index 190296d5a3f..00000000000 --- a/.kilo/plans/1780947943088-nimble-squid.md +++ /dev/null @@ -1,104 +0,0 @@ -# Plan: Repo-Relative Search Tool Paths - -## Goal - -Update JetBrains search-style tool headers so the path target is displayed relative to the current repo/workspace directory: - -- If the path is inside the repo, show the relative path. -- If the path resolves to the repo root (`.` or the repo directory), hide the path target entirely. -- If the path is outside the repo, show the full normalized path. -- Apply this to all current search-style tools: `glob` and `grep`/Search. -- Use IntelliJ path utilities instead of handwritten string prefix/splitting logic. - -## Findings - -- Search-style target rendering is centralized in `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt`: - - `globDirectory(tool)` currently returns raw `tool.input["path"]` or `tool.title`. - - `searchTargets(tool)` currently returns raw `tool.input["path"]`, then `pattern=...`, then `include=...`. -- `GlobToolView` and `SearchToolView` both extend `BaseSearchToolView`, which calls `targets(item)` during base initialization. -- `SessionUi` has the repo/workspace directory as `workspace.directory`, but the view creation path currently does not pass it to tool views. -- In JetBrains split mode, frontend and backend may not share OS path semantics, so local `java.nio.file.Path` interpretation alone can be risky for backend-originated paths. -- IntelliJ provides suitable path helpers in `com.intellij.openapi.util.io`: - - `FileUtil.toSystemIndependentName(...)` - - `FileUtil.toCanonicalPath(..., '/', true)` - - `FileUtil.join(...)` - - `FileUtil.getRelativePath(base, file, '/')` - - `OSAgnosticPathUtil.isAbsolute(...)` - - `OSAgnosticPathUtil.startsWith(...)` -- These helpers are string/path-text utilities and do not require VFS refreshes, file existence checks, RPC, git queries, or network calls. - -## EDT Safety Requirements - -- All search header synchronization runs on the EDT, so path display formatting must be deterministic, local, and non-blocking. -- Do not resolve the repo root during rendering. Use the already-known `Workspace.directory` captured by `SessionUi` and pass it down as an immutable string. -- Do not call any API that can touch disk, VFS, git, RPC, backend services, or the network from `BaseSearchToolView.sync()` / `targets(...)` / the path formatter. -- Specifically avoid `Files.exists`, `Path.toRealPath`, `File.getCanonicalPath`, `LocalFileSystem.refreshAndFindFileByPath`, `VfsUtil`, `Project.baseDir`, `GitRepositoryManager`, `KiloWorkspaceService`, and any coroutine/RPC call in this path. -- The formatter should only use pure string helpers such as `FileUtil.toSystemIndependentName`, `FileUtil.toCanonicalPath(path, '/', true)`, `FileUtil.getRelativePath(base, file, '/')`, and `OSAgnosticPathUtil` checks. -- The amount of work is bounded to a few short path strings per render/update, so no background dispatch or caching is needed unless implementation profiling later shows otherwise. - -## Implementation Steps - -1. Add an internal search path formatter in `ToolSupport.kt`. - - Keep it pure and UI-independent, for example `internal fun searchPath(path: String, repo: String?): String`. - - Normalize both `path` and `repo` with `FileUtil.toSystemIndependentName` and `FileUtil.toCanonicalPath(..., '/', true)`. - - Use `OSAgnosticPathUtil.isAbsolute(...)` to decide whether the tool path is absolute. - - Resolve relative tool paths against the normalized repo with `FileUtil.join(...)`, then canonicalize. - - If no repo is available, keep the existing raw display behavior except hide `.`. - - If the resolved target equals the repo root, return `""` so the target row is hidden. - - If the resolved target is under the repo root, return `FileUtil.getRelativePath(repo, target, '/')`. - - If the resolved target is outside the repo, return the full normalized target path. - - Avoid manual prefix checks, separator splitting, or homemade `../` handling. - - Keep this formatter string-only: no `java.nio.file.Files`, VFS, git, project model, service, or RPC access. - -2. Update target helper functions in `ToolSupport.kt`. - - Change `globDirectory(tool)` to accept `repo: String?` and format the path/title through `searchPath(...)`. - - Change `searchTargets(tool)` to accept `repo: String?` and format only the `path` element through `searchPath(...)`. - - Keep `pattern=...` and `include=...` unchanged. - - Filter blank formatted paths so root paths disappear and pattern/include shift left as they do today when path is absent. - -3. Pass repo context into search tool views. - - Add `repo: String? = null` to `BaseSearchToolView` and store it in the base class. - - Change the abstract target hook to use the base-owned repo, e.g. `targets(tool: Tool, repo: String?): List`. - - This avoids accessing subclass properties from `BaseSearchToolView.init`, which already calls `sync()`. - - Add optional `repo` constructor parameters to `GlobToolView` and `SearchToolView` and pass them to `BaseSearchToolView`. - - Preserve existing defaults so direct tests and call sites that do not know a repo still compile. - - Store the repo string as provided; do not lazily resolve or refresh it from IntelliJ project state inside the view. - -4. Propagate `workspace.directory` through the session view creation path. - - Add optional `repo: String? = null` parameters through: - - `SessionMessageListPanel` - - `TurnView` - - `MessageView` - - `ViewFactory.create(...)` - - `ViewFactory.createUser(...)` - - In `SessionUi.buildUi()`, pass `repo = workspace.directory` when constructing `SessionMessageListPanel`. - - In `ViewFactory`, pass the repo only to `GlobToolView` and `SearchToolView`; other tool views keep current behavior. - -5. Update tests. - - In `GlobToolViewTest`, add coverage for: - - Absolute path inside repo displays as relative, e.g. `src`. - - `.` and exact repo root hide the path row. - - Absolute path outside repo stays full/normalized. - - In `SearchToolViewTest`, add the same repo-relative/root/outside cases for the `path` target while keeping `pattern` and `include` rows unchanged. - - Keep existing no-repo tests to prove fallback behavior remains stable. - - Update any existing expectations that intentionally pass a repo. - - Prefer portable test paths built from simple normalized roots; do not depend on files actually existing. - -6. Add a patch changeset. - - This is user-visible JetBrains UI behavior. - - Add a `.changeset/.md` entry for `"kilo-code": patch`. - - Suggested wording: `Display JetBrains search tool paths relative to the current repository when possible.` - -## Verification - -Run from `packages/kilo-jetbrains/`: - -1. `./gradlew :frontend:test --tests ai.kilocode.client.session.views.GlobToolViewTest --tests ai.kilocode.client.session.views.SearchToolViewTest` -2. `./gradlew typecheck` - -If constructor propagation causes broader frontend compile errors, fix those and rerun the same checks. - -## Notes - -- No `kilocode_change` markers are needed because this is under `packages/kilo-jetbrains/`, a Kilo-owned package. -- Keep the change scoped to search-style tool header paths. Do not alter read tool filename display or tool body output formatting unless requested separately. diff --git a/.kilo/plans/1780950338277-hidden-orchid.md b/.kilo/plans/1780950338277-hidden-orchid.md deleted file mode 100644 index 1c343f05def..00000000000 --- a/.kilo/plans/1780950338277-hidden-orchid.md +++ /dev/null @@ -1,139 +0,0 @@ -# Refactor SessionUiStyle View Tokens - -## Goal -Refactor `SessionUiStyle.View` so tokens are grouped by semantic meaning and call sites describe what they are styling. At the same time, simplify session borders so the style layer only defines two outline colors plus one border width: - -- Bright outline color: prompt/user prompt bubble, prompt input shell, and all question-style views. -- Regular outline color: reasoning and all other session card borders/separators by default. -- Border width: shared one-pixel outline width used by views when constructing their own borders. - -## Findings -- `SessionUiStyle.View` currently mixes transcript backgrounds, card layout constants, card surfaces, hover colors, outline colors, border factories, and nested component groups in one object. -- Current `View.line()` is a high-contrast editor-background-derived color and is used for both bright prompt/question borders and regular card/separator borders. -- Current `View.sessionViewOutline()` delegates to `UiStyle.Colors.contentBorder()`, which is the right softer/default outline color. -- Existing border helpers (`sessionView`, `outline`, `topOutline`, `leftOutline`) hide whether a call site wants bright or regular outline styling and mix color decisions with border-shape decisions. -- The main production call-site groups are: - - Transcript backgrounds: `SessionMessageListPanel`, `SessionScroll`, `TextView`. - - Card layout/surfaces/hover: base part views, tool/todo/question result views. - - Bright prompt/question borders: `PromptPanel.PromptShell`, `MessageView` user prompt bubble, `BaseQuestionView`, `QuestionResultView`. - - Default cards: `PrimarySessionPartView`, `SecondarySessionPartView`, `ReasoningView`, tool body panes, todo body panes. - - Popup/content panel styling: `SessionAccountOverlay`. - - Separators: `ConnectionPanel`, `Dock.banner()`, `CompactionView`. - -## Proposed API -Refactor `SessionUiStyle.kt` without keeping compatibility aliases for old messy names. - -```kotlin -object SessionUiStyle { - object Transcript { - fun bgColor(): Color - } - - object View { - object Layout { - const val GAP = 6 - const val VERTICAL_PADDING = 8 - const val HORIZONTAL_PADDING = 12 - const val BODY_EXTRA_HEIGHT = 16 - } - - object Surface { - fun bgColor(): Color - fun headerBgColor(): Color - fun headerHoverBgColor(): Color - } - - object Outline { - fun color(): Color - fun brightColor(): Color - fun hoverColor(): Color - fun width(): Int - } - - object Prompt { ... } - object Reasoning { ... } - object Message { ... } - object Code { ... } - object Permission { ... } - object Tool { ... } - } - - object AccountPopup { - fun bgColor(): Color - fun outlineColor(): Color - } -} -``` - -Concrete color mapping: -- `View.Outline.color()` returns the softer regular outline, using `UiStyle.Colors.contentBorder()`. -- `View.Outline.brightColor()` preserves the current bright `line()` behavior, using `UiStyle.Colors.contrast(UiStyle.Colors.editorBackground(), BORDER_DELTA)`. -- `View.Outline.hoverColor()` preserves the current hover outline calculation for `Surface.headerHoverBgColor()`. -- `View.Outline.width()` returns `JBUI.scale(1)`. -- `View.Surface.bgColor()` and `View.Surface.headerBgColor()` both use `UiStyle.Colors.editorBackground()`. -- `View.Surface.headerHoverBgColor()` preserves the current `headerHover()` behavior. -- `SessionUiStyle.Transcript.bgColor()` replaces `View.transcript()`. -- `SessionUiStyle.AccountPopup.bgColor()` replaces `View.sessionViewBackground()`. -- `SessionUiStyle.AccountPopup.outlineColor()` replaces `View.sessionViewOutline()`. - -Border construction rule: -- `SessionUiStyle` does not expose all-side/top/left border factories. -- Views construct borders locally from `View.Outline.color()` or `View.Outline.brightColor()` plus `View.Outline.width()` according to their layout. -- Examples: all-side cards use `JBUI.Borders.customLine(color, width)`, body separators use `JBUI.Borders.customLine(color, width, 0, 0, 0)`, reasoning uses `JBUI.Borders.customLine(color, 0, width, 0, 0)`, and rounded prompt/question shells paint using the same color and width. - -## Implementation Plan -1. Update `SessionUiStyle.kt`. - - Add `Transcript`, `View.Layout`, `View.Surface`, `View.Outline`, and `AccountPopup` groups. - - Move current `SESSION_VIEW_*` constants into `View.Layout`. - - Move current `surface`, `header`, and `headerHover` into `View.Surface` with `*BgColor` names. - - Replace `line` and `hoverLine` with the `View.Outline` color/width API above. - - Remove `sessionView`, `outline`, `topOutline`, and `leftOutline` instead of replacing them with new border factory helpers. - - Remove old methods/constants after call sites are migrated. - -2. Update regular session-card call sites to softer outlines. - - `PrimarySessionPartView.syncBorder()` constructs an all-side border from `SessionUiStyle.View.Outline.color()` and `width()`. - - `SecondarySessionPartView.syncBorder()` constructs an all-side border from `SessionUiStyle.View.Outline.color()` and `width()`. - - `ReasoningView.syncBorder()` constructs a left-only border from `SessionUiStyle.View.Outline.color()` and `width()`. - - Tool/todo body separators construct top-only borders from `SessionUiStyle.View.Outline.color()` and `width()`. - - Connection, dock banner, and compaction separators use `SessionUiStyle.View.Outline.color()` and `width()` as appropriate. - -3. Update bright prompt/question call sites. - - `PromptPanel.PromptShell.outlineColor()` -> `SessionUiStyle.View.Outline.brightColor()` when not focused. - - `MessageView.paintComponent()` user prompt bubble outline -> `brightColor()`. - - `BaseQuestionView.outlineColor()` -> `brightColor()`. - - `QuestionResultView.syncBorder()` constructs an all-side border from `brightColor()` and `width()` when expanded. - - `QuestionResultView` body separator constructs a top-only border from `brightColor()` and `width()`. - -4. Update semantic surface/layout call sites. - - Transcript backgrounds -> `SessionUiStyle.Transcript.bgColor()`. - - Card backgrounds -> `SessionUiStyle.View.Surface.bgColor()`. - - Header backgrounds -> `SessionUiStyle.View.Surface.headerBgColor()`. - - Hover header backgrounds -> `SessionUiStyle.View.Surface.headerHoverBgColor()`. - - Layout constants -> `SessionUiStyle.View.Layout.*`. - - Account popup background/border test helper -> `SessionUiStyle.AccountPopup.*`. - -5. Update tests. - - Replace direct assertions against old names with semantic new names. - - Add or adjust assertions so prompt/question borders use `View.Outline.brightColor()`. - - Add or adjust assertions so reasoning/tool/regular expanded card borders use `View.Outline.color()`. - - Keep hover tests asserting only header background changes, using `View.Surface.headerHoverBgColor()` and `headerBgColor()`. - - Finish the pending `QuestionResultViewTest` all-side border assertions and make them check the bright outline. - -6. Changeset. - - Add a patch changeset for `@kilocode/kilo-jetbrains` because the visible JetBrains session border contrast changes. - - Suggested release note: `Refine JetBrains session card borders so prompt and question surfaces use brighter outlines while reasoning and tool cards use softer default borders.` - -## Verification -Run from `packages/kilo-jetbrains/`: - -```sh -./gradlew :frontend:test --tests ai.kilocode.client.session.views.base.AbstractSessionPartViewTest --tests ai.kilocode.client.session.views.QuestionResultViewTest --tests ai.kilocode.client.session.ui.SessionMessageListPanelTest --tests ai.kilocode.client.session.views.ToolViewTest --tests ai.kilocode.client.session.views.base.BaseQuestionViewTest --tests ai.kilocode.client.session.views.question.QuestionViewTest --tests ai.kilocode.client.session.views.permission.PermissionViewTest --tests ai.kilocode.client.session.ui.account.SessionAccountOverlayTest -./gradlew typecheck -``` - -## Constraints -- This task touches only `packages/kilo-jetbrains/` and a changeset. -- Preserve unrelated worktree changes and do not revert user/agent changes from the previous hover/border work. -- Keep Swing UI mutations on the EDT. -- Do not introduce Compose, JCEF, Kotlin UI DSL, services, RPC, or broad UI rewrites. -- Prefer small, mechanical call-site updates over deeper component refactors. 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 43d81313861..d0b5e06b445 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 @@ -53,6 +53,9 @@ class MessageView( get() = if (role == SessionUiStyle.View.Message.USER_ROLE) SessionView.Kind.UserPrompt else SessionView.Kind.Default private val parts = LinkedHashMap() + // Adjacent reasoning parts render through the first ReasoningView. aliases maps each + // merged child id to that owner id, and sources stores the child's latest full text + // so snapshot updates can append only deltas. private val aliases = LinkedHashMap() private val sources = LinkedHashMap() private var hidden: ToolCallRef? = null 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 7b02ee3a77b..b87986f8fd1 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 @@ -15,6 +15,7 @@ import ai.kilocode.client.ui.md.MdViewFactory import com.intellij.openapi.util.Disposer import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane +import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI import java.awt.BorderLayout import java.awt.Dimension @@ -40,7 +41,9 @@ class ReasoningView( override val contentId: String = reasoning.id + /** Lazily creates, registers, populates, and styles the editor-backed body on first access. */ val md: MdView + @RequiresEdt get() { val fresh = !parts.bodyCreated() val view = parts.md(openUrl) @@ -56,6 +59,7 @@ class ReasoningView( private var source = reasoning.content.toString() private var done = reasoning.done private var registered = false + private var following = false init { row.border = JBUI.Borders.empty( @@ -69,6 +73,7 @@ class ReasoningView( sync() } + @RequiresEdt override fun expand(): Boolean { val changed = super.expand() if (!changed) return false @@ -78,6 +83,7 @@ class ReasoningView( return true } + @RequiresEdt override fun collapse(): Boolean { val changed = super.collapse() if (!changed) return false @@ -85,10 +91,13 @@ class ReasoningView( return true } + @RequiresEdt override fun update(content: Content) { if (content !is Reasoning) return var changed = false val next = content.content.toString() + val finished = !done && content.done + val follow = tailVisible() if (done != content.done) { done = content.done changed = true @@ -97,36 +106,50 @@ class ReasoningView( source = next if (parts.bodyCreated()) { md.set(source) - followTail() + followTail(follow) } changed = true } + if (finished) changed = collapse() || changed changed = sync() || changed if (changed) refresh() } + @RequiresEdt override fun appendDelta(delta: String) { if (delta.isEmpty()) return + val follow = tailVisible() source += delta if (parts.bodyCreated()) { md.append(delta) - followTail() + followTail(follow) } val changed = sync() if (changed || bodyVisible()) refresh() } + @RequiresEdt fun markdown(): String = source + @RequiresEdt fun hasToggle(): Boolean = arrow.isVisible + @RequiresEdt fun headerText(): String = parts.title.text + @RequiresEdt internal fun headerFont() = parts.title.font + @RequiresEdt internal fun bodyVisible() = parts.scrollOrNull?.parent === this + @RequiresEdt internal fun horizontalPolicy() = parts.scrollOrNull?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + @RequiresEdt internal fun bodyMaxRows() = SessionUiStyle.View.Reasoning.BODY_LINES + @RequiresEdt internal fun bodyCreated() = parts.bodyCreated() + @RequiresEdt internal fun bodyScrollValue() = parts.scrollOrNull?.verticalScrollBar?.value ?: 0 + @RequiresEdt internal fun bodyScrollBottom() = parts.scrollOrNull?.verticalScrollBar?.let { it.maximum - it.visibleAmount } ?: 0 + @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { this.style = style var changed = false @@ -138,6 +161,7 @@ class ReasoningView( if (changed) refresh() } + @RequiresEdt override fun getPreferredSize(): Dimension { val size = super.getPreferredSize() if (!bodyVisible()) return size @@ -188,11 +212,12 @@ class ReasoningView( return changed } + @RequiresEdt private fun syncBody() { val md = md registerBody(md) md.set(source) - followTail() + followTail(true) } private fun applyBodyStyle(): Boolean { @@ -216,10 +241,22 @@ class ReasoningView( JBUI.scale(SessionUiStyle.View.Layout.BODY_EXTRA_HEIGHT) } - private fun followTail() { - if (!bodyVisible()) return + @RequiresEdt + private fun tailVisible(): Boolean { + if (!bodyVisible()) return false + val scroll = parts.scrollOrNull ?: return false + val bar = scroll.verticalScrollBar + return bar.value >= bar.maximum - bar.visibleAmount + } + + @RequiresEdt + private fun followTail(follow: Boolean) { + if (!follow || !bodyVisible() || following) return val scroll = parts.scrollOrNull ?: return + following = true SwingUtilities.invokeLater { + following = false + if (!bodyVisible()) return@invokeLater val bar = scroll.verticalScrollBar bar.value = bar.maximum - bar.visibleAmount } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt index cde49ceb5e6..24d36cff643 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt @@ -9,6 +9,7 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.ui.UiStyle import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI import java.awt.Dimension import javax.swing.Icon @@ -39,6 +40,7 @@ abstract class BaseSearchToolView( sync() } + @RequiresEdt override fun expand(): Boolean { val changed = super.expand() if (!changed) return false @@ -47,6 +49,7 @@ abstract class BaseSearchToolView( return true } + @RequiresEdt override fun getPreferredSize(): Dimension { val size = super.getPreferredSize() if (!bodyVisible()) return size @@ -54,6 +57,7 @@ abstract class BaseSearchToolView( return Dimension(size.width, minOf(size.height, height)) } + @RequiresEdt override fun update(content: Content) { if (content !is Tool) return item = content @@ -62,29 +66,49 @@ abstract class BaseSearchToolView( if (changed) refresh() } + @RequiresEdt fun labelText(): String = listOf(parts.title.text).plus(targetTexts()).plus(parts.state.text) .filter { it.isNotBlank() } .joinToString(" ") + @RequiresEdt fun bodyText(): String = body(item) + @RequiresEdt internal fun targetTexts(): List = parts.targets.map { it.text }.filter { it.isNotBlank() } + @RequiresEdt internal fun targetVisible(index: Int): Boolean = parts.targets.getOrNull(index)?.isVisible ?: false + @RequiresEdt internal fun bodyVisible() = parts.scroll?.parent === this + @RequiresEdt internal fun hasToggle() = arrow.isVisible + @RequiresEdt internal fun bodyFont() = parts.content?.font ?: style.editorFont + @RequiresEdt internal fun titleFont() = parts.title.font + @RequiresEdt internal fun targetFont(index: Int) = parts.targets.getOrNull(index)?.font ?: style.regularFont + @RequiresEdt internal fun stateFont() = parts.state.font + @RequiresEdt internal fun bodyCreated() = parts.bodyCreated() + @RequiresEdt internal fun scrollComponent() = parts.scroll + @RequiresEdt internal fun bodyEditor() = parts.content?.editor + @RequiresEdt internal fun horizontalPolicy() = parts.scroll?.horizontalScrollBarPolicy + @RequiresEdt internal fun verticalPolicy() = parts.scroll?.verticalScrollBarPolicy + @RequiresEdt internal fun bodyWrap() = parts.content?.lineWrap ?: false + @RequiresEdt internal fun headerComponent() = parts.header + @RequiresEdt internal fun centerComponent() = parts.center + @RequiresEdt internal fun targetComponents() = parts.targets + @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { this.style = style var changed = false diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt index c067bbcfcfe..d2c2a623ead 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt @@ -9,6 +9,7 @@ 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 com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI import java.awt.Dimension import javax.swing.ScrollPaneConstants @@ -38,6 +39,7 @@ class ReadToolView( sync() } + @RequiresEdt override fun getPreferredSize(): Dimension { val size = super.getPreferredSize() if (!bodyVisible()) return size @@ -45,6 +47,7 @@ class ReadToolView( return Dimension(size.width, minOf(size.height, height)) } + @RequiresEdt override fun update(content: Content) { if (content !is Tool) return item = content @@ -53,28 +56,48 @@ class ReadToolView( if (changed) refresh() } + @RequiresEdt fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text) .filter { it.isNotBlank() } .joinToString(" ") + @RequiresEdt fun bodyText(): String = body(item) + @RequiresEdt internal fun bodyVisible() = parts.scroll?.parent === this + @RequiresEdt internal fun hasToggle() = arrow.isVisible + @RequiresEdt internal fun horizontalPolicy() = parts.scroll?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + @RequiresEdt internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES + @RequiresEdt internal fun bodyFont() = parts.text?.font ?: style.transcriptFont + @RequiresEdt internal fun bodyCreated() = parts.bodyCreated() + @RequiresEdt internal fun bodyWrap() = parts.text?.lineWrap ?: false + @RequiresEdt internal fun bodyEditor() = parts.content?.editor + @RequiresEdt internal fun linkVisible() = parts.link.isVisible + @RequiresEdt internal fun linkText() = parts.label + @RequiresEdt internal fun linkMarkup() = parts.link.text ?: "" + @RequiresEdt internal fun linkForeground() = parts.link.foreground + @RequiresEdt internal fun linkFont() = parts.link.font + @RequiresEdt internal fun subtitleForeground() = parts.sub.foreground + @RequiresEdt internal fun subtitleFont() = parts.sub.font + @RequiresEdt internal fun linkHref() = parts.href + @RequiresEdt internal fun openLink() = parts.openLink() + @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { this.style = style var changed = false 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 c8ce370eb5c..3e5c9aa7554 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 @@ -26,13 +26,14 @@ import com.intellij.ui.EditorTextField import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane import com.intellij.ui.components.JBTextArea +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBDimension import com.intellij.util.ui.JBUI import com.intellij.xml.util.XmlStringUtil import java.awt.BorderLayout import java.awt.CardLayout import java.awt.Color import java.awt.Cursor -import java.awt.Dimension import java.awt.Font import java.awt.event.MouseAdapter import java.awt.event.MouseEvent @@ -65,23 +66,30 @@ class ToolParts( private var body: ToolBody? = null val text: JBTextArea? + @RequiresEdt get() = body?.area val content: ToolBody? + @RequiresEdt get() = body val scroll: JBScrollPane? + @RequiresEdt get() = body?.scroll + @RequiresEdt fun scroll(tool: Tool): JBScrollPane = body(tool).scroll + @RequiresEdt fun bodyCreated() = body != null + @RequiresEdt fun openLink() { val value = href ?: return open?.invoke(value) } + @RequiresEdt private fun body(tool: Tool): ToolBody { val item = body if (item != null) return item @@ -100,7 +108,9 @@ class ToolBody private constructor( private val disposable: Disposable?, ) : Disposable { var text: String + @RequiresEdt get() = area?.text ?: ed?.text ?: "" + @RequiresEdt set(value) { if (text == value) return area?.text = value @@ -110,7 +120,9 @@ class ToolBody private constructor( } var font: Font + @RequiresEdt get() = area?.font ?: ed?.font ?: SessionEditorStyle.current().editorFont + @RequiresEdt set(value) { area?.font = value ed?.font = value @@ -118,7 +130,9 @@ class ToolBody private constructor( } var foreground: Color + @RequiresEdt get() = area?.foreground ?: ed?.foreground ?: UiStyle.Colors.fg() + @RequiresEdt set(value) { area?.foreground = value ed?.foreground = value @@ -129,11 +143,13 @@ class ToolBody private constructor( val lineWrap: Boolean get() = area?.lineWrap ?: false val editor: EditorTextField? get() = ed + @RequiresEdt fun caretStart() { area?.caretPosition = 0 ed?.getEditor(false)?.caretModel?.moveToOffset(0) } + @RequiresEdt fun applyStyle(style: SessionEditorStyle): Boolean { val before = font area?.font = style.transcriptFont @@ -143,6 +159,7 @@ class ToolBody private constructor( return before != font } + @RequiresEdt fun register(selection: SessionSelection, parent: Disposable) { val field = ed if (field != null) { @@ -152,6 +169,7 @@ class ToolBody private constructor( area?.let { selection.register(it, parent) } } + @RequiresEdt fun lineHeight(): Int = ed?.getEditor(false)?.lineHeight ?: scroll.viewport.view.getFontMetrics(font).height override fun dispose() { @@ -162,15 +180,15 @@ class ToolBody private constructor( val view = scroll.viewport.view as? JComponent ?: return val height = height(view) val width = width(view) - view.preferredSize = Dimension(width, height) - view.minimumSize = Dimension(0, height) - view.maximumSize = Dimension(Int.MAX_VALUE, height) + view.preferredSize = JBUI.size(width, height) + view.minimumSize = JBUI.size(0, height) + view.maximumSize = JBDimension(Int.MAX_VALUE, height) val inset = scroll.viewportBorder?.getBorderInsets(scroll) ?: JBUI.emptyInsets() val pane = height + scroll.insets.top + scroll.insets.bottom + inset.top + inset.bottom + scroll.horizontalScrollBar.preferredSize.height - scroll.preferredSize = Dimension(0, pane) - scroll.minimumSize = Dimension(0, pane) - scroll.maximumSize = Dimension(Int.MAX_VALUE, pane) + scroll.preferredSize = JBUI.size(0, pane) + scroll.minimumSize = JBUI.size(0, pane) + scroll.maximumSize = JBDimension(Int.MAX_VALUE, pane) } private fun width(view: JComponent): Int { @@ -186,6 +204,7 @@ class ToolBody private constructor( } companion object { + @RequiresEdt fun editor(tool: Tool): ToolBody { val disposable = Disposer.newDisposable("Tool body") val body = runCatching { @@ -200,6 +219,7 @@ class ToolBody private constructor( return body } + @RequiresEdt fun text(tool: Tool): ToolBody { val area = area(tool, true) val body = ToolBody(area, null, pane(area, false), null) @@ -275,6 +295,7 @@ private class ToolField(value: String, private var style: SessionEditorStyle) : private const val SUB_CARD = "sub" private const val LINK_CARD = "link" +@RequiresEdt internal fun toolParts( tool: Tool, openFile: ((String) -> Unit)? = null, @@ -318,6 +339,7 @@ internal fun toolParts( } } +@RequiresEdt internal fun searchParts(count: Int): ToolParts { val glyph = JBLabel() val title = JBLabel() @@ -325,7 +347,7 @@ internal fun searchParts(count: Int): ToolParts { val targets = List(count) { JBLabel().apply { foreground = UiStyle.Colors.fg() - minimumSize = Dimension(0, minimumSize.height) + minimumSize = JBUI.size(0, minimumSize.height) } } val link = JBLabel().apply { isVisible = false } @@ -339,7 +361,7 @@ internal fun searchParts(count: Int): ToolParts { val target = stack.align(HAlign.TRACK, VAlign.CENTER) val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { isOpaque = false - minimumSize = Dimension(0, minimumSize.height) + minimumSize = JBUI.size(0, minimumSize.height) add(title, BorderLayout.WEST) add(target, BorderLayout.CENTER) } @@ -382,6 +404,7 @@ internal fun subtitle(tool: Tool) = when (tool.name) { else -> toolSubtitle(tool) } +@RequiresEdt internal fun setText(label: JBLabel, text: String): Boolean { val value = if (text.isBlank()) "" else XmlStringUtil.wrapInHtml(XmlStringUtil.escapeString(text)) if (label.text == value) return false @@ -389,12 +412,14 @@ internal fun setText(label: JBLabel, text: String): Boolean { return true } +@RequiresEdt internal fun setTargetText(label: JBLabel, text: String): Boolean { if (label.text == text) return false label.text = text return true } +@RequiresEdt internal fun setLinkText(parts: ToolParts, text: String): Boolean { val value = if (text.isBlank()) "" else XmlStringUtil.wrapInHtml("${XmlStringUtil.escapeString(text)}") if (parts.label == text && parts.link.text == value) return false @@ -403,6 +428,7 @@ internal fun setLinkText(parts: ToolParts, text: String): Boolean { return true } +@RequiresEdt internal fun show(parts: ToolParts, link: Boolean): Boolean { if (parts.link.isVisible == link && parts.sub.isVisible != link) return false (parts.slot.layout as CardLayout).show(parts.slot, if (link) LINK_CARD else SUB_CARD) @@ -411,24 +437,28 @@ internal fun show(parts: ToolParts, link: Boolean): Boolean { internal fun subtitleText(parts: ToolParts): String = if (parts.link.isVisible) parts.label else parts.sub.text +@RequiresEdt internal fun setIcon(label: JBLabel, icon: Icon): Boolean { if (label.icon === icon) return false label.icon = icon return true } +@RequiresEdt internal fun setVisible(component: JComponent, visible: Boolean): Boolean { if (component.isVisible == visible) return false component.isVisible = visible return true } +@RequiresEdt internal fun setForeground(component: JComponent, color: Color): Boolean { if (same(component.foreground, color)) return false component.foreground = color return true } +@RequiresEdt internal fun setFont(component: JComponent, font: Font): Boolean { if (component.font == font) return false component.font = font diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt index 1c141f6aeac..97324bccd2b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt @@ -9,6 +9,7 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.ui.UiStyle import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI import java.awt.Dimension import javax.swing.ScrollPaneConstants @@ -33,6 +34,7 @@ class ToolView( sync() } + @RequiresEdt override fun expand(): Boolean { val changed = super.expand() if (!changed) return false @@ -41,6 +43,7 @@ class ToolView( return true } + @RequiresEdt override fun getPreferredSize(): Dimension { val size = super.getPreferredSize() if (!bodyVisible()) return size @@ -48,6 +51,7 @@ class ToolView( return Dimension(size.width, minOf(size.height, height)) } + @RequiresEdt override fun update(content: Content) { if (content !is Tool) return val was = item.name @@ -59,30 +63,51 @@ class ToolView( 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 = output(item) + @RequiresEdt fun bodyText(): String = body(item) + @RequiresEdt internal fun previewText(): String = parts.content?.text ?: preview(item) + @RequiresEdt fun hasToggle(): Boolean = arrow.isVisible + @RequiresEdt internal fun bodyFont() = parts.content?.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 bodyEditable() = parts.content?.editable ?: false + @RequiresEdt internal fun bodyCaretVisible() = parts.content?.caretVisible ?: false + @RequiresEdt internal fun bodyVisible() = parts.scroll?.parent === this + @RequiresEdt internal fun controlCount() = if (arrow.isVisible) 1 else 0 + @RequiresEdt internal fun horizontalPolicy() = parts.scroll?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + @RequiresEdt internal fun verticalPolicy() = parts.scroll?.verticalScrollBarPolicy ?: ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + @RequiresEdt internal fun bodyWrap() = parts.content?.lineWrap ?: false + @RequiresEdt internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES + @RequiresEdt internal fun bodyCreated() = parts.bodyCreated() + @RequiresEdt internal fun bodyEditor() = parts.content?.editor + @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { this.style = style var changed = false diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewStressTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewStressTest.kt new file mode 100644 index 00000000000..1574d682d92 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewStressTest.kt @@ -0,0 +1,63 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Reasoning +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.util.Disposer +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.UIUtil +import java.awt.Container +import javax.swing.JPanel + +@Suppress("UnstableApiUsage") +class ReasoningViewStressTest : BasePlatformTestCase() { + + fun `test streaming reasoning retains markdown body and disposes editors`() { + val base = EditorFactory.getInstance().allEditors.size + val view = ReasoningView(reasoning("r1", done = false, text = "intro\n\n```kotlin\n")) + val component = view.md.component + val scroll = scrolls(view).first() + val editor = editors(view).single() + val count = panel(view).componentCount + editor.getEditor(true) + + repeat(150) { i -> view.appendDelta("val x$i = $i\n") } + + assertSame(component, view.md.component) + assertSame(scroll, scrolls(view).first()) + assertSame(editor, editors(view).single()) + assertEquals(1, editors(view).size) + assertTrue(htmls(view).size <= 1) + assertEquals(count, panel(view).componentCount) + + view.update(reasoning("r1", done = true, text = view.markdown() + "```")) + assertFalse(view.bodyVisible()) + Disposer.dispose(view) + drainEdt() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + + private fun reasoning(id: String, done: Boolean, text: String) = Reasoning(id).also { + it.done = done + it.content.append(text) + } + + private fun panel(view: ReasoningView): JPanel = view.md.component as JPanel + + private fun scrolls(view: ReasoningView) = descendants(view).filterIsInstance() + + private fun htmls(view: ReasoningView) = descendants(view).filterIsInstance() + + private fun editors(view: ReasoningView) = descendants(view).filterIsInstance() + + private fun descendants(root: Container): List = root.components.flatMap { child -> + listOf(child) + ((child as? Container)?.let(::descendants) ?: emptyList()) + } + + private fun drainEdt() { + UIUtil.dispatchAllInvocationEvents() + } +} 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 42c60a53158..a5d14e4490e 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 @@ -5,8 +5,11 @@ 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 com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil +import java.awt.Component +import java.awt.Container import javax.swing.JPanel import javax.swing.ScrollPaneConstants @@ -54,6 +57,29 @@ class ReasoningViewTest : BasePlatformTestCase() { assertEquals("one\ntwo\nthree\nfour", view.markdown()) } + fun `test live reasoning collapses when marked done`() { + val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo\nthree\nfour")) + + assertTrue(view.isExpanded()) + + view.update(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) + + assertFalse(view.isExpanded()) + assertFalse(view.bodyVisible()) + assertTrue(view.bodyCreated()) + } + + fun `test manually expanded finished reasoning stays open on update`() { + val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo")) + + view.toggle() + view.update(reasoning("p1", done = true, text = "one\ntwo\nthree")) + + assertTrue(view.isExpanded()) + assertTrue(view.bodyVisible()) + assertEquals("one\ntwo\nthree", view.markdown()) + } + fun `test toggle opens and closes reasoning`() { val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) @@ -184,6 +210,20 @@ class ReasoningViewTest : BasePlatformTestCase() { assertEquals(view.bodyScrollBottom(), view.bodyScrollValue()) } + fun `test appended reasoning does not yank user scrolled above tail`() { + val view = ReasoningView(reasoning("p1", done = false, text = (1..40).joinToString("\n") { "line $it" })) + view.setSize(300, 80) + view.doLayout() + UIUtil.dispatchAllInvocationEvents() + val scroll = scroll(view) + scroll.verticalScrollBar.value = 0 + + view.appendDelta("\nline 41\nline 42") + UIUtil.dispatchAllInvocationEvents() + + assertEquals(0, scroll.verticalScrollBar.value) + } + fun `test reasoning block uses vertical separator`() { val view = ReasoningView(reasoning("p1", done = true, text = "one")) @@ -234,4 +274,15 @@ class ReasoningViewTest : BasePlatformTestCase() { it.done = done it.content.append(text) } + + private fun scroll(component: Component): JBScrollPane { + if (component is JBScrollPane) return component + if (component is Container) { + component.components.forEach { child -> + val scroll = runCatching { scroll(child) }.getOrNull() + if (scroll != null) return scroll + } + } + error("scroll not found") + } } 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 79fb2e97dbb..3b5171f4f3a 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 @@ -3,6 +3,8 @@ 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.views.tool.GlobToolView +import ai.kilocode.client.session.views.tool.SearchToolView import ai.kilocode.client.session.views.tool.ToolView import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.util.Disposer @@ -26,12 +28,43 @@ class ToolBodyStressTest : BasePlatformTestCase() { assertEquals(base, EditorFactory.getInstance().allEditors.size) } + fun `test expanded search tool editors are disposed after churn`() { + val base = EditorFactory.getInstance().allEditors.size + + repeat(60) { i -> + val search = SearchToolView(search(i)) + search.toggle() + search.bodyEditor()?.getEditor(true) + Disposer.dispose(search) + + val glob = GlobToolView(glob(i)) + glob.toggle() + glob.bodyEditor()?.getEditor(true) + Disposer.dispose(glob) + } + drainEdt() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + private fun tool(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" } } + private fun search(index: Int) = Tool("s$index", "grep", toolKind("grep")).also { + it.state = ToolExecState.COMPLETED + it.input = mapOf("path" to "src", "pattern" to "needle$index", "include" to "*.kt") + it.output = (1..20).joinToString("\n") { line -> "src/File$line.kt: needle$index" } + } + + private fun glob(index: Int) = Tool("g$index", "glob", toolKind("glob")).also { + it.state = ToolExecState.COMPLETED + it.input = mapOf("path" to "src", "pattern" to "**/*$index.kt") + it.output = (1..20).joinToString("\n") { line -> "src/File$line.kt" } + } + private fun drainEdt() { UIUtil.dispatchAllInvocationEvents() } 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 9e358f61d76..7a03cb52083 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 @@ -211,6 +211,29 @@ class TurnViewTest : BasePlatformTestCase() { assertEquals("first second third", (mv.part("r1") as ReasoningView).markdown()) } + fun `test reasoning alias maps stay bounded across churn`() { + val mv = MessageView(msg("a1", "assistant"), openFile) + + repeat(100) { i -> + mv.upsertPart(reasoning("r${i}a", "first $i ")) + mv.upsertPart(reasoning("r${i}b", "second $i")) + + assertEquals(listOf("r${i}a"), mv.partIds()) + assertSame(mv.part("r${i}a"), mv.part("r${i}b")) + assertEquals(1, aliasSize(mv)) + assertEquals(1, sourceSize(mv)) + assertEquals(1, mv.componentCount) + + mv.removePart("r${i}b") + mv.removePart("r${i}a") + + assertTrue(mv.partIds().isEmpty()) + assertEquals(0, aliasSize(mv)) + assertEquals(0, sourceSize(mv)) + assertEquals(0, mv.componentCount) + } + } + fun `test text between reasoning parts keeps separate views`() { val message = msg("a1", "assistant") message.parts["r1"] = reasoning("r1", "first") @@ -316,6 +339,16 @@ class TurnViewTest : BasePlatformTestCase() { private fun text(id: String, content: String) = Text(id).also { it.content.append(content) } + private fun aliasSize(view: MessageView) = mapSize(view, "aliases") + + private fun sourceSize(view: MessageView) = mapSize(view, "sources") + + private fun mapSize(view: MessageView, name: String): Int { + val field = MessageView::class.java.getDeclaredField(name) + field.isAccessible = true + return (field.get(view) as Map<*, *>).size + } + private class TrackingRepaintManager(private val watched: Set) : RepaintManager() { val dirty = mutableListOf() val invalid = mutableListOf() From 3658deaf4e448273de55f0635faa8b7f22e82767 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 9 Jun 2026 10:54:34 -0400 Subject: [PATCH 23/26] fix(jetbrains): keep completed reasoning expanded --- .../ai/kilocode/client/session/views/ReasoningView.kt | 2 -- .../ai/kilocode/client/session/views/ReasoningViewTest.kt | 6 +++--- 2 files changed, 3 insertions(+), 5 deletions(-) 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 b87986f8fd1..21afb1fcab7 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 @@ -96,7 +96,6 @@ class ReasoningView( if (content !is Reasoning) return var changed = false val next = content.content.toString() - val finished = !done && content.done val follow = tailVisible() if (done != content.done) { done = content.done @@ -110,7 +109,6 @@ class ReasoningView( } changed = true } - if (finished) changed = collapse() || changed changed = sync() || changed if (changed) refresh() } 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 a5d14e4490e..c09062ec808 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 @@ -57,15 +57,15 @@ class ReasoningViewTest : BasePlatformTestCase() { assertEquals("one\ntwo\nthree\nfour", view.markdown()) } - fun `test live reasoning collapses when marked done`() { + fun `test live reasoning stays expanded when marked done`() { val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo\nthree\nfour")) assertTrue(view.isExpanded()) view.update(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) - assertFalse(view.isExpanded()) - assertFalse(view.bodyVisible()) + assertTrue(view.isExpanded()) + assertTrue(view.bodyVisible()) assertTrue(view.bodyCreated()) } From b7d4f7efed764a6d1968b2f5d3f535283f232cf9 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 9 Jun 2026 11:18:20 -0400 Subject: [PATCH 24/26] style(jetbrains): compact session spacing --- .../ai/kilocode/client/session/ui/style/SessionUiStyle.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 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 64fc44d6aa3..9ffd77482dd 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 @@ -15,7 +15,7 @@ object SessionUiStyle { /** Geometry for the transcript list and its scroll behavior. */ object SessionLayout { - const val GAP = 4 + const val GAP = 3 const val TRANSCRIPT_PADDING = 12 const val TRANSCRIPT_SCROLLBAR_PADDING = 10 const val USER_PROMPT_INDENT = 100 @@ -25,8 +25,8 @@ object SessionUiStyle { /** Shared tokens for individual transcript views and session views. */ object View { object Layout { - const val GAP = 6 - const val VERTICAL_PADDING = 8 + const val GAP = 5 + const val VERTICAL_PADDING = 7 const val HORIZONTAL_PADDING = 12 const val BODY_EXTRA_HEIGHT = 16 } From c90846a98938d3cdd666c46294ed4bb4871f7fcd Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 9 Jun 2026 12:54:36 -0400 Subject: [PATCH 25/26] fix(jetbrains): preserve session scroll intent --- .changeset/fix-jetbrains-session-scroll.md | 5 + .../client/session/scroll/SessionScroll.kt | 63 +++++--- .../client/session/SessionScrollTest.kt | 144 +++++++++++++++++- 3 files changed, 189 insertions(+), 23 deletions(-) create mode 100644 .changeset/fix-jetbrains-session-scroll.md diff --git a/.changeset/fix-jetbrains-session-scroll.md b/.changeset/fix-jetbrains-session-scroll.md new file mode 100644 index 00000000000..f707263102b --- /dev/null +++ b/.changeset/fix-jetbrains-session-scroll.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Fix JetBrains session scrolling so mouse wheel and keyboard scrolling no longer snap back or bounce near the transcript bottom. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt index bb60884b10e..018cea2eb0b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt @@ -51,10 +51,10 @@ internal class SessionScroll( private var opening = false private var stable = -1 private var seq = 0 + private var pause = false private var user = false private var value = 0 private var question = false - private var restoring = false init { jump = JBLabel(ScrollButtonIcon.create()).apply { @@ -73,7 +73,6 @@ internal class SessionScroll( user = true } }) - component.viewport.addChangeListener { onViewport() } component.verticalScrollBar.addAdjustmentListener { onScroll() } root.addOverlay(jump) { _, child -> val size = child.preferredSize @@ -98,11 +97,10 @@ internal class SessionScroll( @RequiresEdt fun atBottom(): Boolean { - val bar = component.verticalScrollBar return when { component.viewport.view !== messages -> tail - bar.maximum <= bar.visibleAmount -> true - else -> bar.value + bar.visibleAmount >= bar.maximum - JBUI.scale(THRESHOLD) + !tail -> false + else -> near() } } @@ -114,6 +112,7 @@ internal class SessionScroll( return } user = false + pause = false tail = true stable = -1 auto = true @@ -150,6 +149,7 @@ internal class SessionScroll( seq++ stable = -1 user = false + pause = false auto = true try { action() @@ -164,6 +164,10 @@ internal class SessionScroll( tail = atBottom() syncValue() updateJump() + if (tail) { + stable = -1 + seq++ + } } @RequiresEdt @@ -171,6 +175,7 @@ internal class SessionScroll( opening = true stable = -1 user = false + pause = false tail = true auto = true show(messages) @@ -216,6 +221,7 @@ internal class SessionScroll( opening = false stable = -1 user = false + pause = false tail = true auto = true show(messages) @@ -299,48 +305,61 @@ internal class SessionScroll( } @RequiresEdt - private fun onViewport() { - if (restoring || auto || opening || user || tail || component.viewport.view !== messages) return - val y = value.coerceIn(0, bottom()) - if (component.viewport.viewPosition.y == y && bar.value == y) return - restoring = true - try { - component.viewport.viewPosition = Point(0, y) - bar.value = y - } finally { - restoring = false - } - updateJump() + private fun bottom(): Int { + val bar = component.verticalScrollBar + return (bar.maximum - bar.visibleAmount).coerceAtLeast(bar.minimum) } @RequiresEdt - private fun bottom(): Int { + private fun near(): Boolean { val bar = component.verticalScrollBar - return (bar.maximum - bar.visibleAmount).coerceAtLeast(bar.minimum) + return bar.maximum <= bar.visibleAmount || bar.value + bar.visibleAmount >= bar.maximum - JBUI.scale(THRESHOLD) } @RequiresEdt private fun onScroll() { + val prev = value val moved = bar.value != value + val down = bar.value > value syncValue() if (auto || opening) { updateJump() return } if (component.viewport.view === messages) { - val bottom = atBottom() + val bottom = near() if (bottom) { - tail = true + if (user && moved && !down) { + tail = false + pause = true + } else if (!tail && !user) { + if (moved) { + auto = true + try { + bar.value = prev.coerceIn(bar.minimum, bottom()) + } finally { + auto = false + } + syncValue() + } + tail = false + } else if (pause && !user) { + tail = false + } else { + tail = true + pause = false + } user = false updateJump() return } - if (tail && (!user || !moved)) { + if (tail && !user && !moved) { user = false followBottom(true) return } tail = false + pause = false user = false seq++ } 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 5eabc89c1b5..6aea6cecff2 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 @@ -53,7 +53,7 @@ class SessionScrollTest : SessionUiTestBase() { if (bottom(bar) <= threshold) { fillTranscript(24, start = 24) } - setValue(bar, bottom(bar) - threshold + 1) + setValuePassive(bar, bottom(bar) - threshold + 1) emit(ChatEventDto.MessageUpdated("ses_test", message("tail"))) drainScroll() @@ -61,6 +61,49 @@ class SessionScrollTest : SessionUiTestBase() { assertBottom(bar) } + fun `test viewport driven scroll can move away from stale saved position`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setValue(bar, bottom(bar) / 2) + val value = bar.value + val target = (value + JBUI.scale(96)).coerceAtMost(bottom(bar) - 1) + assertTrue("value=$value target=$target bottom=${bottom(bar)}", target > value) + + (scrollComponent() as JBScrollPane).viewport.viewPosition = Point(0, target) + drainScroll() + + assertEquals(target, bar.value) + assertTrue(jumpButton().isVisible) + } + + fun `test user scroll upward near bottom disables tail follow`() { + showMessages() + fillTranscript(48) + val bar = scrollBar() + val threshold = JBUI.scale(32) + assertTrue("bottom=${bottom(bar)} threshold=$threshold", bottom(bar) > threshold * 2) + val id = "near_bottom_user_tail" + val pid = "near_bottom_user_part" + emit(ChatEventDto.MessageUpdated("ses_test", message(id)), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n")), flush = false) + forceFlush() + drainScroll() + setBottom(bar) + setValue(bar, bottom(bar) - threshold + 1) + val value = bar.value + assertFalse(ui.scroll.following()) + + repeat(240) { i -> + emit(ChatEventDto.PartDelta("ses_test", id, pid, "text", "tail line $i\n"), flush = false) + } + forceFlush() + drainScroll() + + assertTrue("value=$value actual=${bar.value}", bar.value >= value) + assertFalse(ui.scroll.following()) + } + fun `test session update preserves position outside bottom threshold`() { showMessages() fillTranscript(24) @@ -207,6 +250,55 @@ class SessionScrollTest : SessionUiTestBase() { assertFalse(jumpButton().isVisible) } + fun `test user scrolling to bottom during massive stream resumes following`() { + showMessages() + fillTranscript(48) + val bar = scrollBar() + val id = "stream_massive_resume" + val pid = "stream_massive_resume_part" + emit(ChatEventDto.MessageUpdated("ses_test", message(id)), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n")), flush = false) + forceFlush() + drainScroll() + setValue(bar, bottom(bar) / 2) + assertFalse(ui.scroll.following()) + assertTrue(jumpButton().isVisible) + val first = buildString { + repeat(160) { i -> append("line $i\n\n") } + } + + repeat(160) { i -> + emit(ChatEventDto.PartDelta("ses_test", id, pid, "text", "line $i\n\n"), flush = false) + } + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n${first}snapshot\n\n")), flush = false) + forceFlush() + settleShort(100) + layout() + setBottom(bar) + drainScroll() + setBottom(bar) + drainScroll() + + assertBottom(bar) + assertTrue(ui.scroll.following()) + assertFalse(jumpButton().isVisible) + val second = buildString { + repeat(160) { i -> append("tail line $i\n\n") } + } + + repeat(160) { i -> + emit(ChatEventDto.PartDelta("ses_test", id, pid, "text", "tail line $i\n\n"), flush = false) + } + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n${first}snapshot\n\n${second}snapshot tail\n\n")), flush = false) + forceFlush() + settleShort(100) + drainScroll() + + assertBottom(bar) + assertTrue(ui.scroll.following()) + assertFalse(jumpButton().isVisible) + } + fun `test part delta preserves middle scroll position`() { showMessages() fillTranscript(24) @@ -460,6 +552,56 @@ class SessionScrollTest : SessionUiTestBase() { assertFalse(button.isVisible) } + fun `test scroll button resumes following during massive stream`() { + showMessages() + fillTranscript(48) + val button = jumpButton() + val bar = scrollBar() + val id = "stream_massive_button" + val pid = "stream_massive_button_part" + emit(ChatEventDto.MessageUpdated("ses_test", message(id)), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n")), flush = false) + forceFlush() + drainScroll() + setValue(bar, bottom(bar) / 2) + val first = buildString { + repeat(160) { i -> append("line $i\n\n") } + } + + repeat(160) { i -> + emit(ChatEventDto.PartDelta("ses_test", id, pid, "text", "line $i\n\n"), flush = false) + } + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n${first}snapshot\n\n")), flush = false) + forceFlush() + settleShort(100) + drainScroll() + + assertTrue(button.isVisible) + assertFalse(ui.scroll.following()) + + click(button) + drainScroll() + + assertBottom(bar) + assertTrue(ui.scroll.following()) + assertFalse(button.isVisible) + val second = buildString { + repeat(160) { i -> append("tail line $i\n\n") } + } + + repeat(160) { i -> + emit(ChatEventDto.PartDelta("ses_test", id, pid, "text", "tail line $i\n\n"), flush = false) + } + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n${first}snapshot\n\n${second}snapshot tail\n\n")), flush = false) + forceFlush() + settleShort(100) + drainScroll() + + assertBottom(bar) + assertTrue(ui.scroll.following()) + assertFalse(button.isVisible) + } + fun `test scroll button remains hidden outside transcript body`() { val button = jumpButton() From 5792f44e41edbb8e3fac8e51c5fd5b2e94f4dca1 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 9 Jun 2026 13:59:28 -0400 Subject: [PATCH 26/26] fix(jetbrains): release session UI editors in tests --- .../client/session/ui/selection/SessionSelection.kt | 2 +- .../ai/kilocode/client/session/views/base/GenericView.kt | 1 + .../ai/kilocode/client/session/views/tool/ToolSupport.kt | 7 ++++++- .../main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt | 3 +++ .../kotlin/ai/kilocode/client/session/SessionUiTestBase.kt | 2 ++ .../kilocode/client/session/ui/SessionSelectionCopyTest.kt | 2 +- .../client/session/views/ReasoningViewStressTest.kt | 2 +- .../client/session/views/permission/PermissionViewTest.kt | 4 ++-- 8 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionSelection.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionSelection.kt index 2a52484c25a..0f7fb1a14b0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionSelection.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionSelection.kt @@ -157,7 +157,7 @@ class SessionSelection : Disposable { override fun clearSelection() { val pos = component.selectionStart.coerceIn(0, component.document.length) - component.select(pos, pos) + component.caretPosition = pos } override fun applyStyle(style: SessionEditorStyle) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt index c0973f9d166..7f535048a70 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt @@ -26,6 +26,7 @@ class GenericView private constructor( label.foreground = UiStyle.Colors.weak() applyStyle(SessionEditorStyle.current()) syncExpandable(false) + border = null } override fun update(content: Content) {} // generic content has no updatable state 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 3e5c9aa7554..81744bfb98b 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 @@ -208,7 +208,12 @@ class ToolBody private constructor( fun editor(tool: Tool): ToolBody { val disposable = Disposer.newDisposable("Tool body") val body = runCatching { - val field = ToolField(preview(tool), SessionEditorStyle.current()).also { it.setDisposedWith(disposable) } + val field = ToolField(preview(tool), SessionEditorStyle.current()).also { ed -> + ed.setDisposedWith(disposable) + Disposer.register(disposable) { + ed.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) + } + } ToolBody(null, field, pane(field, true), disposable) }.getOrElse { err -> LOG.warn("kind=tool codeEditor=true failed message=${err.message}", 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 30b0c6c94df..cd03b91b5c1 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 @@ -499,6 +499,9 @@ internal class MdViewHybrid( val field = runCatching { CodeField(file, opts, text).also { ed -> ed.setDisposedWith(disposable) + Disposer.register(disposable) { + ed.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) + } selection?.register(ed, disposable) } }.getOrElse { err -> diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt index e77fb3d6119..4fe55cf5d26 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt @@ -26,6 +26,7 @@ import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionTimeDto import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.openapi.util.Disposer import com.intellij.util.ui.UIUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -73,6 +74,7 @@ abstract class SessionUiTestBase : BasePlatformTestCase() { override fun tearDown() { try { + Disposer.dispose(ui) scope.cancel() } finally { super.tearDown() 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 b6f7dd12f8d..a266bc91432 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 @@ -47,7 +47,7 @@ class SessionSelectionCopyTest : SessionUiTestBase() { select(two, "bravo") copyProvider()!!.performCopy(DataContext.EMPTY_CONTEXT) - assertNull(one.selectedText) + assertTrue(one.selectedText.isNullOrEmpty()) assertEquals("bravo", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewStressTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewStressTest.kt index 1574d682d92..d9f1ac10f19 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewStressTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewStressTest.kt @@ -33,7 +33,7 @@ class ReasoningViewStressTest : BasePlatformTestCase() { assertEquals(count, panel(view).componentCount) view.update(reasoning("r1", done = true, text = view.markdown() + "```")) - assertFalse(view.bodyVisible()) + assertTrue(view.bodyVisible()) Disposer.dispose(view) drainEdt() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt index 18082d542a4..6b52bef0020 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt @@ -4,12 +4,12 @@ import ai.kilocode.client.session.model.Permission import ai.kilocode.client.session.model.PermissionFileDiff import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.PermissionRequestState +import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.session.views.base.BaseQuestionView import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.PermissionReplyDto -import com.intellij.icons.AllIcons import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.components.JBLabel @@ -410,7 +410,7 @@ class PermissionViewTest : BasePlatformTestCase() { val labels = findAll(view) assertTrue( "Expected permission warning icon in header", - labels.any { it.icon == AllIcons.General.Warning }, + labels.any { it.icon == SessionViewIcons.warning }, ) }