From fde3671780356c6ad993f88940b8695876f47269 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 3 Jul 2026 20:11:29 -0400 Subject: [PATCH 01/19] fix(jetbrains): scroll wide markdown tables instead of cropping them Render markdown tables in their own horizontal scroll block, mirroring the code-block pattern, so a wide table shrinks to the panel width and scrolls horizontally instead of stretching the message and cropping content. Derive the pane height from the rendered view's preferred size so the table is not clipped vertically. --- .../client/ui/md/hybrid/MdViewHybrid.kt | 86 +++++++++++++++++-- .../client/ui/md/MdViewHybridStressTest.kt | 36 ++++++++ .../kilocode/client/ui/md/MdViewHybridTest.kt | 85 ++++++++++++++++++ 3 files changed, 202 insertions(+), 5 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt index 973d8b9e5ed..ea9baa39df0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt @@ -29,6 +29,7 @@ import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.JBUI import org.commonmark.ext.autolink.AutolinkExtension import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension +import org.commonmark.ext.gfm.tables.TableBlock import org.commonmark.ext.gfm.tables.TablesExtension import org.commonmark.node.AbstractVisitor import org.commonmark.node.Block @@ -411,6 +412,7 @@ internal open class MdViewHybrid( val disposable = Disposer.newDisposable("Markdown block") return when (desc) { is Desc.Html -> HtmlView(desc, htmlBlock(desc.body, disposable), disposable) + is Desc.Table -> TableView(desc, tableBlock(desc.body, disposable), disposable) is Desc.Code -> when (val kind = desc.kind) { is Kind.Source -> CodeView(desc, codeBlock(desc.text, kind.file, disposable), disposable) is Kind.Terminal -> TermView(desc, terminalBlock(desc.text, kind, disposable), disposable) @@ -500,6 +502,28 @@ internal open class MdViewHybrid( }.getOrNull() } + private fun tableBlock(body: String, disposable: Disposable): JBScrollPane { + val opts = opts() + val inner = htmlBlock(body, disposable) + val pane = object : JBScrollPane(inner), SessionCopyTarget { + override val copyAnchor: JComponent get() = this + + override fun copyText() = inner.document.getText(0, inner.document.length).trim() + + // Width is pinned to 0 so BoxLayout shrinks the pane to the container while the wide + // table scrolls horizontally inside it. Height is derived from the inner pane's current + // preferred height on every pass so it is correct once the html view is realized + // (a static measurement taken before layout is too small and crops the table). + override fun getPreferredSize() = Dimension(0, tableHeight(this, inner)) + + override fun getMinimumSize() = Dimension(0, tableHeight(this, inner)) + + override fun getMaximumSize() = Dimension(Int.MAX_VALUE, tableHeight(this, inner)) + } + styleTablePane(pane, opts) + return pane + } + private fun codeBlock(text: String, file: FileType, disposable: Disposable): JBScrollPane { val opts = opts() val value = text.trimEnd('\n') @@ -633,6 +657,30 @@ internal open class MdViewHybrid( pane.maximumSize = Dimension(Int.MAX_VALUE, height) } + private fun styleTablePane(pane: JBScrollPane, opts: MdStyle) { + pane.apply { + border = JBUI.Borders.empty() + viewportBorder = JBUI.Borders.empty() + isOpaque = opts.opaque + background = opts.background + viewport.isOpaque = opts.opaque + viewport.background = opts.background + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + isWheelScrollingEnabled = true + setOverlappingScrollBar(false) + horizontalScrollBar.preferredSize = Dimension(0, JBUI.scale(SessionUiStyle.View.Code.SCROLLBAR_HEIGHT)) + horizontalScrollBar.isOpaque = opts.opaque + verticalScrollBar.preferredSize = JBUI.emptySize() + } + } + + private fun tableHeight(pane: JBScrollPane, inner: JComponent): Int { + val pad = pane.viewportBorder?.getBorderInsets(pane) ?: JBUI.emptyInsets() + return inner.preferredSize.height + pane.insets.top + pane.insets.bottom + + pad.top + pad.bottom + pane.horizontalScrollBar.preferredSize.height + } + private fun codeWidth(component: JComponent, text: String): Int { val metrics = component.getFontMetrics(component.font) val width = text.lineSequence().maxOfOrNull { metrics.stringWidth(it) } ?: 0 @@ -859,6 +907,7 @@ internal open class MdViewHybrid( when (desc) { is Desc.Html -> html.append(desc.body) is Desc.Code -> html.append(codeHtml(desc.text)) + is Desc.Table -> html.append(desc.body) } } md.clear() @@ -977,6 +1026,7 @@ internal open class MdViewHybrid( private sealed class Desc { data class Html(val body: String) : Desc() data class Code(val text: String, val kind: Kind) : Desc() + data class Table(val body: String) : Desc() } private data class Projection(val html: String, val blocks: List, val open: Fence?) @@ -1017,6 +1067,29 @@ internal open class MdViewHybrid( } } + private inner class TableView(desc: Desc.Table, private val pane: JBScrollPane, disposable: Disposable) : + View(desc, pane, disposable) { + override fun compatible(desc: Desc) = desc is Desc.Table + + override fun update(desc: Desc) { + if (this.desc == desc) return + this.desc = desc + val inner = pane.viewport.view as? JBHtmlPane ?: return + inner.text = html((desc as Desc.Table).body, opts()) + pane.revalidate() + } + + override fun style(opts: MdStyle) { + styleTablePane(pane, opts) + val inner = pane.viewport.view as? JBHtmlPane ?: return + inner.isOpaque = opts.opaque + inner.background = opts.background + inner.reloadCssStylesheets() + inner.text = html((desc as Desc.Table).body, opts) + pane.revalidate() + } + } + private inner class CodeView(desc: Desc.Code, private val pane: JBScrollPane, disposable: Disposable) : View(desc, pane, disposable) { override fun compatible(desc: Desc) = desc is Desc.Code && (this.desc as Desc.Code).kind == desc.kind @@ -1170,12 +1243,15 @@ internal open class MdViewHybrid( var child = parent.firstChild while (child != null) { val next = child.next - if (child is ThematicBreak) { - child = next - continue + when { + child is ThematicBreak -> Unit + child is FencedCodeBlock || child is IndentedCodeBlock -> child.accept(this) + child is TableBlock -> { + flush() + blocks.add(Desc.Table(renderer.render(child))) + } + child is Block -> run.append(renderer.render(child)) } - 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/MdViewHybridStressTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridStressTest.kt index e9d40248cf6..50d60a5d72a 100644 --- 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 @@ -164,6 +164,42 @@ class MdViewHybridStressTest : BasePlatformTestCase() { assertTrue(view.markdown().contains("line 49")) } + fun `test repeated table set reuses single pane and stays bounded`() { + repeat(150) { i -> view.set("| a | b |\n|---|---|\n| $i | ${i + 1} |") } + val pane = scrolls().single() + val inner = pane.viewport.view as JBHtmlPane + + repeat(50) { i -> view.set("| a | b |\n|---|---|\n| y$i | z$i |") } + + assertSame(pane, scrolls().single()) + assertSame(inner, scrolls().single().viewport.view) + assertEquals(1, scrolls().size) + assertEquals(0, htmls().size) + assertEquals(1, panel().componentCount) + assertTrue(inner.text.contains("y49")) + } + + fun `test churn across prose code and table stays bounded and leak free`() { + val base = EditorFactory.getInstance().allEditors.size + + repeat(60) { i -> + view.set("prose $i") + view.set("```kotlin\nval x = $i\n```") + editors().single().getEditor(true) + view.set("| a | b |\n|---|---|\n| $i | ${i + 1} |") + assertEquals(1, scrolls().size) + assertTrue(editors().isEmpty()) + } + + view.clear() + drainEdt() + + assertTrue(scrolls().isEmpty()) + assertTrue(htmls().isEmpty()) + assertEquals(0, panel().componentCount) + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + private fun panel(): JPanel = view.component as JPanel private fun scrolls(): List = panel().components.filterIsInstance() 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 d029c0087a2..3517634cb90 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 @@ -771,6 +771,91 @@ class MdViewHybridTest : BasePlatformTestCase() { assertEquals(pane.background, pane.viewport.background) } + fun `test table renders in horizontal scroll pane without an editor`() { + view.set("| a | b |\n|---|---|\n| 1 | 2 |") + val pane = scrolls().single() + val inner = pane.viewport.view as JBHtmlPane + + assertTrue(editors().isEmpty()) + assertTrue(htmls().isEmpty()) + assertTrue(inner.text.contains("")) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, pane.horizontalScrollBarPolicy) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, pane.verticalScrollBarPolicy) + assertTrue(pane.horizontalScrollBar.preferredSize.height > 0) + assertEquals(0, pane.verticalScrollBar.preferredSize.width) + } + + fun `test wide table width is bounded and boxed`() { + val header = (1..10).joinToString("|", prefix = "|", postfix = "|") { " column$it " } + val sep = (1..10).joinToString("|", prefix = "|", postfix = "|") { "---" } + val row = (1..10).joinToString("|", prefix = "|", postfix = "|") { " ${"x".repeat(20)} " } + view.set("$header\n$sep\n$row") + val pane = scrolls().single() + val inner = pane.viewport.view as JBHtmlPane + + assertEquals(0, pane.preferredSize.width) + assertTrue(inner.preferredSize.width > pane.preferredSize.width) + assertTrue(pane.maximumSize.width > 1000) + } + + fun `test table pane reserves full table height and does not clip vertically`() { + val rows = (1..8).joinToString("\n") { "| r${it}c1 | r${it}c2 |" } + view.set("| a | b |\n|---|---|\n$rows") + val pane = scrolls().single() + val inner = pane.viewport.view as JBHtmlPane + + layout(width = 420) + + val bar = pane.horizontalScrollBar.preferredSize.height + assertTrue("pane preferred height should cover the rendered table", pane.preferredSize.height >= inner.preferredSize.height + bar) + assertTrue("table should not be clipped vertically", pane.height >= inner.preferredSize.height) + } + + fun `test table separates surrounding prose runs`() { + view.set("intro\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\noutro") + + val html = htmls() + assertEquals(2, html.size) + assertEquals(1, scrolls().size) + assertTrue(editors().isEmpty()) + assertEquals(2, struts().size) + assertTrue(html[0].text.contains("intro")) + assertTrue(html[1].text.contains("outro")) + assertTrue((scrolls().single().viewport.view as JBHtmlPane).text.contains("
")) + } + + fun `test rerendering table reuses retained scroll pane and html child`() { + view.set("| a | b |\n|---|---|\n| 1 | 2 |") + val pane = scrolls().single() + val inner = pane.viewport.view as JBHtmlPane + + view.set("| a | b |\n|---|---|\n| 3 | 4 |") + + assertSame(pane, scrolls().single()) + assertSame(inner, scrolls().single().viewport.view) + assertEquals(1, scrolls().size) + assertTrue(inner.text.contains("4")) + } + + fun `test replacing table with prose disposes the scroll pane`() { + view.set("| a | b |\n|---|---|\n| 1 | 2 |") + assertEquals(1, scrolls().size) + + view.set("plain prose") + drainEdt() + + assertTrue(scrolls().isEmpty()) + assertTrue(htmls().single().text.contains("plain prose")) + } + + fun `test clear disposes table scroll pane`() { + view.set("| a | b |\n|---|---|\n| 1 | 2 |") + view.clear() + + assertEquals("", view.markdown()) + assertTrue(scrolls().isEmpty()) + } + fun `test clear resets source and components`() { view.set("```\ncode\n```") view.clear() From c37d14da7f5b52d787649f416ef67fa09854ab64 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 3 Jul 2026 20:51:50 -0400 Subject: [PATCH 02/19] refactor(jetbrains): extract markdown projector --- .../client/ui/md/hybrid/MdProjector.kt | 226 +++++++ .../client/ui/md/hybrid/MdTerminal.kt | 14 - .../client/ui/md/hybrid/MdViewHybrid.kt | 597 ++++-------------- .../kilocode/client/ui/md/MdLanguageTest.kt | 67 ++ .../kilocode/client/ui/md/MdProjectorTest.kt | 72 +++ .../client/ui/md/MdShellHighlightTest.kt | 50 ++ .../kilocode/client/ui/md/MdTerminalTest.kt | 23 + .../ai/kilocode/client/ui/md/MdTestStyles.kt | 46 ++ .../kilocode/client/ui/md/MdViewHybridTest.kt | 82 +-- .../ai/kilocode/client/ui/md/MdViewTest.kt | 43 +- 10 files changed, 654 insertions(+), 566 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdProjector.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdLanguageTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdProjectorTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdShellHighlightTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdTestStyles.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdProjector.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdProjector.kt new file mode 100644 index 00000000000..1d3e077951b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdProjector.kt @@ -0,0 +1,226 @@ +package ai.kilocode.client.ui.md.hybrid + +import com.intellij.openapi.fileTypes.PlainTextFileType +import org.commonmark.ext.autolink.AutolinkExtension +import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension +import org.commonmark.ext.gfm.tables.TableBlock +import org.commonmark.ext.gfm.tables.TablesExtension +import org.commonmark.node.AbstractVisitor +import org.commonmark.node.Block +import org.commonmark.node.Document +import org.commonmark.node.FencedCodeBlock +import org.commonmark.node.IndentedCodeBlock +import org.commonmark.node.Node +import org.commonmark.node.ThematicBreak +import org.commonmark.parser.Parser +import org.commonmark.renderer.html.HtmlRenderer + +internal class MdProjector { + private val extensions = listOf( + AutolinkExtension.create(), + TablesExtension.create(), + StrikethroughExtension.create(), + ) + + private val parser: Parser = Parser.builder().extensions(extensions).build() + + private val renderer: HtmlRenderer = HtmlRenderer.builder() + .extensions(extensions) + .escapeHtml(true) + .sanitizeUrls(true) + .build() + + fun project(text: String): Projection { + val blocks = mutableListOf() + val html = StringBuilder() + val md = StringBuilder() + val lines = lines(text) + var trailing: Fence? = null + var idx = 0 + + fun flush() { + if (md.isEmpty()) return + val doc = parser.parse(md.toString()) + val descs = collect(doc) + blocks.addAll(descs) + for (desc in descs) { + when (desc) { + is Desc.Html -> html.append(desc.body) + is Desc.Code -> html.append(codeHtml(desc.text)) + is Desc.Table -> html.append(desc.body) + } + } + md.clear() + } + + while (idx < lines.size) { + val line = lines[idx] + val open = opener(line.text) + if (open == null) { + val pending = idx == lines.lastIndex && pendingOpener(line.text) + if (pending) { + flush() + blocks.add(Desc.Code("", Kind.Source(PlainTextFileType.INSTANCE))) + html.append(codeHtml("")) + } else { + md.append(line.text).append(line.end) + } + idx++ + continue + } + + flush() + idx++ + val code = StringBuilder() + var closed = false + var trimmed = false + while (idx < lines.size) { + val item = lines[idx] + val close = closer(item.text, open) + if (close) { + closed = true + idx++ + break + } + val partial = idx == lines.lastIndex && partialCloser(item.text, open) + if (partial) trimmed = true + if (!partial) code.append(item.text).append(item.end) + idx++ + } + val desc = Desc.Code(code.toString(), MdLanguage.kind(open.info)) + blocks.add(desc) + html.append(codeHtml(desc.text)) + trailing = if (!closed && !trimmed) open else null + } + + flush() + return Projection(html.toString(), blocks, trailing) + } + + private fun collect(doc: Node): List { + val visitor = Visitor() + doc.accept(visitor) + return visitor.blocks + } + + private fun lines(text: String): List { + if (text.isEmpty()) return emptyList() + val lines = mutableListOf() + var start = 0 + while (start < text.length) { + val end = text.indexOf('\n', start) + if (end == -1) { + lines.add(Line(text.substring(start), "")) + break + } + lines.add(Line(text.substring(start, end), "\n")) + start = end + 1 + } + return lines + } + + private fun opener(text: String): Fence? { + val trimmed = text.dropWhile { it == ' ' } + val indent = text.length - trimmed.length + if (indent > 3) return null + val char = trimmed.firstOrNull() ?: return null + if (char != '`' && char != '~') return null + val size = trimmed.takeWhile { it == char }.length + if (size < 3) return null + val info = trimmed.drop(size).trim() + if (char == '`' && info.contains('`')) return null + return Fence(char, size, info) + } + + private fun closer(text: String, fence: Fence): Boolean { + val trimmed = text.dropWhile { it == ' ' } + val indent = text.length - trimmed.length + if (indent > 3) return false + val size = trimmed.takeWhile { it == fence.char }.length + if (size < fence.size) return false + return trimmed.drop(size).isBlank() + } + + private fun pendingOpener(text: String): Boolean { + val trimmed = text.dropWhile { it == ' ' } + val indent = text.length - trimmed.length + if (indent > 3) return false + val char = trimmed.firstOrNull() ?: return false + if (char != '`' && char != '~') return false + val size = trimmed.takeWhile { it == char }.length + if (size !in 1..2) return false + return trimmed.drop(size).isBlank() + } + + private fun partialCloser(text: String, fence: Fence): Boolean { + val trimmed = text.dropWhile { it == ' ' } + val indent = text.length - trimmed.length + if (indent > 3) return false + val size = trimmed.takeWhile { it == fence.char }.length + if (size !in 1 until fence.size) return false + return trimmed.drop(size).isBlank() + } + + private fun codeHtml(text: String): String = "
${escape(text)}
\n" + + private fun escape(text: String): String = text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + + private inner class Visitor : AbstractVisitor() { + val blocks = mutableListOf() + private val run = StringBuilder() + + override fun visit(document: Document) { + visitChildren(document) + flush() + } + + override fun visit(code: FencedCodeBlock) { + flush() + blocks.add(Desc.Code(code.literal, MdLanguage.kind(code.info))) + } + + override fun visit(code: IndentedCodeBlock) { + flush() + blocks.add(Desc.Code(code.literal, MdLanguage.kind(null))) + } + + private fun flush() { + if (run.isEmpty()) return + blocks.add(Desc.Html(run.toString())) + run.clear() + } + + public override fun visitChildren(parent: Node) { + var child = parent.firstChild + while (child != null) { + val next = child.next + when { + child is ThematicBreak -> Unit + child is FencedCodeBlock || child is IndentedCodeBlock -> child.accept(this) + child is TableBlock -> { + flush() + blocks.add(Desc.Table(renderer.render(child))) + } + child is Block -> run.append(renderer.render(child)) + } + child = next + } + } + } +} + +internal sealed class Desc { + data class Html(val body: String) : Desc() + data class Code(val text: String, val kind: Kind) : Desc() + data class Table(val body: String) : Desc() +} + +internal data class Projection(val html: String, val blocks: List, val open: Fence?) + +internal data class Line(val text: String, val end: String) + +internal data class Fence(val char: Char, val size: Int, val info: String) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdTerminal.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdTerminal.kt index 2422c9b9810..c8c257cf5d2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdTerminal.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdTerminal.kt @@ -42,20 +42,6 @@ internal object MdTerminal { } } - fun backspace(text: String): String { - val out = StringBuilder() - var idx = 0 - while (idx < text.length) { - val ch = text[idx++] - if (ch == '\b') { - if (out.isNotEmpty()) out.deleteCharAt(out.length - 1) - continue - } - out.append(ch) - } - return out.toString() - } - fun reduce(text: String, keepSgr: Boolean): String = split(text.replace("\r\n", "\n"), '\n') .joinToString("\n") { controls(it, keepSgr) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt index ea9baa39df0..e3d04e8fc80 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt @@ -15,6 +15,7 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.DataSink import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.editor.markup.HighlighterLayer import com.intellij.openapi.editor.markup.HighlighterTargetArea import com.intellij.openapi.fileTypes.FileType @@ -27,20 +28,8 @@ import com.intellij.ui.components.JBHtmlPaneConfiguration import com.intellij.ui.components.JBHtmlPaneStyleConfiguration import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.JBUI -import org.commonmark.ext.autolink.AutolinkExtension -import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension -import org.commonmark.ext.gfm.tables.TableBlock -import org.commonmark.ext.gfm.tables.TablesExtension -import org.commonmark.node.AbstractVisitor -import org.commonmark.node.Block -import org.commonmark.node.Document -import org.commonmark.node.FencedCodeBlock -import org.commonmark.node.IndentedCodeBlock -import org.commonmark.node.Node -import org.commonmark.node.ThematicBreak -import org.commonmark.parser.Parser -import org.commonmark.renderer.html.HtmlRenderer import java.awt.Color +import java.awt.Component import java.awt.Dimension import java.awt.Font import java.awt.Point @@ -56,6 +45,7 @@ import javax.swing.SwingUtilities import javax.swing.event.ChangeListener import javax.swing.event.HyperlinkEvent import javax.swing.text.html.StyleSheet +import kotlin.reflect.KProperty @Suppress("UnstableApiUsage") internal open class MdViewHybrid( @@ -76,32 +66,19 @@ internal open class MdViewHybrid( private val blocks = mutableListOf() private var openFence: Fence? = null private var stale = false + private val projector = MdProjector() - private val extensions = listOf( - AutolinkExtension.create(), - TablesExtension.create(), - StrikethroughExtension.create(), - ) - - private val parser: Parser = Parser.builder().extensions(extensions).build() - - private val renderer: HtmlRenderer = HtmlRenderer.builder() - .extensions(extensions) - .escapeHtml(true) - .sanitizeUrls(true) - .build() - - private var fontOverride: Font? = null - private var foregroundOverride: Color? = null - private var backgroundOverride: Color? = null - private var linkColorOverride: Color? = null - private var codeBgOverride: Color? = null - private var preBgOverride: Color? = null - private var preFgOverride: Color? = null - private var codeFontOverride: String? = null - private var quoteBorderOverride: Color? = null - private var quoteFgOverride: Color? = null - private var tableBorderOverride: Color? = null + private val fontOverride = Override { opts().font } + private val foregroundOverride = Override { opts().foreground } + private val backgroundOverride = Override { opts().background } + private val linkColorOverride = Override { opts().linkColor } + private val codeBgOverride = Override { opts().codeBg } + private val preBgOverride = Override { opts().preBg } + private val preFgOverride = Override { opts().preFg } + private val codeFontOverride = Override { opts().codeFont } + private val quoteBorderOverride = Override { opts().quoteBorder } + private val quoteFgOverride = Override { opts().quoteFg } + private val tableBorderOverride = Override { opts().tableBorder } private var opaqueState = true private val root = RootPanel().apply { @@ -112,104 +89,27 @@ internal open class MdViewHybrid( override val component: JComponent get() = root - override var font: Font - get() = fontOverride ?: opts().font - set(value) { - if (disposed) return - if (fontOverride == value) return - fontOverride = value - syncStyle() - } + override var font: Font by fontOverride - override var foreground: Color - get() = foregroundOverride ?: opts().foreground - set(value) { - if (disposed) return - if (foregroundOverride == value) return - foregroundOverride = value - syncStyle() - } + override var foreground: Color by foregroundOverride - override var background: Color - get() = backgroundOverride ?: opts().background - set(value) { - if (disposed) return - if (backgroundOverride == value) return - backgroundOverride = value - syncStyle() - } + override var background: Color by backgroundOverride - override var linkColor: Color - get() = linkColorOverride ?: opts().linkColor - set(value) { - if (disposed) return - if (linkColorOverride == value) return - linkColorOverride = value - syncStyle() - } + override var linkColor: Color by linkColorOverride - override var codeBg: Color - get() = codeBgOverride ?: opts().codeBg - set(value) { - if (disposed) return - if (codeBgOverride == value) return - codeBgOverride = value - syncStyle() - } + override var codeBg: Color by codeBgOverride - override var preBg: Color - get() = preBgOverride ?: opts().preBg - set(value) { - if (disposed) return - if (preBgOverride == value) return - preBgOverride = value - syncStyle() - } + override var preBg: Color by preBgOverride - override var preFg: Color - get() = preFgOverride ?: opts().preFg - set(value) { - if (disposed) return - if (preFgOverride == value) return - preFgOverride = value - syncStyle() - } + override var preFg: Color by preFgOverride - override var codeFont: String - get() = codeFontOverride ?: opts().codeFont - set(value) { - if (disposed) return - if (codeFontOverride == value) return - codeFontOverride = value - syncStyle() - } + override var codeFont: String by codeFontOverride - override var quoteBorder: Color - get() = quoteBorderOverride ?: opts().quoteBorder - set(value) { - if (disposed) return - if (quoteBorderOverride == value) return - quoteBorderOverride = value - syncStyle() - } + override var quoteBorder: Color by quoteBorderOverride - override var quoteFg: Color - get() = quoteFgOverride ?: opts().quoteFg - set(value) { - if (disposed) return - if (quoteFgOverride == value) return - quoteFgOverride = value - syncStyle() - } + override var quoteFg: Color by quoteFgOverride - override var tableBorder: Color - get() = tableBorderOverride ?: opts().tableBorder - set(value) { - if (disposed) return - if (tableBorderOverride == value) return - tableBorderOverride = value - syncStyle() - } + override var tableBorder: Color by tableBorderOverride override var opaque: Boolean get() = opaqueState @@ -237,17 +137,17 @@ internal open class MdViewHybrid( override fun resetStyles() { if (disposed) return - fontOverride = null - foregroundOverride = null - backgroundOverride = null - linkColorOverride = null - codeBgOverride = null - preBgOverride = null - preFgOverride = null - codeFontOverride = null - quoteBorderOverride = null - quoteFgOverride = null - tableBorderOverride = null + fontOverride.clear() + foregroundOverride.clear() + backgroundOverride.clear() + linkColorOverride.clear() + codeBgOverride.clear() + preBgOverride.clear() + preFgOverride.clear() + codeFontOverride.clear() + quoteBorderOverride.clear() + quoteFgOverride.clear() + tableBorderOverride.clear() opaqueState = true syncStyle() } @@ -303,7 +203,7 @@ internal open class MdViewHybrid( override fun html(): String { if (stale) { - val out = project(source.toString()) + val out = projector.project(source.toString()) rendered = out.html openFence = out.open stale = false @@ -342,7 +242,7 @@ internal open class MdViewHybrid( private fun syncBlocks() { if (disposed) return val text = source.toString() - val out = project(text) + val out = projector.project(text) rendered = out.html openFence = out.open stale = false @@ -527,19 +427,12 @@ internal open class MdViewHybrid( private fun codeBlock(text: String, file: FileType, disposable: Disposable): JBScrollPane { val opts = opts() val value = text.trimEnd('\n') - fun editor(type: FileType) = CodeField(type, opts, text, false).also { ed -> - Disposer.register(disposable) { - ed.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) - } - ed.setDisposedWith(disposable) - selection?.register(ed, disposable) - } val field = runCatching { - editor(file) + codeField(file, opts, text, false, disposable) }.getOrElse { err -> LOG.warn("kind=markdown codeEditor=true failed message=${err.message}", err) if (code.opts.editorOnly) runCatching { - editor(PlainTextFileType.INSTANCE) + codeField(PlainTextFileType.INSTANCE, opts, text, false, disposable) }.getOrElse { fallback -> LOG.warn("kind=markdown codeEditor=true fallback=plain failed message=${fallback.message}", fallback) throw fallback @@ -548,23 +441,10 @@ internal open class MdViewHybrid( } } sizeCodeField(field, value) - val pane = object : JBScrollPane(field), SessionCopyTarget { + val pane = object : CodePane(field), SessionCopyTarget { override val copyAnchor: JComponent get() = this - override fun copyText() = when (field) { - is CodeField -> field.text - is JBTextArea -> field.text - else -> "" - } - - override fun doLayout() { - super.doLayout() - if (code.opts.verticalPolicy != ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER) return - val view = viewport.view ?: return - val size = viewport.extentSize - if (size.height <= 0 || view.height == size.height) return - view.setSize(view.width.coerceAtLeast(size.width), size.height) - } + override fun copyText() = fieldText(field) } styleCodePane(pane, opts) sizeCodePane(pane, field) @@ -575,27 +455,12 @@ internal open class MdViewHybrid( val opts = opts() val term = MdTerminal.decode(text, kind.stream) val value = shellDisplay(term, kind.mode) - val field = CodeField(PlainTextFileType.INSTANCE, opts, value.text, false).also { ed -> - Disposer.register(disposable) { - ed.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) - } - ed.setDisposedWith(disposable) - selection?.register(ed, disposable) - } + val field = codeField(PlainTextFileType.INSTANCE, opts, value.text, false, disposable) sizeCodeField(field, value.text) - val pane = object : JBScrollPane(field), SessionCopyTarget { + val pane = object : CodePane(field), SessionCopyTarget { override val copyAnchor: JComponent get() = this override fun copyText() = field.text - - override fun doLayout() { - super.doLayout() - if (code.opts.verticalPolicy != ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER) return - val view = viewport.view ?: return - val size = viewport.extentSize - if (size.height <= 0 || view.height == size.height) return - view.setSize(view.width.coerceAtLeast(size.width), size.height) - } } styleCodePane(pane, opts) sizeCodePane(pane, field) @@ -634,8 +499,39 @@ internal open class MdViewHybrid( } } + private fun codeField(file: FileType, opts: MdStyle, text: String, soft: Boolean, disposable: Disposable) = + CodeField(file, opts, text, soft).also { ed -> + Disposer.register(disposable) { + ed.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) + } + ed.setDisposedWith(disposable) + selection?.register(ed, disposable) + } + + private fun applyEditorChrome(ed: EditorEx, opts: MdStyle, soft: Boolean) { + style.applyToEditor(ed) + ed.setBorder(JBUI.Borders.empty()) + ed.scrollPane.border = JBUI.Borders.empty() + ed.scrollPane.viewportBorder = JBUI.Borders.empty() + ed.backgroundColor = opts.preBg + ed.scrollPane.background = opts.preBg + ed.scrollPane.isOpaque = true + ed.scrollPane.viewport.isOpaque = true + ed.scrollPane.viewport.background = opts.preBg + ed.settings.isUseSoftWraps = soft + ed.settings.isAdditionalPageAtBottom = false + ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + } + + private fun fieldText(component: Component): String = when (component) { + is CodeField -> component.text + is JBTextArea -> component.text + else -> "" + } + private fun sizeCodeField(component: JComponent, text: String) { - val height = codeHeight(component, text) + val height = codeHeight(component, text, null) val width = codeWidth(component, text) component.preferredSize = Dimension(width, height) component.minimumSize = Dimension(0, height) @@ -644,12 +540,8 @@ internal open class MdViewHybrid( private fun sizeCodePane(pane: JBScrollPane, component: JComponent) { val pad = pane.viewportBorder.getBorderInsets(pane) - val text = when (component) { - is CodeField -> component.text - is JBTextArea -> component.text - else -> "" - } - val content = visibleCodeHeight(component, text) + val text = fieldText(component) + val content = codeHeight(component, text, code.opts.maxLines) val height = content + pane.insets.top + pane.insets.bottom + pad.top + pad.bottom + pane.horizontalScrollBar.preferredSize.height pane.preferredSize = Dimension(0, height) @@ -687,35 +579,22 @@ internal open class MdViewHybrid( return width + JBUI.scale(SessionUiStyle.View.Code.WIDTH_PADDING) } - private fun codeHeight(component: JComponent, text: String): Int { + private fun codeHeight(component: JComponent, text: String, max: Int?): Int { val count = text.lineSequence().count() - val rows = count.coerceAtLeast(SessionUiStyle.View.Code.MIN_ROWS) + val base = count.coerceAtLeast(SessionUiStyle.View.Code.MIN_ROWS) + val rows = max?.let { base.coerceAtMost(it) } ?: base val field = component as? CodeField if (field != null) { field.ensureWillComputePreferredSize() val ed = field.getEditor(false) val line = ed?.lineHeight ?: component.getFontMetrics(component.font).height + if (max != null) return line * rows return maxOf(field.preferredSize.height, line * rows) } val line = component.getFontMetrics(component.font).height return line * rows } - private fun visibleCodeHeight(component: JComponent, text: String): Int { - val max = code.opts.maxLines ?: return component.preferredSize.height - val count = text.lineSequence().count() - val rows = count.coerceAtLeast(SessionUiStyle.View.Code.MIN_ROWS).coerceAtMost(max) - val field = component as? CodeField - if (field != null) { - field.ensureWillComputePreferredSize() - val ed = field.getEditor(false) - val line = ed?.lineHeight ?: component.getFontMetrics(component.font).height - return line * rows - } - val line = component.getFontMetrics(component.font).height - return line * rows - } - private fun textArea(text: String, opts: MdStyle, disposable: Disposable) = object : JBTextArea(text.trimEnd('\n')), SessionCopyTarget { override val copyAnchor: JComponent get() = this @@ -753,21 +632,7 @@ internal open class MdViewHybrid( init { setFontInheritedFromLAF(false) font = style.editorFont - addSettingsProvider { ed -> - style.applyToEditor(ed) - ed.setBorder(JBUI.Borders.empty()) - ed.scrollPane.border = JBUI.Borders.empty() - ed.scrollPane.viewportBorder = JBUI.Borders.empty() - ed.backgroundColor = opts.preBg - ed.scrollPane.background = opts.preBg - ed.scrollPane.isOpaque = true - ed.scrollPane.viewport.isOpaque = true - ed.scrollPane.viewport.background = opts.preBg - ed.settings.isUseSoftWraps = soft - ed.settings.isAdditionalPageAtBottom = false - ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER - } + addSettingsProvider { ed -> applyEditorChrome(ed, opts, soft) } } override fun uiDataSnapshot(sink: DataSink) { @@ -782,6 +647,35 @@ internal open class MdViewHybrid( } } + private open inner class CodePane(component: JComponent) : JBScrollPane(component) { + override fun doLayout() { + super.doLayout() + if (code.opts.verticalPolicy != ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER) return + val view = viewport.view ?: return + val size = viewport.extentSize + if (size.height <= 0 || view.height == size.height) return + view.setSize(view.width.coerceAtLeast(size.width), size.height) + } + } + + private inner class Override(private val base: () -> T) { + var value: T? = null + private set + + operator fun getValue(ref: Any?, property: KProperty<*>): T = value ?: base() + + operator fun setValue(ref: Any?, property: KProperty<*>, next: T) { + if (disposed) return + if (value == next) return + value = next + syncStyle() + } + + fun clear() { + value = null + } + } + private fun shellDisplay(term: Term, mode: Mode): ShellDisplay { if (mode == Mode.Shell) return MdShellHighlight.project(term.text) if (mode == Mode.Command) return MdShellHighlight.command(term.text) @@ -858,17 +752,17 @@ internal open class MdViewHybrid( private fun opts(): MdStyle { val base = MdCommon.defaults(style) return base.copy( - font = fontOverride ?: base.font, - foreground = foregroundOverride ?: base.foreground, - background = backgroundOverride ?: base.background, - linkColor = linkColorOverride ?: base.linkColor, - codeBg = codeBgOverride ?: base.codeBg, - preBg = preBgOverride ?: base.preBg, - preFg = preFgOverride ?: base.preFg, - codeFont = codeFontOverride ?: base.codeFont, - quoteBorder = quoteBorderOverride ?: base.quoteBorder, - quoteFg = quoteFgOverride ?: base.quoteFg, - tableBorder = tableBorderOverride ?: base.tableBorder, + font = fontOverride.value ?: base.font, + foreground = foregroundOverride.value ?: base.foreground, + background = backgroundOverride.value ?: base.background, + linkColor = linkColorOverride.value ?: base.linkColor, + codeBg = codeBgOverride.value ?: base.codeBg, + preBg = preBgOverride.value ?: base.preBg, + preFg = preFgOverride.value ?: base.preFg, + codeFont = codeFontOverride.value ?: base.codeFont, + quoteBorder = quoteBorderOverride.value ?: base.quoteBorder, + quoteFg = quoteFgOverride.value ?: base.quoteFg, + tableBorder = tableBorderOverride.value ?: base.tableBorder, opaque = opaqueState, ) } @@ -884,159 +778,8 @@ internal open class MdViewHybrid( return html } - private fun collect(doc: Node): List { - val visitor = Visitor() - doc.accept(visitor) - return visitor.blocks - } - - private fun project(text: String): Projection { - val blocks = mutableListOf() - val html = StringBuilder() - val md = StringBuilder() - val lines = lines(text) - var trailing: Fence? = null - var idx = 0 - - fun flush() { - if (md.isEmpty()) return - val doc = parser.parse(md.toString()) - val descs = collect(doc) - blocks.addAll(descs) - for (desc in descs) { - when (desc) { - is Desc.Html -> html.append(desc.body) - is Desc.Code -> html.append(codeHtml(desc.text)) - is Desc.Table -> html.append(desc.body) - } - } - md.clear() - } - - while (idx < lines.size) { - val line = lines[idx] - val open = opener(line.text) - if (open == null) { - val pending = idx == lines.lastIndex && pendingOpener(line.text) - if (pending) { - flush() - blocks.add(Desc.Code("", Kind.Source(PlainTextFileType.INSTANCE))) - html.append(codeHtml("")) - } else { - md.append(line.text).append(line.end) - } - idx++ - continue - } - - flush() - idx++ - val code = StringBuilder() - var closed = false - var trimmed = false - while (idx < lines.size) { - val item = lines[idx] - val close = closer(item.text, open) - if (close) { - closed = true - idx++ - break - } - val partial = idx == lines.lastIndex && partialCloser(item.text, open) - if (partial) trimmed = true - if (!partial) code.append(item.text).append(item.end) - idx++ - } - val desc = Desc.Code(code.toString(), MdLanguage.kind(open.info)) - blocks.add(desc) - html.append(codeHtml(desc.text)) - trailing = if (!closed && !trimmed) open else null - } - - flush() - return Projection(html.toString(), blocks, trailing) - } - - private fun lines(text: String): List { - if (text.isEmpty()) return emptyList() - val lines = mutableListOf() - var start = 0 - while (start < text.length) { - val end = text.indexOf('\n', start) - if (end == -1) { - lines.add(Line(text.substring(start), "")) - break - } - lines.add(Line(text.substring(start, end), "\n")) - start = end + 1 - } - return lines - } - - private fun opener(text: String): Fence? { - val trimmed = text.dropWhile { it == ' ' } - val indent = text.length - trimmed.length - if (indent > 3) return null - val char = trimmed.firstOrNull() ?: return null - if (char != '`' && char != '~') return null - val size = trimmed.takeWhile { it == char }.length - if (size < 3) return null - val info = trimmed.drop(size).trim() - if (char == '`' && info.contains('`')) return null - return Fence(char, size, info) - } - - private fun closer(text: String, fence: Fence): Boolean { - val trimmed = text.dropWhile { it == ' ' } - val indent = text.length - trimmed.length - if (indent > 3) return false - val size = trimmed.takeWhile { it == fence.char }.length - if (size < fence.size) return false - return trimmed.drop(size).isBlank() - } - - private fun pendingOpener(text: String): Boolean { - val trimmed = text.dropWhile { it == ' ' } - val indent = text.length - trimmed.length - if (indent > 3) return false - val char = trimmed.firstOrNull() ?: return false - if (char != '`' && char != '~') return false - val size = trimmed.takeWhile { it == char }.length - if (size !in 1..2) return false - return trimmed.drop(size).isBlank() - } - - private fun partialCloser(text: String, fence: Fence): Boolean { - val trimmed = text.dropWhile { it == ' ' } - val indent = text.length - trimmed.length - if (indent > 3) return false - val size = trimmed.takeWhile { it == fence.char }.length - if (size !in 1 until fence.size) return false - return trimmed.drop(size).isBlank() - } - - private fun codeHtml(text: String): String = "
${escape(text)}
\n" - - private fun escape(text: String): String = text - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace("\"", """) - - private sealed class Desc { - data class Html(val body: String) : Desc() - data class Code(val text: String, val kind: Kind) : Desc() - data class Table(val body: String) : Desc() - } - - private data class Projection(val html: String, val blocks: List, val open: Fence?) - private data class HtmlCache(val body: String, val color: Int, val html: String) - private data class Line(val text: String, val end: String) - - private data class Fence(val char: Char, val size: Int, val info: String) - private abstract inner class View( var desc: Desc, val component: JComponent, @@ -1111,18 +854,7 @@ internal open class MdViewHybrid( override fun grow(delta: String) { val item = desc as Desc.Code - val next = item.copy(text = item.text + delta) - desc = next - val value = next.text.trimEnd('\n') - val view = pane.viewport.view - when (view) { - is CodeField -> view.text = value - is JBTextArea -> view.text = value - } - if (view is JComponent) { - sizeCodeField(view, value) - sizeCodePane(pane, view) - } + update(item.copy(text = item.text + delta)) } override fun style(opts: MdStyle) { @@ -1132,29 +864,12 @@ internal open class MdViewHybrid( is CodeField -> { view.font = style.editorFont view.background = opts.preBg - view.getEditor(false)?.let { ed -> - style.applyToEditor(ed) - ed.setBorder(JBUI.Borders.empty()) - ed.scrollPane.border = JBUI.Borders.empty() - ed.scrollPane.viewportBorder = JBUI.Borders.empty() - ed.backgroundColor = opts.preBg - ed.scrollPane.background = opts.preBg - ed.scrollPane.isOpaque = true - ed.scrollPane.viewport.isOpaque = true - ed.scrollPane.viewport.background = opts.preBg - ed.settings.isUseSoftWraps = view.soft - ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER - } + view.getEditor(false)?.let { ed -> applyEditorChrome(ed, opts, view.soft) } } is JBTextArea -> styleTextArea(view, opts) } if (view is JComponent) { - val text = when (view) { - is CodeField -> view.text - is JBTextArea -> view.text - else -> "" - } + val text = fieldText(view) sizeCodeField(view, text) sizeCodePane(pane, view) } @@ -1186,20 +901,7 @@ internal open class MdViewHybrid( val kind = item.kind as Kind.Terminal view.font = style.editorFont view.background = opts.preBg - view.getEditor(false)?.let { ed -> - style.applyToEditor(ed) - ed.setBorder(JBUI.Borders.empty()) - ed.scrollPane.border = JBUI.Borders.empty() - ed.scrollPane.viewportBorder = JBUI.Borders.empty() - ed.backgroundColor = opts.preBg - ed.scrollPane.background = opts.preBg - ed.scrollPane.isOpaque = true - ed.scrollPane.viewport.isOpaque = true - ed.scrollPane.viewport.background = opts.preBg - ed.settings.isUseSoftWraps = view.soft - ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER - } + view.getEditor(false)?.let { ed -> applyEditorChrome(ed, opts, view.soft) } val term = MdTerminal.decode(item.text, kind.stream) val value = shellDisplay(term, kind.mode) if (view.text != value.text) view.text = value.text @@ -1213,47 +915,4 @@ internal open class MdViewHybrid( update(item.copy(text = item.text + delta)) } } - - private inner class Visitor : AbstractVisitor() { - val blocks = mutableListOf() - private val run = StringBuilder() - - override fun visit(document: Document) { - visitChildren(document) - flush() - } - - override fun visit(code: FencedCodeBlock) { - flush() - blocks.add(Desc.Code(code.literal, MdLanguage.kind(code.info))) - } - - override fun visit(code: IndentedCodeBlock) { - flush() - blocks.add(Desc.Code(code.literal, MdLanguage.kind(null))) - } - - private fun flush() { - if (run.isEmpty()) return - blocks.add(Desc.Html(run.toString())) - run.clear() - } - - public override fun visitChildren(parent: Node) { - var child = parent.firstChild - while (child != null) { - val next = child.next - when { - child is ThematicBreak -> Unit - child is FencedCodeBlock || child is IndentedCodeBlock -> child.accept(this) - child is TableBlock -> { - flush() - blocks.add(Desc.Table(renderer.render(child))) - } - child is Block -> run.append(renderer.render(child)) - } - child = next - } - } - } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdLanguageTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdLanguageTest.kt new file mode 100644 index 00000000000..7921173f52c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdLanguageTest.kt @@ -0,0 +1,67 @@ +package ai.kilocode.client.ui.md + +import ai.kilocode.client.ui.md.hybrid.Kind +import ai.kilocode.client.ui.md.hybrid.MdLanguage +import ai.kilocode.client.ui.md.hybrid.Mode +import ai.kilocode.client.ui.md.hybrid.Stream +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.fileTypes.FileTypeRegistry +import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.openapi.fileTypes.UnknownFileType +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class MdLanguageTest : BasePlatformTestCase() { + fun `test terminal tags resolve streams and modes`() { + assertKind("ansi", Stream.Stdout, Mode.Ansi) + assertKind("ansi-stdout", Stream.Stdout, Mode.Ansi) + assertKind("terminal", Stream.Stdout, Mode.Ansi) + assertKind("terminal-output", Stream.Stdout, Mode.Ansi) + assertKind("shell-command", Stream.Stdout, Mode.Command) + assertKind("shell-output", Stream.Stdout, Mode.Shell) + assertKind("ansi-stderr", Stream.Stderr, Mode.Ansi) + assertKind("terminal-error", Stream.Stderr, Mode.Ansi) + assertKind("shell-error", Stream.Stderr, Mode.Ansi) + } + + fun `test source aliases resolve file types`() { + mapOf( + "rust" to "rs", + "ruby" to "rb", + "docker" to "dockerfile", + "c++" to "cpp", + "h++" to "hpp", + "csharp" to "cs", + "c#" to "cs", + "fsharp" to "fs", + "f#" to "fs", + "batch" to "bat", + "cmd" to "bat", + "make" to "makefile", + "terraform" to "tf", + "markdown" to "md", + "typescript" to "ts", + "yml" to "yaml", + ).forEach { (lang, ext) -> + assertSame(type(ext), (MdLanguage.kind(lang) as Kind.Source).file) + } + } + + fun `test shell script and metadata are normalized`() { + assertSame(type("sh"), (MdLanguage.kind("shell script") as Kind.Source).file) + assertSame(type("json"), (MdLanguage.kind(" json title=\"sample.json\" ") as Kind.Source).file) + assertKind(" ansi-stdout ignored metadata ", Stream.Stdout, Mode.Ansi) + } + + private fun assertKind(lang: String, stream: Stream, mode: Mode) { + val kind = MdLanguage.kind(lang) as Kind.Terminal + + assertEquals(stream, kind.stream) + assertEquals(mode, kind.mode) + } + + private fun type(ext: String): FileType { + val type = FileTypeRegistry.getInstance().getFileTypeByExtension(ext) + if (type == UnknownFileType.INSTANCE) return PlainTextFileType.INSTANCE + return type + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdProjectorTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdProjectorTest.kt new file mode 100644 index 00000000000..2e34f69dc37 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdProjectorTest.kt @@ -0,0 +1,72 @@ +package ai.kilocode.client.ui.md + +import ai.kilocode.client.ui.md.hybrid.Desc +import ai.kilocode.client.ui.md.hybrid.Kind +import ai.kilocode.client.ui.md.hybrid.MdProjector +import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class MdProjectorTest : BasePlatformTestCase() { + private val projector = MdProjector() + + fun `test prose coalesces and thematic breaks are filtered`() { + val out = projector.project("# Title\n\nfirst\n\n---\n\n- item") + + assertEquals(1, out.blocks.size) + val html = out.blocks.single() as Desc.Html + assertTrue(html.body.contains("

")) + assertTrue(html.body.contains("
    ")) + assertFalse(html.body.contains("val x = 1\n")) + } + + fun `test table is extracted as its own block`() { + val out = projector.project("intro\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\noutro") + + assertTrue(out.blocks[0] is Desc.Html) + assertTrue(out.blocks[1] is Desc.Table) + assertTrue(out.blocks[2] is Desc.Html) + assertTrue((out.blocks[1] as Desc.Table).body.contains("

")) + } + + fun `test partial opener renders empty code block`() { + val out = projector.project("``") + + assertEquals(listOf(Desc.Code("", Kind.Source(PlainTextFileType.INSTANCE))), out.blocks) + assertEquals("
\n", out.html) + assertNull(out.open) + } + + fun `test language prefix split stays out of code text`() { + val out = projector.project("```python\nprint(1)\n") + val code = out.blocks.single() as Desc.Code + + assertEquals("print(1)\n", code.text) + assertFalse(out.html.contains("python")) + assertEquals('`', out.open!!.char) + } + + fun `test partial closer is trimmed and complete closer closes`() { + val partial = projector.project("```python\nprint(1)\n``") + val complete = projector.project("```python\nprint(1)\n```\n\nafter") + + assertEquals("print(1)\n", (partial.blocks.single() as Desc.Code).text) + assertNull(partial.open) + assertEquals(2, complete.blocks.size) + assertEquals("print(1)\n", (complete.blocks[0] as Desc.Code).text) + assertTrue((complete.blocks[1] as Desc.Html).body.contains("after")) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdShellHighlightTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdShellHighlightTest.kt new file mode 100644 index 00000000000..47da0f01e23 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdShellHighlightTest.kt @@ -0,0 +1,50 @@ +package ai.kilocode.client.ui.md + +import ai.kilocode.client.ui.md.hybrid.MdShellHighlight +import ai.kilocode.client.ui.md.hybrid.ShellDisplay +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class MdShellHighlightTest : BasePlatformTestCase() { + fun `test project groups git stat commits and highlights semantic ranges`() { + val display = MdShellHighlight.project( + """ + 475ab514 (HEAD -> main, origin/main) First change + src/App.kt | 2 ++ + 1 file changed, 1 insertion(+), 1 deletion(-) + e8b9785 Second change + src/Other.kt | 7 +++---- + 1 file changed, 3 insertions(+), 1 deletion(-) + + + ...output truncated... + """.trimIndent(), + ) + val spans = spans(display) + + assertTrue(display.text.contains("1 deletion(-)\n\ne8b9785")) + assertTrue(spans.contains("475ab514" to DefaultLanguageHighlighterColors.NUMBER)) + assertTrue(spans.contains("(HEAD -> main, origin/main)" to DefaultLanguageHighlighterColors.KEYWORD)) + assertTrue(spans.contains("1 insertion(+)" to DefaultLanguageHighlighterColors.STRING)) + assertTrue(spans.contains("1 deletion(-)" to DefaultLanguageHighlighterColors.LINE_COMMENT)) + assertTrue(spans.contains("++" to DefaultLanguageHighlighterColors.STRING)) + assertTrue(spans.contains("----" to DefaultLanguageHighlighterColors.LINE_COMMENT)) + assertTrue(spans.contains("" to DefaultLanguageHighlighterColors.DOC_COMMENT)) + assertTrue(spans.contains("...output truncated..." to DefaultLanguageHighlighterColors.KEYWORD)) + } + + fun `test command highlights commands flags strings and env vars`() { + val display = MdShellHighlight.command("FOO=bar; git commit -m 'hello world' --amend") + val spans = spans(display) + + assertTrue(spans.contains("git" to DefaultLanguageHighlighterColors.FUNCTION_CALL)) + assertTrue(spans.contains("-m" to DefaultLanguageHighlighterColors.KEYWORD)) + assertTrue(spans.contains("--amend" to DefaultLanguageHighlighterColors.KEYWORD)) + assertTrue(spans.contains("'hello world'" to DefaultLanguageHighlighterColors.STRING)) + assertTrue(spans.contains("FOO" to DefaultLanguageHighlighterColors.STATIC_FIELD)) + } + + private fun spans(display: ShellDisplay) = display.ranges.map { + display.text.substring(it.start, it.end) to it.key + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdTerminalTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdTerminalTest.kt index c7766d764b1..a5cc144c99e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdTerminalTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdTerminalTest.kt @@ -1,6 +1,8 @@ package ai.kilocode.client.ui.md import ai.kilocode.client.ui.md.hybrid.MdTerminal +import ai.kilocode.client.ui.md.hybrid.Stream +import com.intellij.execution.process.ProcessOutputTypes import com.intellij.testFramework.fixtures.BasePlatformTestCase class MdTerminalTest : BasePlatformTestCase() { @@ -23,4 +25,25 @@ class MdTerminalTest : BasePlatformTestCase() { assertEquals("green", MdTerminal.strip("\u001B[32mgreen\u001B[0m")) assertTrue(MdTerminal.hasAnsi("\u001B[32mgreen\u001B[0m")) } + + fun `test decode produces ranges for sgr coloring`() { + val term = MdTerminal.decode("\u001B[32mgreen\u001B[0m\n", Stream.Stdout) + + assertEquals("green", term.text) + assertTrue(term.ranges.any { term.text.substring(it.start, it.end) == "green" }) + } + + fun `test decode uses stdout and stderr keys`() { + val out = MdTerminal.decode("ok", Stream.Stdout) + val err = MdTerminal.decode("boom", Stream.Stderr) + + assertEquals(ProcessOutputTypes.STDOUT, out.ranges.single().key) + assertEquals(ProcessOutputTypes.STDERR, err.ranges.single().key) + } + + fun `test decode trims trailing newlines`() { + val term = MdTerminal.decode("one\n\n", Stream.Stdout) + + assertEquals("one", term.text) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdTestStyles.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdTestStyles.kt new file mode 100644 index 00000000000..68cfcb90c8a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdTestStyles.kt @@ -0,0 +1,46 @@ +package ai.kilocode.client.ui.md + +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors +import com.intellij.openapi.editor.HighlighterColors +import com.intellij.openapi.editor.colors.CodeInsightColors +import com.intellij.openapi.editor.colors.EditorColors +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.editor.colors.EditorColorsScheme +import com.intellij.openapi.editor.markup.TextAttributes +import java.awt.Color +import java.awt.Font + +internal fun customStyle(): SessionEditorStyle { + val scheme = EditorColorsManager.getInstance().globalScheme.clone() as EditorColorsScheme + scheme.setAttributes( + HighlighterColors.TEXT, + TextAttributes(Color(0x10, 0x20, 0x30), Color(0x01, 0x02, 0x03), null, null, Font.PLAIN), + ) + scheme.setAttributes( + DefaultLanguageHighlighterColors.DOC_COMMENT, + TextAttributes(Color(0x33, 0x44, 0x55), null, null, null, Font.PLAIN), + ) + scheme.setAttributes( + DefaultLanguageHighlighterColors.LINE_COMMENT, + TextAttributes(Color(0x44, 0x55, 0x66), null, null, null, Font.PLAIN), + ) + scheme.setAttributes( + DefaultLanguageHighlighterColors.DOC_CODE_INLINE, + TextAttributes(Color(0xAA, 0xBB, 0xCC), Color(0x11, 0x22, 0x33), null, null, Font.PLAIN), + ) + scheme.setAttributes( + DefaultLanguageHighlighterColors.STRING, + TextAttributes(Color(0xCC, 0x88, 0x66), null, null, null, Font.PLAIN), + ) + scheme.setAttributes( + DefaultLanguageHighlighterColors.DOC_CODE_BLOCK, + TextAttributes(Color(0xDD, 0xEE, 0xFF), Color(0x44, 0x55, 0x66), null, null, Font.PLAIN), + ) + scheme.setAttributes( + CodeInsightColors.HYPERLINK_ATTRIBUTES, + TextAttributes(Color(0x77, 0x88, 0x99), null, null, null, Font.PLAIN), + ) + scheme.setColor(EditorColors.PREVIEW_BORDER_COLOR, Color(0x22, 0x33, 0x44)) + return SessionEditorStyle.create(scheme = scheme, family = "Courier New", size = 21) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt index 3517634cb90..b80b7489614 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 @@ -9,16 +9,10 @@ import com.intellij.execution.ui.ConsoleViewContentType import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.openapi.editor.DefaultLanguageHighlighterColors -import com.intellij.openapi.editor.HighlighterColors -import com.intellij.openapi.editor.colors.CodeInsightColors -import com.intellij.openapi.editor.colors.EditorColors -import com.intellij.openapi.editor.colors.EditorColorsManager -import com.intellij.openapi.editor.colors.EditorColorsScheme import com.intellij.openapi.fileTypes.FileType import com.intellij.openapi.fileTypes.FileTypeRegistry import com.intellij.openapi.fileTypes.PlainTextFileType import com.intellij.openapi.fileTypes.UnknownFileType -import com.intellij.openapi.editor.markup.TextAttributes import com.intellij.openapi.ide.CopyPasteManager import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase @@ -29,16 +23,16 @@ import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import java.awt.BorderLayout import java.awt.Color -import java.awt.Font import java.awt.Point +import java.awt.datatransfer.DataFlavor import java.awt.event.MouseEvent +import java.net.URI import javax.swing.Box import javax.swing.JPanel import javax.swing.ScrollPaneConstants import javax.swing.event.HyperlinkEvent import javax.swing.text.html.HTML import javax.swing.text.html.HTMLDocument -import java.awt.datatransfer.DataFlavor @Suppress("UnstableApiUsage") class MdViewHybridTest : BasePlatformTestCase() { @@ -991,6 +985,25 @@ class MdViewHybridTest : BasePlatformTestCase() { assertEquals("https://example.com", received.single().href) } + fun `test link listener receives activated prose link with component`() { + val received = mutableListOf() + view.addLinkListener { received.add(it) } + view.set("See [docs](https://example.com)") + val pane = htmls().single() + val event = HyperlinkEvent( + pane, + HyperlinkEvent.EventType.ACTIVATED, + URI("https://example.com").toURL(), + "https://example.com", + ) + + pane.hyperlinkListeners.forEach { it.hyperlinkUpdate(event) } + + val link = received.single() + assertEquals("https://example.com", link.href) + assertSame(pane, link.component) + } + fun `test markdown root and code child expose selection copy provider`() { Disposer.dispose(view) disposed = true @@ -1018,6 +1031,26 @@ class MdViewHybridTest : BasePlatformTestCase() { } } + fun `test setSelection resyncs blocks and code child exposes selection copy provider`() { + view.set("```text\nalpha code\n```") + val old = editors().single().getEditor(true)!! + val selection = SessionSelection() + try { + view.setSelection(selection) + drainEdt() + val field = editors().single() + val child = CopyProviderSink() + + (field as UiDataProvider).uiDataSnapshot(child) + + assertTrue(old.isDisposed) + assertNotNull(child.copy) + assertEquals("alpha code", field.text) + } finally { + selection.dispose() + } + } + private fun scrolls(): List = (view.component as JPanel).components.filterIsInstance() private fun htmls(): List = (view.component as JPanel).components.filterIsInstance() @@ -1045,37 +1078,4 @@ class MdViewHybridTest : BasePlatformTestCase() { UIUtil.dispatchAllInvocationEvents() } - private fun customStyle(): SessionEditorStyle { - val scheme = EditorColorsManager.getInstance().globalScheme.clone() as EditorColorsScheme - scheme.setAttributes( - HighlighterColors.TEXT, - TextAttributes(Color(0x10, 0x20, 0x30), Color(0x01, 0x02, 0x03), null, null, Font.PLAIN), - ) - scheme.setAttributes( - DefaultLanguageHighlighterColors.DOC_COMMENT, - TextAttributes(Color(0x33, 0x44, 0x55), null, null, null, Font.PLAIN), - ) - scheme.setAttributes( - DefaultLanguageHighlighterColors.LINE_COMMENT, - TextAttributes(Color(0x44, 0x55, 0x66), null, null, null, Font.PLAIN), - ) - scheme.setAttributes( - DefaultLanguageHighlighterColors.DOC_CODE_INLINE, - TextAttributes(Color(0xAA, 0xBB, 0xCC), Color(0x11, 0x22, 0x33), null, null, Font.PLAIN), - ) - scheme.setAttributes( - DefaultLanguageHighlighterColors.STRING, - TextAttributes(Color(0xCC, 0x88, 0x66), null, null, null, Font.PLAIN), - ) - scheme.setAttributes( - DefaultLanguageHighlighterColors.DOC_CODE_BLOCK, - TextAttributes(Color(0xDD, 0xEE, 0xFF), Color(0x44, 0x55, 0x66), null, null, Font.PLAIN), - ) - scheme.setAttributes( - CodeInsightColors.HYPERLINK_ATTRIBUTES, - TextAttributes(Color(0x77, 0x88, 0x99), null, null, null, Font.PLAIN), - ) - scheme.setColor(EditorColors.PREVIEW_BORDER_COLOR, Color(0x22, 0x33, 0x44)) - return SessionEditorStyle.create(scheme = scheme, family = "Courier New", size = 21) - } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt index 16ba83868a7..598483cfe15 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt @@ -2,20 +2,13 @@ package ai.kilocode.client.ui.md import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle -import com.intellij.openapi.editor.DefaultLanguageHighlighterColors -import com.intellij.openapi.editor.HighlighterColors -import com.intellij.openapi.editor.colors.CodeInsightColors -import com.intellij.openapi.editor.colors.EditorColors -import com.intellij.openapi.editor.colors.EditorColorsManager -import com.intellij.openapi.editor.colors.EditorColorsScheme -import com.intellij.openapi.editor.markup.TextAttributes import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import java.awt.Color import java.awt.Font /** - * Tests for the fallback HTML [MdView]. + * Tests for the hybrid markdown renderer's HTML and CSS output. * * Uses [BasePlatformTestCase] to get a real IntelliJ Application so that * JBHtmlPane initialisation works correctly. @@ -568,38 +561,4 @@ class MdViewTest : BasePlatformTestCase() { assertTrue(view.html().contains("")) } - private fun customStyle(): SessionEditorStyle { - val scheme = EditorColorsManager.getInstance().globalScheme.clone() as EditorColorsScheme - scheme.setAttributes( - HighlighterColors.TEXT, - TextAttributes(Color(0x10, 0x20, 0x30), Color(0x01, 0x02, 0x03), null, null, Font.PLAIN), - ) - scheme.setAttributes( - DefaultLanguageHighlighterColors.DOC_COMMENT, - TextAttributes(Color(0x33, 0x44, 0x55), null, null, null, Font.PLAIN), - ) - scheme.setAttributes( - DefaultLanguageHighlighterColors.LINE_COMMENT, - TextAttributes(Color(0x44, 0x55, 0x66), null, null, null, Font.PLAIN), - ) - scheme.setAttributes( - DefaultLanguageHighlighterColors.DOC_CODE_INLINE, - TextAttributes(Color(0xAA, 0xBB, 0xCC), Color(0x11, 0x22, 0x33), null, null, Font.PLAIN), - ) - scheme.setAttributes( - DefaultLanguageHighlighterColors.STRING, - TextAttributes(Color(0xCC, 0x88, 0x66), null, null, null, Font.PLAIN), - ) - scheme.setAttributes( - DefaultLanguageHighlighterColors.DOC_CODE_BLOCK, - TextAttributes(Color(0xDD, 0xEE, 0xFF), Color(0x44, 0x55, 0x66), null, null, Font.PLAIN), - ) - scheme.setAttributes( - CodeInsightColors.HYPERLINK_ATTRIBUTES, - TextAttributes(Color(0x77, 0x88, 0x99), null, null, null, Font.PLAIN), - ) - scheme.setColor(EditorColors.PREVIEW_BORDER_COLOR, Color(0x22, 0x33, 0x44)) - return SessionEditorStyle.create(scheme = scheme, family = "Courier New", size = 21) - } - } From 166fe23ca46908e5a49f05f60efffc9abffe7ddf Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 3 Jul 2026 21:48:54 -0400 Subject: [PATCH 03/19] fix(jetbrains): preserve collapsed subagent views --- .changeset/jetbrains-inline-subagents.md | 5 + .../session/controller/SessionController.kt | 38 +- .../kilocode/client/session/model/Message.kt | 2 + .../client/session/model/SessionModel.kt | 70 +++- .../client/session/ui/style/SessionUiStyle.kt | 1 + .../client/session/views/ViewFactory.kt | 4 + .../client/session/views/tool/TaskToolView.kt | 359 ++++++++++++++++++ .../resources/messages/KiloBundle.properties | 1 + .../session/controller/PromptLifecycleTest.kt | 91 ++++- .../session/ui/SessionMessageListPanelTest.kt | 38 ++ .../client/session/views/TaskToolViewTest.kt | 166 ++++++++ .../client/testing/FakeSessionRpcApi.kt | 3 +- 12 files changed, 771 insertions(+), 7 deletions(-) create mode 100644 .changeset/jetbrains-inline-subagents.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt diff --git a/.changeset/jetbrains-inline-subagents.md b/.changeset/jetbrains-inline-subagents.md new file mode 100644 index 00000000000..4928ad409bd --- /dev/null +++ b/.changeset/jetbrains-inline-subagents.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show subagent tool activity inline in JetBrains session transcripts. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index e594cc96575..b6639dc6bca 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -894,7 +894,7 @@ class SessionController( val job = cs.launch { try { sessions.events(child, directory).collect { event -> - if (!isChildPermissionEvent(event, child)) return@collect + if (!isChildEvent(event, child)) return@collect LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-event child=$child ${ChatLogSummary.eventBody(event)}" } updates.enqueue(event) } @@ -914,6 +914,7 @@ class SessionController( assertEdt() if (!childIds.add(child)) return subscribeChild(child) + cs.launch { seedChild(child) } cs.launch { recoverChildPermissions(child) } } @@ -948,6 +949,27 @@ class SessionController( } } + private suspend fun seedChild(child: String) { + try { + val items = sessions.messages(child, directory) + runEdt { + if (disposed) return@runEdt + updateModel { + for (msg in items) { + if (msg.info.role != "assistant") continue + for (part in msg.parts) { + if (part.type == "tool") model.upsertChildTool(child, part, replace = false) + } + } + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + LOG.warn("${ChatLogSummary.sid(sid ?: "pending")} kind=child-history child=$child dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) + } + } + /** Rehydrate pending permissions/questions and current session status after history load. */ private suspend fun recoverPending(id: String) { try { @@ -1026,6 +1048,10 @@ class SessionController( } is ChatEventDto.PartUpdated -> { + if (childIds.contains(event.sessionID)) { + if (event.part.type == "tool") model.upsertChildTool(event.sessionID, event.part) + return + } partType = event.part.type tool = event.part.tool val key = PartKey(event.part.messageID, event.part.id) @@ -1051,6 +1077,10 @@ class SessionController( } is ChatEventDto.PartRemoved -> { + if (childIds.contains(event.sessionID)) { + model.removeChildTool(event.sessionID, event.partID) + return + } snapshots.remove(PartKey(event.messageID, event.partID)) model.removeContent(event.messageID, event.partID) } @@ -1918,8 +1948,10 @@ private fun childID(part: PartDto): String? { return part.metadata["sessionId"] } -/** Returns true when [event] is a permission event for [child] (used by child subscriptions). */ -private fun isChildPermissionEvent(event: ChatEventDto, child: String): Boolean = when (event) { +/** Returns true when [event] should be routed from a child subscription. */ +private fun isChildEvent(event: ChatEventDto, child: String): Boolean = when (event) { + is ChatEventDto.PartUpdated -> event.sessionID == child + is ChatEventDto.PartRemoved -> event.sessionID == child is ChatEventDto.PermissionAsked -> event.sessionID == child is ChatEventDto.PermissionReplied -> event.sessionID == child else -> false diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt index e2ab924b547..7eeaa951500 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt @@ -82,6 +82,8 @@ class Tool(id: String, val name: String, var kind: ToolKind) : Content(id) { var title: String? = null var input: Map = emptyMap() var metadata: Map = emptyMap() + var childSessionId: String? = null + var childTools: List = emptyList() var output: String? = null var error: String? = null var time: PartTimeDto? = null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index 5aedad3b1f7..f483c1983d6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -47,6 +47,8 @@ class SessionModel { private val entries = LinkedHashMap() private val turnEntries = LinkedHashMap() private val hiddenText = mutableSetOf>() + private val childRefs = HashMap() + private val childTools = HashMap>() var app: KiloAppStateDto = KiloAppStateDto(KiloAppStatusDto.DISCONNECTED) var version: String? = null @@ -145,7 +147,8 @@ class SessionModel { @RequiresEdt fun removeMessage(id: String) { - if (entries.remove(id) == null) return + val msg = entries.remove(id) ?: return + for (part in msg.parts.values) untrackChild(part) hiddenText.removeAll { it.first == id } fire(SessionModelEvent.MessageRemoved(id)) regroup() @@ -156,7 +159,8 @@ class SessionModel { fun removeContent(messageId: String, contentId: String) { hiddenText.remove(messageId to contentId) val msg = entries[messageId] ?: return - if (msg.parts.remove(contentId) == null) return + val old = msg.parts.remove(contentId) ?: return + untrackChild(old) fire(SessionModelEvent.ContentRemoved(messageId, contentId)) updateHeader() } @@ -186,10 +190,38 @@ class SessionModel { } val content = fromDto(dto) msg.parts[dto.id] = content + trackChild(messageId, content) fire(SessionModelEvent.ContentAdded(messageId, content)) updateHeader() } + @RequiresEdt + fun upsertChildTool(child: String, dto: PartDto, replace: Boolean = true) { + if (dto.type != "tool") return + val ref = childRefs[child] ?: return + val msg = entries[ref.messageId] ?: return + val parent = msg.parts[ref.partId] as? Tool ?: return + val tool = fromDto(dto) as? Tool ?: return + val tools = childTools.getOrPut(child) { LinkedHashMap() } + if (!replace && tools.containsKey(dto.id)) return + tools[dto.id] = tool + parent.childTools = tools.values.toList() + fire(SessionModelEvent.ContentUpdated(ref.messageId, parent)) + updateHeader() + } + + @RequiresEdt + fun removeChildTool(child: String, partId: String) { + val ref = childRefs[child] ?: return + val tools = childTools[child] ?: return + if (tools.remove(partId) == null) return + val msg = entries[ref.messageId] ?: return + val parent = msg.parts[ref.partId] as? Tool ?: return + parent.childTools = tools.values.toList() + fire(SessionModelEvent.ContentUpdated(ref.messageId, parent)) + updateHeader() + } + @RequiresEdt fun appendDelta(messageId: String, contentId: String, delta: String) { val msg = entries[messageId] ?: return @@ -257,6 +289,8 @@ class SessionModel { @RequiresEdt fun loadHistory(history: List) { entries.clear() + childRefs.clear() + childTools.clear() hiddenText.clear() session = null state = SessionState.Idle @@ -274,6 +308,7 @@ class SessionModel { if (empty(part)) continue val content = fromDto(part, part.text) item.parts[content.id] = content + trackChild(msg.info.id, content) } entries[msg.info.id] = item } @@ -286,6 +321,8 @@ class SessionModel { fun clear() { entries.clear() turnEntries.clear() + childRefs.clear() + childTools.clear() hiddenText.clear() session = null state = SessionState.Idle @@ -410,17 +447,24 @@ class SessionModel { existing.source = dto.source } is Tool -> { + val old = existing.childSessionId existing.kind = toolKind(dto.tool) existing.state = parseToolState(dto.state) existing.callId = dto.callID existing.title = dto.title existing.input = dto.input existing.metadata = dto.metadata + existing.childSessionId = childID(existing) + if (old != null && old != existing.childSessionId) { + childRefs.remove(old) + childTools.remove(old) + } existing.output = dto.output existing.error = dto.error existing.time = dto.time existing.todos = dto.todos existing.todoView = dto.todoView + trackChild(messageId, existing) } is Compaction -> return is StepFinish -> { @@ -461,6 +505,7 @@ class SessionModel { title = dto.title input = dto.input metadata = dto.metadata + childSessionId = childID(this) output = dto.output error = dto.error time = dto.time @@ -481,6 +526,20 @@ class SessionModel { for (l in listeners) l.onEvent(event) } + private fun trackChild(messageId: String, content: Content) { + val tool = content as? Tool ?: return + val child = tool.childSessionId ?: return + childRefs[child] = ChildRef(messageId, tool.id) + tool.childTools = childTools[child]?.values?.toList() ?: emptyList() + } + + private fun untrackChild(content: Content) { + val tool = content as? Tool ?: return + val child = tool.childSessionId ?: return + childRefs.remove(child) + childTools.remove(child) + } + private fun updateHeader() { val next = buildHeader() if (next == header) return @@ -599,6 +658,13 @@ private fun parseToolState(raw: String?): ToolExecState = when (raw) { else -> ToolExecState.PENDING } +private data class ChildRef(val messageId: String, val partId: String) + +private fun childID(tool: Tool): String? { + if (tool.name != "task") return null + return tool.metadata["sessionId"] +} + data class AgentItem( val name: String, val display: String, 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 4b0b665e165..5071f431b6d 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 @@ -165,6 +165,7 @@ object SessionUiStyle { /** Tool session-view preview limits and state colors. */ object Tool { const val BODY_LINES = 15 + const val TASK_LINES = 10 const val PREVIEW_LIMIT = 20_000 fun pending(): Color = UiStyle.Colors.weak() 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 10886c589c0..68c06d142a3 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 @@ -8,6 +8,7 @@ import ai.kilocode.client.session.views.tool.GlobToolView import ai.kilocode.client.session.views.tool.ReadToolView import ai.kilocode.client.session.views.tool.SearchToolView import ai.kilocode.client.session.views.tool.ShellToolView +import ai.kilocode.client.session.views.tool.TaskToolView import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.model.Compaction @@ -53,6 +54,7 @@ object ViewFactory { 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) + TaskToolView.canRender(content) -> TaskToolView(content, selection = selection) else -> ToolView(content, selection = selection) } is Compaction -> CompactionView(content) @@ -98,6 +100,8 @@ object ViewFactory { 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 TaskToolView) return !TaskToolView.canRender(content) || QuestionResultView.canRender(content) + if (view !is TaskToolView && TaskToolView.canRender(content)) return true if (view is ToolView) return QuestionResultView.canRender(content) return false } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt new file mode 100644 index 00000000000..1bbe202f1cb --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt @@ -0,0 +1,359 @@ +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.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 ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.StackAxis +import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.UiDataProvider +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBDimension +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.Dimension +import java.awt.Point +import java.awt.Rectangle +import javax.swing.JPanel +import javax.swing.ScrollPaneConstants +import javax.swing.Scrollable +import javax.swing.SwingUtilities +import kotlin.math.abs + +class TaskToolView( + tool: Tool, + private val selection: SessionSelection? = null, + private val parts: ToolParts = toolParts(tool), +) : SecondarySessionPartView(parts.header, { TaskBody(parts.glyph).scroll }), UiDataProvider { + + override val contentId: String = tool.id + + private var item = tool + private var style = SessionEditorStyle.current() + private val rows = LinkedHashMap() + private var following = false + private var collapsed = false + + init { + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) + applyStyle(style) + sync() + if (item.childTools.isNotEmpty()) expand() + } + + override fun uiDataSnapshot(sink: DataSink) { + selection?.provideCopy(sink) { copyText() } + } + + @RequiresEdt + override fun update(content: Content) { + if (content !is Tool) return + val fresh = item.childTools.isEmpty() && content.childTools.isNotEmpty() + item = content + val follow = tailVisible() + var changed = sync() + changed = syncRows() || changed + if (content.childTools.isNotEmpty() && !collapsed) changed = expand() || changed + followTail(follow || fresh) + if (changed) refresh() + } + + @RequiresEdt + override fun expand(): Boolean { + collapsed = false + val changed = super.expand() + syncRows() + return changed + } + + @RequiresEdt + override fun collapse(): Boolean { + if (item.childTools.isNotEmpty() && isExpanded()) collapsed = true + return super.collapse() + } + + @RequiresEdt + fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text) + .filter { it.isNotBlank() } + .joinToString(" ") + + @RequiresEdt + fun rowCount(): Int = rows.size + + @RequiresEdt + fun rowLabels(): List = rows.values.map { row -> row.text() } + + @RequiresEdt + fun bodyCreated(): Boolean = hasBody() + + @RequiresEdt + fun bodyVisible(): Boolean = isExpanded() + + @RequiresEdt + fun controlCount(): Int = if (arrow.isVisible) 1 else 0 + @RequiresEdt + internal fun bodyMaxRows() = SessionUiStyle.View.Tool.TASK_LINES + @RequiresEdt + internal fun bodyScrollValue() = taskBodyOrNull()?.verticalScrollBar?.value ?: 0 + @RequiresEdt + internal fun bodyScrollBottom() = taskBodyOrNull()?.let(::bottom) ?: 0 + @RequiresEdt + internal fun setBodyScrollValue(value: Int) { + taskBodyOrNull()?.verticalScrollBar?.value = value + } + @RequiresEdt + internal fun horizontalPolicy() = taskBodyOrNull()?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + @RequiresEdt + internal fun verticalPolicy() = taskBodyOrNull()?.verticalScrollBarPolicy ?: ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + @RequiresEdt + internal fun bodyInsets() = taskBody().panel.border.getBorderInsets(taskBody().panel) + @RequiresEdt + internal fun rowTitleColor(id: String) = rows[id]?.title?.foreground + + @RequiresEdt + override fun applyStyle(style: SessionEditorStyle) { + this.style = style + var changed = false + changed = setFont(parts.title, style.boldEditorFont) || changed + changed = setFont(parts.sub, style.smallEditorFont) || changed + changed = setFont(parts.state, style.smallEditorFont) || changed + for (row in rows.values) changed = row.applyStyle(style) || changed + if (changed) refresh() + } + + @RequiresEdt + 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)) + } + + private fun sync(): Boolean { + var changed = false + changed = syncExpandable(item.childTools.isNotEmpty()) || changed + changed = setVisible(parts.state, item.childTools.isEmpty()) || changed + changed = setIcon(parts.glyph, icon(item)) || changed + changed = setForeground(parts.glyph, color(item)) || changed + changed = setText(parts.title, agentTitle(item)) || changed + changed = setText(parts.sub, summary(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 syncRows(): Boolean { + if (!hasBody()) return false + val body = taskBody() + var changed = false + val ids = item.childTools.map { tool -> tool.id }.toSet() + val stale = rows.keys.filter { id -> id !in ids } + for (id in stale) { + val row = rows.remove(id) ?: continue + body.rows.remove(row.panel) + changed = true + } + for (tool in item.childTools) { + val row = rows[tool.id] + if (row == null) { + val next = Row(tool).also { it.applyStyle(style) } + rows[tool.id] = next + body.rows.next(next.panel) + changed = true + continue + } + changed = row.update(tool) || changed + } + if (changed) { + body.rows.revalidate() + body.rows.repaint() + } + return changed + } + + private fun taskBody() = bodyComponent() as TaskBodyScroll + + private fun taskBodyOrNull() = if (hasBody()) bodyComponent() as? TaskBodyScroll else null + + private fun bodyMaxHeight(): Int { + val body = taskBodyOrNull() ?: return 0 + val height = rows.values.firstOrNull()?.panel?.getFontMetrics(style.smallEditorFont)?.height + ?: body.rows.getFontMetrics(style.smallEditorFont).height + return height * bodyMaxRows() + JBUI.scale(SessionUiStyle.View.Layout.BODY_EXTRA_HEIGHT) + } + + @RequiresEdt + private fun tailVisible(): Boolean { + if (!bodyVisible()) return false + val scroll = taskBodyOrNull() ?: return false + val bar = scroll.verticalScrollBar + val bottom = bar.maximum - bar.visibleAmount + return bottom > 0 && abs(bar.value - bottom) <= UiStyle.Gap.pad() + } + + @RequiresEdt + private fun followTail(follow: Boolean) { + if (!follow || !bodyVisible() || following) return + val scroll = taskBodyOrNull() ?: return + following = true + SwingUtilities.invokeLater { followPass(scroll, 4) } + } + + @RequiresEdt + private fun followPass(scroll: JBScrollPane, passes: Int) { + if (!bodyVisible()) { + following = false + return + } + val view = scroll.viewport.view + view?.setSize(scroll.viewport.extentSize.width.coerceAtLeast(1), view.preferredSize.height) + view?.doLayout() + scroll.viewport.doLayout() + scroll.doLayout() + scroll.viewport.viewPosition = Point(0, bottom(scroll)) + scroll.verticalScrollBar.value = bottom(scroll) + if (passes <= 0 || scroll.verticalScrollBar.value == bottom(scroll)) { + following = false + return + } + SwingUtilities.invokeLater { followPass(scroll, passes - 1) } + } + + private fun bottom(scroll: JBScrollPane): Int { + val view = scroll.viewport.view ?: return 0 + return maxOf(0, view.height - scroll.viewport.extentSize.height) + } + + private fun copyText(): String = buildString { + append(agentTitle(item)) + val desc = item.input["description"].orEmpty() + if (desc.isNotBlank()) append(" - ").append(desc) + for (tool in item.childTools) { + append('\n') + append(title(tool)) + val sub = subtitle(tool) + if (sub.isNotBlank()) append(' ').append(sub) + } + } + + private class Row(tool: Tool) { + private var item = tool + val icon = JBLabel() + val title = JBLabel() + val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } + val panel = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { + isOpaque = false + add(icon, BorderLayout.WEST) + add(JPanel(BorderLayout(UiStyle.Gap.sm(), 0)).apply { + isOpaque = false + add(title, BorderLayout.WEST) + add(sub, BorderLayout.CENTER) + }, BorderLayout.CENTER) + } + + @RequiresEdt + fun update(tool: Tool): Boolean { + item = tool + var changed = false + changed = setIcon(icon, icon(tool)) || changed + changed = setForeground(icon, color(tool)) || changed + changed = setText(title, title(tool)) || changed + changed = setForeground(title, rowTitleColor(tool)) || changed + changed = setText(sub, subtitle(tool)) || changed + return changed + } + + @RequiresEdt + fun applyStyle(style: SessionEditorStyle): Boolean { + var changed = false + changed = setFont(title, style.boldEditorFont) || changed + changed = setFont(sub, style.smallEditorFont) || changed + return update(item) || changed + } + + @RequiresEdt + fun text(): String = listOf(title.text, sub.text).filter { it.isNotBlank() }.joinToString(" ") + } + + override fun dumpLabel() = "TaskToolView#$contentId(${labelText()})" + + companion object { + fun canRender(content: Tool): Boolean = content.name == "task" + } +} + +private class TaskBody(glyph: JBLabel) { + val rows = TaskRows() + val panel = JPanel(BorderLayout()).apply { + isOpaque = true + background = SessionUiStyle.View.Surface.bgColor() + border = JBUI.Borders.empty( + UiStyle.Gap.sm(), + glyph.preferredSize.width + JBUI.scale(SessionUiStyle.View.Layout.GAP) + UiStyle.Gap.md(), + UiStyle.Gap.sm(), + UiStyle.Gap.md(), + ) + add(rows, BorderLayout.CENTER) + } + val scroll = TaskBodyScroll(this) +} + +private class TaskBodyScroll(val body: TaskBody) : JBScrollPane(body.panel) { + val rows: Stack get() = body.rows + val panel: JPanel get() = body.panel + + init { + border = JBUI.Borders.empty() + isOpaque = true + background = SessionUiStyle.View.Surface.bgColor() + viewport.background = SessionUiStyle.View.Surface.bgColor() + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + } +} + +private class TaskRows : Stack(StackAxis.VERTICAL, UiStyle.Gap.sm()), Scrollable { + override fun getScrollableTracksViewportWidth() = true + override fun getScrollableTracksViewportHeight() = false + override fun getPreferredScrollableViewportSize(): Dimension = preferredSize + override fun getScrollableUnitIncrement( + visibleRect: Rectangle, + orientation: Int, + direction: Int, + ) = JBUI.scale(SessionUiStyle.SessionLayout.SCROLL_INCREMENT) + override fun getScrollableBlockIncrement( + visibleRect: Rectangle, + orientation: Int, + direction: Int, + ) = visibleRect.height + + override fun getMaximumSize() = JBDimension(Int.MAX_VALUE, super.getMaximumSize().height) +} + +private fun rowTitleColor(tool: Tool) = if (tool.state == ToolExecState.ERROR) { + UiStyle.Colors.errorLabelForeground() +} else { + UiStyle.Colors.weak() +} + +private fun agentTitle(tool: Tool): String { + val type = tool.input["subagent_type"]?.takeIf { it.isNotBlank() } ?: tool.name + return KiloBundle.message("session.part.tool.agent", type.replaceFirstChar { it.titlecase() }) +} + +private fun summary(tool: Tool): String { + val desc = tool.input["description"].orEmpty() + val count = tool.childTools.size + if (count <= 0) return desc + if (desc.isBlank()) return "($count)" + return "$desc ($count)" +} 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 811f29dc0a4..c64398d56ec 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -93,6 +93,7 @@ session.part.reasoning=Reasoning session.part.compaction=context compacted session.part.tool.copy=Copy session.part.tool.error=Error +session.part.tool.agent={0} Agent session.part.tool.pending=Pending session.part.tool.read=Read session.part.tool.glob=Glob diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt index 7d94f601a8c..b7c80275fef 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt @@ -1,12 +1,17 @@ package ai.kilocode.client.session.controller import ai.kilocode.client.plugin.KiloPluginSettings +import ai.kilocode.client.session.SessionRef import ai.kilocode.client.session.model.PermissionFileDiff import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.SessionState -import ai.kilocode.client.session.SessionRef +import ai.kilocode.client.session.model.Tool import ai.kilocode.rpc.dto.AgentDto import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.ModelDto import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.PartSourceDto @@ -23,6 +28,7 @@ import ai.kilocode.rpc.dto.QuestionReplyDto import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.ToolRefDto import java.util.concurrent.CopyOnWriteArrayList +import kotlinx.coroutines.CompletableDeferred class PromptLifecycleTest : SessionControllerTestBase() { @@ -626,6 +632,78 @@ class PromptLifecycleTest : SessionControllerTestBase() { assertTrue("Root state must not be changed by child non-permission events", stateEvents.isEmpty()) } + fun `test child tool update is stored on parent task part`() { + val (m, _, modelEvents) = prompted() + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false) + emit(taskPart("ses_child"), flush = false) + emit(ChatEventDto.PartUpdated("ses_child", childTool("child_read", "read"))) + + val task = m.model.content("msg1", "part_task") as Tool + assertEquals("ses_child", task.childSessionId) + assertEquals(1, task.childTools.size) + assertEquals("read", task.childTools[0].name) + assertNull(m.model.content("child_msg", "child_read")) + assertTrue(modelEvents.any { it.toString() == "ContentUpdated msg1/part_task" }) + } + + fun `test child tool removed updates parent task part`() { + val (m, _, _) = prompted() + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false) + emit(taskPart("ses_child"), flush = false) + emit(ChatEventDto.PartUpdated("ses_child", childTool("child_read", "read")), flush = false) + emit(ChatEventDto.PartRemoved("ses_child", "child_msg", "child_read")) + + val task = m.model.content("msg1", "part_task") as Tool + assertTrue(task.childTools.isEmpty()) + } + + fun `test history load backfills child tools`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady() + rpc.histories["ses_test"] = mutableListOf( + MessageWithPartsDto( + msg("msg1", "ses_test", "assistant"), + listOf(taskPart("ses_child").part), + ), + ) + rpc.histories["ses_child"] = mutableListOf( + MessageWithPartsDto( + msg("child_msg", "ses_child", "assistant"), + listOf(childTool("child_read", "read"), childTool("child_grep", "grep")), + ), + ) + + val m = controller("ses_test") + flush() + + val task = m.model.content("msg1", "part_task") as Tool + assertEquals(listOf("read", "grep"), task.childTools.map { it.name }) + assertNull(m.model.content("child_msg", "child_read")) + } + + fun `test stale child history does not overwrite live child tool update`() { + rpc.historyGate = CompletableDeferred() + rpc.histories["ses_child"] = mutableListOf( + MessageWithPartsDto( + msg("child_msg", "ses_child", "assistant"), + listOf(childTool("child_read", "grep")), + ), + ) + val (m, _, _) = prompted() + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false) + emit(taskPart("ses_child"), flush = false) + emit(ChatEventDto.PartUpdated("ses_child", childTool("child_read", "read"))) + + var task = m.model.content("msg1", "part_task") as Tool + assertEquals(listOf("read"), task.childTools.map { it.name }) + + rpc.historyGate!!.complete(Unit) + flush() + + task = m.model.content("msg1", "part_task") as Tool + assertEquals(listOf("read"), task.childTools.map { it.name }) + } + fun `test root permission event is not processed as child permission`() { val (m, _, _) = prompted() @@ -646,9 +724,20 @@ class PromptLifecycleTest : SessionControllerTestBase() { type = "tool", tool = "task", metadata = mapOf("sessionId" to childSessionId), + input = mapOf("subagent_type" to "explore", "description" to "Find files"), ), ) + private fun childTool(id: String, name: String) = PartDto( + id = id, + sessionID = "ses_child", + messageID = "child_msg", + type = "tool", + tool = name, + state = "completed", + input = mapOf("filePath" to "src/Main.kt", "pattern" to "query"), + ) + private fun childPermission(id: String) = PermissionRequestDto( id = id, sessionID = "ses_child", 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 c2287d31ee5..7e725241cb1 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 @@ -20,6 +20,7 @@ import ai.kilocode.client.session.views.MessageToolbar import ai.kilocode.client.session.views.MessageView import ai.kilocode.client.session.views.TextView import ai.kilocode.client.session.views.base.PartView +import ai.kilocode.client.session.views.tool.TaskToolView import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.session.views.todo.TodoWriteView import ai.kilocode.rpc.dto.MessageDto @@ -376,6 +377,33 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertTrue(mv.partIds().isEmpty()) } + fun `test child tool update refreshes collapsed task view without replacing it`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent( + "a1", + toolPart( + "part_task", + "a1", + "task", + "call_task", + input = mapOf("subagent_type" to "explore", "description" to "Find files"), + metadata = mapOf("sessionId" to "ses_child"), + ), + ) + model.upsertChildTool("ses_child", childTool("child_read", "read")) + val view = panel.findMessage("a1")!!.part("part_task") as TaskToolView + + assertTrue(view.bodyVisible()) + view.collapse() + model.upsertChildTool("ses_child", childTool("child_read", "grep")) + + val updated = panel.findMessage("a1")!!.part("part_task") as TaskToolView + assertSame(view, updated) + assertFalse(updated.bodyVisible()) + assertTrue(updated.rowLabels().single().contains("Grep")) + assertTrue(updated.rowLabels().single().contains("pattern=query")) + } + // ------ HistoryLoaded ------ fun `test HistoryLoaded rebuilds panel from scratch`() { @@ -804,6 +832,16 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { input = input, metadata = metadata, todos = todos, ) + private fun childTool(id: String, tool: String) = PartDto( + id = id, + sessionID = "ses_child", + messageID = "child_msg", + type = "tool", + tool = tool, + state = "completed", + input = mapOf("filePath" to "src/Main.kt", "pattern" to "query"), + ) + private fun root(view: QuestionResultView) = view.components[0] as JPanel private fun header(view: QuestionResultView) = root(view).components[0] as JPanel diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt new file mode 100644 index 00000000000..eb6d0beb058 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt @@ -0,0 +1,166 @@ +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.SessionUiStyle +import ai.kilocode.client.session.views.base.SecondarySessionPartView +import ai.kilocode.client.session.views.tool.TaskToolView +import ai.kilocode.client.ui.UiStyle +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.Color +import javax.swing.ScrollPaneConstants + +@Suppress("UnstableApiUsage") +class TaskToolViewTest : BasePlatformTestCase() { + private val views = mutableListOf() + + override fun tearDown() { + try { + views.forEach(Disposer::dispose) + views.clear() + } finally { + super.tearDown() + } + } + + fun `test task tool uses secondary chrome`() { + val base: Any = view(task()) + + assertTrue(base is SecondarySessionPartView) + } + + fun `test task header shows agent description and count`() { + val view = view(task(children = listOf(child("c1", "read"), child("c2", "grep")))) + + assertTrue(view.labelText().contains("Explore Agent")) + assertTrue(view.labelText().contains("Find files (2)")) + assertEquals(2, view.rowCount()) + assertTrue(view.bodyVisible()) + } + + fun `test update adds child row without replacing existing rows`() { + val view = view(task(children = listOf(child("c1", "read")))) + val before = view.rowLabels().first() + + view.update(task(children = listOf(child("c1", "read"), child("c2", "grep")))) + + assertEquals(2, view.rowCount()) + assertEquals(before, view.rowLabels().first()) + assertTrue(view.rowLabels().any { it.contains("Grep") }) + } + + fun `test removing child rows collapses body`() { + val view = view(task(children = listOf(child("c1", "read")))) + + view.update(task(children = emptyList())) + + assertEquals(0, view.rowCount()) + assertFalse(view.bodyVisible()) + } + + fun `test body is lazy until child tools arrive`() { + val view = view(task(children = emptyList())) + + assertFalse(view.bodyCreated()) + view.update(task(children = listOf(child("c1", "read")))) + + assertTrue(view.bodyCreated()) + assertTrue(view.bodyVisible()) + } + + fun `test collapsed task body stays collapsed on child update`() { + val view = view(task(children = listOf(child("c1", "read")))) + + view.collapse() + view.update(task(children = listOf(child("c1", "grep")))) + + assertFalse(view.bodyVisible()) + assertTrue(view.rowLabels().single().contains("Grep")) + assertTrue(view.rowLabels().single().contains("pattern=query")) + } + + fun `test expanded task body is capped to ten rows`() { + val view = view(task(children = children(20))) + val taller = view(task(children = children(80))) + + assertEquals(10, view.bodyMaxRows()) + assertTrue(view.preferredSize.height > 0) + assertEquals(view.preferredSize.height, taller.preferredSize.height) + } + + fun `test task body uses nested vertical scroll`() { + val view = view(task(children = children(20))) + + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, view.horizontalPolicy()) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, view.verticalPolicy()) + } + + fun `test child tool titles use target color`() { + val view = view(task(children = listOf(child("c1", "read"), child("c2", "grep", ToolExecState.ERROR)))) + + assertColor(UiStyle.Colors.weak(), view.rowTitleColor("c1")) + assertColor(UiStyle.Colors.errorLabelForeground(), view.rowTitleColor("c2")) + } + + fun `test task body is indented beyond header padding`() { + val view = view(task(children = listOf(child("c1", "read")))) + + assertTrue(view.bodyInsets().left > JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING)) + assertEquals(UiStyle.Gap.sm(), view.bodyInsets().top) + assertEquals(UiStyle.Gap.sm(), view.bodyInsets().bottom) + } + + fun `test appended child tools scroll nested body to bottom`() { + val view = view(task(children = children(40))) + view.setSize(300, view.preferredSize.height) + view.doLayout() + UIUtil.dispatchAllInvocationEvents() + view.setBodyScrollValue(view.bodyScrollBottom() - 1) + + view.update(task(children = children(70))) + UIUtil.dispatchAllInvocationEvents() + UIUtil.dispatchAllInvocationEvents() + UIUtil.dispatchAllInvocationEvents() + + assertEquals(view.bodyScrollBottom(), view.bodyScrollValue()) + } + + fun `test appended child tools do not yank nested body above tail`() { + val view = view(task(children = children(40))) + view.setSize(300, view.preferredSize.height) + view.doLayout() + UIUtil.dispatchAllInvocationEvents() + view.setBodyScrollValue(0) + + view.update(task(children = children(70))) + UIUtil.dispatchAllInvocationEvents() + + assertEquals(0, view.bodyScrollValue()) + } + + private fun view(tool: Tool): TaskToolView = TaskToolView(tool).also { views.add(it) } + + private fun task(children: List = emptyList()) = Tool("part_task", "task", toolKind("task")).also { + it.state = ToolExecState.COMPLETED + it.input = mapOf("subagent_type" to "explore", "description" to "Find files") + it.metadata = mapOf("sessionId" to "ses_child") + it.childSessionId = "ses_child" + it.childTools = children + } + + private fun child(id: String, name: String, state: ToolExecState = ToolExecState.COMPLETED) = Tool(id, name, toolKind(name)).also { + it.state = state + it.input = mapOf("filePath" to "src/Main.kt", "pattern" to "query") + } + + private fun children(count: Int) = (1..count).map { child("c$it", "read") } + + private fun assertColor(expected: Color, actual: Color?) { + assertNotNull(actual) + assertEquals(expected.rgb, actual!!.rgb) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index a7053469f27..389082cb1d1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -46,6 +46,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { /** Message history returned by [messages]. */ val history = mutableListOf() + val histories = mutableMapOf>() var historyGate: CompletableDeferred? = null var historyCalls = 0 private set @@ -218,7 +219,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { assertNotEdt("messages") historyCalls++ historyGate?.await() - return history.toList() + return histories[id]?.toList() ?: history.toList() } override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? { From f3c886b3fafe040a9d9d139792a2cae934d30754 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 3 Jul 2026 22:22:52 -0400 Subject: [PATCH 04/19] fix(jetbrains): avoid blocking prompt mention expansion --- .changeset/fix-jetbrains-prompt-submit.md | 5 ++++ .../ai/kilocode/client/session/SessionUi.kt | 6 ++--- .../client/session/ui/prompt/PromptPanel.kt | 26 +++++++++++++------ 3 files changed, 26 insertions(+), 11 deletions(-) create mode 100644 .changeset/fix-jetbrains-prompt-submit.md diff --git a/.changeset/fix-jetbrains-prompt-submit.md b/.changeset/fix-jetbrains-prompt-submit.md new file mode 100644 index 00000000000..d028d74abcb --- /dev/null +++ b/.changeset/fix-jetbrains-prompt-submit.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Fix prompt submission in JetBrains IDEs when sending messages with file or git-change mentions. 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 495e450ab6a..2e7945cd4ef 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 @@ -74,7 +74,6 @@ import com.intellij.openapi.options.Configurable import com.intellij.openapi.options.ConfigurableWithId import com.intellij.openapi.options.ShowSettingsUtil import com.intellij.openapi.project.Project -import com.intellij.openapi.progress.runBlockingCancellable import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.registry.Registry import com.intellij.util.concurrency.annotations.RequiresEdt @@ -369,6 +368,7 @@ class SessionUi( onEnhance = controller::enhancePrompt, onMentions = ::mentionParts, completion = completion, + cs = cs, ) connection = ConnectionPanel(this, controller) root.addOverlay(connection) { pane, child -> @@ -695,9 +695,9 @@ class SessionUi( spec.available, ) - private fun mentionParts(text: String): List = runBlockingCancellable { + private suspend fun mentionParts(text: String): List { val names = MentionAction.ALL.mapTo(mutableSetOf()) { it.name } - promptMentionParts( + return promptMentionParts( text = text, directory = workspace.directory, reserved = names, 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 65016594979..f95c7a28660 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 @@ -66,6 +66,10 @@ import com.intellij.util.ui.UIUtil import com.intellij.util.ui.components.BorderLayoutPanel import com.intellij.util.messages.MessageBusConnection import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.awt.BorderLayout import java.awt.Cursor import java.awt.Graphics @@ -97,9 +101,10 @@ class PromptPanel( private val onSend: (String, List) -> Unit, private val onAbort: () -> Unit, private val onEnhance: (String, (Result) -> Unit) -> Unit, - private val onMentions: (String) -> List = { emptyList() }, + private val onMentions: suspend (String) -> List = { emptyList() }, private val completion: KiloPromptCompletionProvider? = null, private val selection: SessionSelection? = null, + private val cs: CoroutineScope = CoroutineScope(Dispatchers.Default), ) : BorderLayoutPanel(), SessionEditorStyleTarget, SendPromptContext, UiDataProvider { companion object { @@ -517,21 +522,26 @@ class PromptPanel( val txt = text() val items = attachments.toList() submitting = true - ApplicationManager.getApplication().executeOnPooledThread { + cs.launch { try { - val files = items.map { it.part() } + val files = withContext(Dispatchers.IO) { items.map { it.part() } } val mentioned = onMentions(txt) - ApplicationManager.getApplication().invokeLater { + withContext(Dispatchers.Main) { submitting = false - if (project.isDisposed) return@invokeLater + if (project.isDisposed) return@withContext val parts = files + mentioned LOG.debug { "${ChatLogSummary.prompt(promptDto(txt, parts))} src=$src busy=$busy" } onSend(txt, parts) } - } catch (e: Exception) { - ApplicationManager.getApplication().invokeLater { + } catch (e: CancellationException) { + withContext(Dispatchers.Main) { submitting = false - if (project.isDisposed) return@invokeLater + } + throw e + } catch (e: Exception) { + withContext(Dispatchers.Main) { + submitting = false + if (project.isDisposed) return@withContext LOG.warn("kind=prompt-submit src=$src failed message=${e.message}", e) notify(KiloBundle.message("prompt.attachment.send.failed", e.message ?: e.javaClass.simpleName)) } From 66cef1b662b085f0a4f6d05c5be94969a6c02f07 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 3 Jul 2026 22:47:36 -0400 Subject: [PATCH 05/19] fix(jetbrains): increase todo checklist padding --- .changeset/jetbrains-todo-padding.md | 5 ++++ .../session/views/todo/TodoListPanel.kt | 26 +++++++------------ .../session/views/todo/TodoWriteView.kt | 2 +- .../session/views/todo/TodoWriteViewTest.kt | 13 ++++++++++ 4 files changed, 29 insertions(+), 17 deletions(-) create mode 100644 .changeset/jetbrains-todo-padding.md diff --git a/.changeset/jetbrains-todo-padding.md b/.changeset/jetbrains-todo-padding.md new file mode 100644 index 00000000000..1a3672774df --- /dev/null +++ b/.changeset/jetbrains-todo-padding.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Increase JetBrains todo checklist inner padding. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoListPanel.kt index 62da49a8d96..7005a6eda05 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoListPanel.kt @@ -4,26 +4,25 @@ import ai.kilocode.client.plugin.KiloBundle 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.Stack +import ai.kilocode.client.ui.layout.StackAxis import ai.kilocode.rpc.dto.TodoDto import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI import com.intellij.xml.util.XmlStringUtil import java.awt.BasicStroke -import java.awt.BorderLayout import java.awt.Color import java.awt.Component import java.awt.Graphics import java.awt.Graphics2D import java.awt.RenderingHints import javax.swing.Icon -import javax.swing.BoxLayout -import javax.swing.JPanel class TodoListPanel( todos: List = emptyList(), private var before: Int = 0, private var after: Int = 0, -) : JPanel() { +) : Stack(StackAxis.VERTICAL) { private var items = todos private var style = SessionEditorStyle.current() @@ -32,11 +31,7 @@ class TodoListPanel( private val later = JBLabel() init { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - isOpaque = false - border = JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.md()) - add(prior) - add(later) + border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad()) applyStyle(style) sync() } @@ -86,13 +81,13 @@ class TodoListPanel( private fun sync() { removeAll() rows.clear() - add(prior) + next(prior) items.forEach { todo -> val row = Row(todo, style) rows.add(row) - add(row.panel) + next(row.panel) } - add(later) + next(later) syncHidden() } @@ -122,11 +117,10 @@ class TodoListPanel( icon = this@Row.icon } val text = JBLabel() - val panel = JPanel(BorderLayout(UiStyle.Gap.sm(), 0)).apply { - isOpaque = false + val panel = Stack.horizontal(UiStyle.Gap.sm()).apply { border = JBUI.Borders.empty(UiStyle.Gap.xs(), 0) - add(check, BorderLayout.WEST) - add(text, BorderLayout.CENTER) + next(check) + next(text) } init { 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 131bbc560a2..ef7483eeef3 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 @@ -35,7 +35,7 @@ class TodoWriteView(tool: Tool, private val parts: TodoParts = todoParts()) : 0, 0, ), - JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.md()), + JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad()), ) applyStyle(style) sync() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt index c4b56fe2078..b4af0eba89f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt @@ -86,6 +86,19 @@ class TodoWriteViewTest : BasePlatformTestCase() { assertEquals(UiStyle.Gap.md(), centerGap(view)) } + fun `test todo body uses next standard inner padding`() { + val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also { + it.todos = listOf(TodoDto("Next", "pending", "medium")) + }) + val body = view.components.filterIsInstance().single() + val ins = body.border.getBorderInsets(body) + + assertEquals(UiStyle.Gap.lg() + SessionUiStyle.View.Outline.width(), ins.top) + assertEquals(UiStyle.Gap.pad(), ins.left) + assertEquals(UiStyle.Gap.lg(), ins.bottom) + assertEquals(UiStyle.Gap.pad(), ins.right) + } + fun `test compact view renders hidden labels and visible rows`() { val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also { it.todos = listOf( From eb59ad6b134b1123055c4ab4adde8f055346bb91 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 3 Jul 2026 22:57:40 -0400 Subject: [PATCH 06/19] fix(jetbrains): balance shell tooltip padding --- .changeset/jetbrains-shell-tooltip-padding.md | 5 +++++ .../session/views/tool/ShellToolView.kt | 12 +++++++++++ .../client/session/views/ShellToolViewTest.kt | 21 +++++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 .changeset/jetbrains-shell-tooltip-padding.md diff --git a/.changeset/jetbrains-shell-tooltip-padding.md b/.changeset/jetbrains-shell-tooltip-padding.md new file mode 100644 index 00000000000..6ab1a926d97 --- /dev/null +++ b/.changeset/jetbrains-shell-tooltip-padding.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Balance JetBrains shell command tooltip padding when a horizontal scrollbar is present. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt index be75209af21..de306bacded 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt @@ -210,6 +210,7 @@ class ShellToolView( md.codeFont = style.editorFamily md.component.border = JBUI.Borders.empty() md.set(popupMd(formatCommand(cmd))) + padPopup(md.component) return HeaderPopupBody(PopupPanel(md.component), md, style.editorBackground) } @@ -220,6 +221,17 @@ class ShellToolView( } } +private fun padPopup(root: JComponent) { + root.components.filterIsInstance().forEach { pane -> + val field = pane.viewport.view as? EditorTextField ?: return@forEach + field.border = JBUI.Borders.empty(SessionUiStyle.View.Code.SCROLLBAR_HEIGHT, 0, 0, 0) + val pad = JBUI.scale(SessionUiStyle.View.Code.SCROLLBAR_HEIGHT) + pane.preferredSize = Dimension(pane.preferredSize.width, pane.preferredSize.height + pad) + pane.minimumSize = Dimension(pane.minimumSize.width, pane.minimumSize.height + pad) + pane.maximumSize = Dimension(pane.maximumSize.width, pane.maximumSize.height + pad) + } +} + private class PopupPanel(child: JComponent) : JPanel(BorderLayout()) { init { // Transparent so the balloon fill (editor background) shows uniformly behind the content. diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt index e5d8268676a..6c602afca31 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt @@ -401,6 +401,17 @@ class ShellToolViewTest : BasePlatformTestCase() { assertEquals(1, editors.size) assertEquals("echo one;\n echo two;\n echo three", editors.single().text) + val pane = popupScrollPanes(body.component).single { it.viewport.view is com.intellij.ui.EditorTextField } + val pad = pane.viewportBorder.getBorderInsets(pane) + val field = editors.single() + val border = field.border.getBorderInsets(field) + assertEquals( + SessionUiStyle.View.Code.VIEWPORT_TOP_PADDING, + pad.top, + ) + assertEquals(SessionUiStyle.View.Code.VIEWPORT_BOTTOM_PADDING, pad.bottom) + assertEquals(SessionUiStyle.View.Code.SCROLLBAR_HEIGHT, border.top) + assertEquals(0, border.bottom) assertTrue(body.component.preferredSize.width in 1..JBUI.scale(SessionUiStyle.View.Popup.MAX_WIDTH)) assertTrue(body.component.preferredSize.height > 0) } finally { @@ -527,4 +538,14 @@ class ShellToolViewTest : BasePlatformTestCase() { visit(root) return found } + + private fun popupScrollPanes(root: JComponent): List { + val found = mutableListOf() + fun visit(component: JComponent) { + if (component is JBScrollPane) found.add(component) + component.components.filterIsInstance().forEach(::visit) + } + visit(root) + return found + } } From 6469e9c19694d63bfedcba7d69df244ab9bf7d14 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sat, 4 Jul 2026 11:57:35 -0400 Subject: [PATCH 07/19] fix(jetbrains): make settings list action buttons fully clickable Hit-testing for inline action cells (Connect/OAuth/Disconnect/Enable) re-derived cell rectangles by hand, ignoring the horizontal insets the platform SelectablePanel adds in the New UI. The click target was offset from the drawn button, so only a small strip responded to clicks. Read the rectangles back from the actual rendered component tree instead, giving one source of geometry shared by the provider, agent, and MCP settings lists. --- .../fix-jetbrains-provider-action-clicks.md | 5 ++ .../client/settings/base/SettingsListModel.kt | 90 ++++++++++--------- .../settings/base/SettingsListRenderer.kt | 4 + .../client/settings/base/SettingsListView.kt | 4 +- .../settings/agents/AgentsSettingsUiTest.kt | 8 +- .../settings/agents/McpSettingsUiTest.kt | 4 +- .../settings/base/SettingsListViewTest.kt | 6 +- .../providers/ProvidersSettingsUiTest.kt | 71 +++++++++------ 8 files changed, 109 insertions(+), 83 deletions(-) create mode 100644 .changeset/fix-jetbrains-provider-action-clicks.md diff --git a/.changeset/fix-jetbrains-provider-action-clicks.md b/.changeset/fix-jetbrains-provider-action-clicks.md new file mode 100644 index 00000000000..c026cc8bb12 --- /dev/null +++ b/.changeset/fix-jetbrains-provider-action-clicks.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Fix unreliable clicks on inline action buttons (Connect, OAuth, Disconnect, Enable) in the JetBrains provider, agent, and MCP settings lists so the whole button is clickable. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListModel.kt index 01e106ec8ad..7e6a57389f6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListModel.kt @@ -2,11 +2,14 @@ package ai.kilocode.client.settings.base import ai.kilocode.client.ui.UiStyle import com.intellij.util.ui.JBUI -import java.awt.Dimension +import java.awt.Component +import java.awt.Container import java.awt.Point import java.awt.Rectangle import javax.swing.Icon import javax.swing.JList +import javax.swing.ListCellRenderer +import javax.swing.SwingUtilities private const val CELL_GAP = 8 @@ -57,55 +60,62 @@ internal fun settingsListVisibleCells(item: SettingsListItem, selected: Boolean) return item.cells.filter { selected || it.alwaysVisible } } +internal fun settingsListCellGap() = JBUI.scale(CELL_GAP) + +/** + * Clickable action-cell rectangles for a row, in list coordinates. + * + * The rectangles are read back from the actual rendered component tree instead of being + * re-derived by hand. This keeps the click targets identical to what the [SettingsListRenderer] + * draws — including the horizontal insets the platform's [com.intellij.ui.popup.list.SelectablePanel] + * adds in the New UI, which a hand-computed layout would miss. + */ +internal fun settingsListCellBounds( + list: JList<*>, + index: Int, + selected: Boolean, +): Map { + val model = list.model + if (index < 0 || index >= model.size) return emptyMap() + @Suppress("UNCHECKED_CAST") + val renderer = list.cellRenderer as? ListCellRenderer ?: return emptyMap() + val cell = list.getCellBounds(index, index) ?: return emptyMap() + val comp = renderer.getListCellRendererComponent(list, model.getElementAt(index), index, selected, list.hasFocus()) + comp.setBounds(0, 0, cell.width, cell.height) + settingsListLayout(comp) + val out = linkedMapOf() + for (action in settingsListActionCells(comp)) { + val origin = SwingUtilities.convertPoint(action, 0, 0, comp) + out[action.cellId] = Rectangle(cell.x + origin.x, cell.y + origin.y, action.width, action.height) + } + return out +} + internal fun settingsListCellAt( list: JList<*>, - bounds: Rectangle, + index: Int, point: Point, - item: SettingsListItem, selected: Boolean, ): String? { - val cells = settingsListCellBounds(list, bounds, item, selected) + val item = list.model.getElementAt(index) as? SettingsListItem ?: return null + val cells = settingsListCellBounds(list, index, selected) return settingsListVisibleCells(item, selected) .firstOrNull { cell -> cell.enabled && cells[cell.id]?.contains(point) == true } ?.id } -internal fun settingsListCellBounds( - list: JList<*>, - bounds: Rectangle, - item: SettingsListItem, - selected: Boolean, -): Map { - val height = settingsListCellHeight(list) - var edge = bounds.x + bounds.width - UiStyle.Gap.pad() - val out = linkedMapOf() - for (cell in settingsListVisibleCells(item, selected).asReversed()) { - val size = settingsListCellSize(list, cell) - val width = size.width - val h = height.coerceAtLeast(size.height) - val top = bounds.y + (bounds.height - h) / 2 - val left = edge - width - out[cell.id] = Rectangle(left, top, width, h) - edge = left - JBUI.scale(CELL_GAP) +private fun settingsListLayout(component: Component) { + if (component !is Container) return + component.doLayout() + for (child in component.components) settingsListLayout(child) +} + +private fun settingsListActionCells(component: Component): List { + val out = mutableListOf() + fun visit(c: Component) { + if (c is SettingsListActionCell && c.isVisible) out += c + if (c is Container) c.components.forEach(::visit) } + visit(component) return out } - -internal fun settingsListCellSize(list: JList<*>, cell: SettingsListCell): Dimension { - val label = SettingsListActionCell().apply { - update(cell) - font = list.font - isEnabled = cell.enabled - } - val size = label.preferredSize - if (!cell.iconOnly) return size - val min = settingsListCellHeight(list) - return Dimension(size.width.coerceAtLeast(min), size.height.coerceAtLeast(min)) -} - -private fun settingsListCellHeight(list: JList<*>): Int { - val metrics = list.getFontMetrics(list.font) - return metrics.height + UiStyle.Gap.sm() * 2 -} - -internal fun settingsListCellGap() = JBUI.scale(CELL_GAP) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListRenderer.kt index 04da2eb0397..314e2c64b7f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListRenderer.kt @@ -133,7 +133,11 @@ internal class SettingsListRenderer( } internal class SettingsListActionCell : JBLabel() { + var cellId: String = "" + private set + fun update(cell: SettingsListCell) { + cellId = cell.id text = if (cell.iconOnly) "" else cell.label icon = cell.icon toolTipText = cell.label.takeIf { it.isNotBlank() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListView.kt index b42042473f1..661ac90103f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListView.kt @@ -203,9 +203,9 @@ internal class SettingsListView( val item = model.getElementAt(idx) val selected = idx == list.selectedIndex val id = if (enabled) { - settingsListCellAt(list, bounds, e.point, item, selected) + settingsListCellAt(list, idx, e.point, selected) } else { - settingsListCellBounds(list, bounds, item, selected) + settingsListCellBounds(list, idx, selected) .entries .firstOrNull { it.value.contains(e.point) } ?.key diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/AgentsSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/AgentsSettingsUiTest.kt index 71912dbbfaa..55ee4432754 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/AgentsSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/AgentsSettingsUiTest.kt @@ -286,9 +286,7 @@ class AgentsSettingsUiTest : BasePlatformTestCase() { list.doLayout() val idx = rows(panel).indexOfFirst { it.key == "hidden" } list.selectedIndex = idx - val row = rows(panel)[idx] - val bounds = list.getCellBounds(idx, idx) - val area = settingsListCellBounds(list, bounds, row, selected = true).getValue(DELETE_CELL) + val area = settingsListCellBounds(list, idx, selected = true).getValue(DELETE_CELL) click(list, center(area)) true } @@ -483,9 +481,7 @@ class AgentsSettingsUiTest : BasePlatformTestCase() { list.doLayout() val idx = rows(panel).indexOfFirst { it.key == key } list.selectedIndex = idx - val row = rows(panel)[idx] - val bounds = list.getCellBounds(idx, idx) - val area = settingsListCellBounds(list, bounds, row, selected = true).getValue(cell) + val area = settingsListCellBounds(list, idx, selected = true).getValue(cell) click(list, center(area)) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt index ddaf9ef93f9..aff659e9fbc 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt @@ -350,9 +350,7 @@ class McpSettingsUiTest : BasePlatformTestCase() { list.doLayout() val idx = rows(panel).indexOfFirst { it.key == key } list.selectedIndex = idx - val row = rows(panel)[idx] - val bounds = list.getCellBounds(idx, idx) - val area = settingsListCellBounds(list, bounds, row, selected = true).getValue(id) + val area = settingsListCellBounds(list, idx, selected = true).getValue(id) click(list, center(area)) true } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt index 6bc0e25ae52..1dffba4081c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt @@ -176,8 +176,7 @@ class SettingsListViewTest : BasePlatformTestCase() { view.list.doLayout() UIUtil.dispatchAllInvocationEvents() - val bounds = view.list.getCellBounds(0, 0) - val area = settingsListCellBounds(view.list, bounds, row, selected = true).getValue("edit") + val area = settingsListCellBounds(view.list, 0, selected = true).getValue("edit") val point = Point(area.x + area.width - 1, area.y + area.height - 1) click(view, point) @@ -218,8 +217,7 @@ class SettingsListViewTest : BasePlatformTestCase() { view.list.doLayout() UIUtil.dispatchAllInvocationEvents() - val bounds = view.list.getCellBounds(0, 0) - val area = settingsListCellBounds(view.list, bounds, row, selected = true).getValue("edit") + val area = settingsListCellBounds(view.list, 0, selected = true).getValue("edit") click(view, center(area)) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt index 5db81bbf411..6c6da0923c8 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.settings.providers import ai.kilocode.client.app.KiloProviderService +import ai.kilocode.client.settings.base.SettingsListConfig import ai.kilocode.client.settings.base.SettingsListItem import ai.kilocode.client.settings.base.SettingsListRenderer import ai.kilocode.client.settings.base.SettingsListActionCell @@ -366,36 +367,33 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { fun `test renderer hit test maps actions`() { edt { val row = ProviderListRow(provider("cloudflare", "Cloudflare"), "All providers", listOf(ProviderListAction.OAUTH, ProviderListAction.CONNECT)) - val list = JBList(listOf(row)) - val bounds = Rectangle(0, 0, 320, 48) - val areas = actionBounds(list, bounds, row, selected = true) + val list = hitList(row) + val areas = actionBounds(list, selected = true) - assertEquals(ProviderListAction.CONNECT, actionAt(list, bounds, center(areas.getValue(ProviderListAction.CONNECT)), row, selected = true)) - assertEquals(ProviderListAction.OAUTH, actionAt(list, bounds, center(areas.getValue(ProviderListAction.OAUTH)), row, selected = true)) - assertNull(actionAt(list, bounds, Point(4, 4), row, selected = true)) - assertTrue(actionBounds(list, bounds, row, selected = false).isEmpty()) + assertEquals(ProviderListAction.CONNECT, actionAt(list, center(areas.getValue(ProviderListAction.CONNECT)), selected = true)) + assertEquals(ProviderListAction.OAUTH, actionAt(list, center(areas.getValue(ProviderListAction.OAUTH)), selected = true)) + assertNull(actionAt(list, Point(4, 4), selected = true)) + assertTrue(actionBounds(list, selected = false).isEmpty()) } } fun `test renderer keeps connected disconnect action visible when unselected`() { edt { val row = ProviderListRow(provider("openai", "OpenAI"), "Connected providers", listOf(ProviderListAction.DISCONNECT), connected = true) - val list = JBList(listOf(row)) - val bounds = Rectangle(0, 0, 320, 48) - val area = actionBounds(list, bounds, row, selected = false).getValue(ProviderListAction.DISCONNECT) + val list = hitList(row) + val area = actionBounds(list, selected = false).getValue(ProviderListAction.DISCONNECT) - assertEquals(ProviderListAction.DISCONNECT, actionAt(list, bounds, center(area), row, selected = false)) + assertEquals(ProviderListAction.DISCONNECT, actionAt(list, center(area), selected = false)) } } fun `test renderer ignores disabled env disconnect action`() { edt { val row = ProviderListRow(provider("env", "Env", source = "env"), "All providers", listOf(ProviderListAction.DISCONNECT)) - val list = JBList(listOf(row)) - val bounds = Rectangle(0, 0, 320, 48) - val area = actionBounds(list, bounds, row, selected = true).getValue(ProviderListAction.DISCONNECT) + val list = hitList(row) + val area = actionBounds(list, selected = true).getValue(ProviderListAction.DISCONNECT) - assertNull(actionAt(list, bounds, center(area), row, selected = true)) + assertNull(actionAt(list, center(area), selected = true)) } } @@ -444,15 +442,14 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { fun `test disabled provider rows hide action labels and hit targets`() { edt { val row = ProviderListRow(provider("cloudflare", "Cloudflare"), "All providers", listOf(ProviderListAction.OAUTH, ProviderListAction.CONNECT), disabled = true) - val list = JBList(listOf(row)) - val bounds = Rectangle(0, 0, 320, 48) + val list = hitList(row) val renderer = renderer(row) render(renderer, list, row, selected = true) assertTrue(visibleActions(row, selected = true).isEmpty()) - assertTrue(actionBounds(list, bounds, row, selected = true).isEmpty()) - assertNull(actionAt(list, bounds, Point(300, 24), row, selected = true)) + assertTrue(actionBounds(list, selected = true).isEmpty()) + assertNull(actionAt(list, Point(300, 24), selected = true)) assertTrue(actionTexts(renderer).isEmpty()) } } @@ -529,15 +526,22 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { } } - fun `test action bounds are vertically centered`() { + fun `test action hit target spans the full rendered button`() { edt { val row = ProviderListRow(provider("openai", "OpenAI"), "Popular providers", listOf(ProviderListAction.CONNECT)) - val list = JBList(listOf(row)) - val bounds = Rectangle(0, 10, 320, 80) - val area = actionBounds(list, bounds, row, selected = true).getValue(ProviderListAction.CONNECT) + val list = hitList(row) + val bounds = list.getCellBounds(0, 0) + val area = actionBounds(list, selected = true).getValue(ProviderListAction.CONNECT) - assertTrue(kotlin.math.abs((bounds.y + bounds.height / 2) - (area.y + area.height / 2)) <= 1) assertTrue(bounds.contains(area)) + // The button is right-aligned within the row. + assertTrue(area.x >= bounds.x + bounds.width / 2) + // Every horizontal slice of the drawn button resolves to the action, including the left + // edge that regressed when hit-testing ignored the New UI selection insets. + val y = area.y + area.height / 2 + assertEquals(ProviderListAction.CONNECT, actionAt(list, Point(area.x + 1, y), selected = true)) + assertEquals(ProviderListAction.CONNECT, actionAt(list, Point(area.x + area.width - 1, y), selected = true)) + assertNull(actionAt(list, Point(area.x - 2, y), selected = true)) } } @@ -900,13 +904,24 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { .filter { it.iconWidth == JBUI.scale(20) && it.iconHeight == JBUI.scale(20) } .map { Dimension(it.iconWidth, it.iconHeight) } - private fun actionAt(list: JBList, bounds: Rectangle, point: Point, row: ProviderListRow, selected: Boolean): ProviderListAction? { - val id = settingsListCellAt(list, bounds, point, row, selected) ?: return null + /** Builds a list wired with the real [SettingsListRenderer] and laid out, so hit-testing matches what is drawn. */ + private fun hitList(row: ProviderListRow): JBList { + val model = CollectionListModel(listOf(row)) + val list = JBList(model) + list.cellRenderer = SettingsListRenderer(model as CollectionListModel, SettingsListConfig.Preferred) + list.size = Dimension(320, 200) + list.doLayout() + UIUtil.dispatchAllInvocationEvents() + return list + } + + private fun actionAt(list: JBList, point: Point, selected: Boolean): ProviderListAction? { + val id = settingsListCellAt(list, 0, point, selected) ?: return null return ProviderListAction.entries.firstOrNull { it.name == id } } - private fun actionBounds(list: JBList, bounds: Rectangle, row: ProviderListRow, selected: Boolean): Map { - val cells = settingsListCellBounds(list, bounds, row, selected) + private fun actionBounds(list: JBList, selected: Boolean): Map { + val cells = settingsListCellBounds(list, 0, selected) return cells.mapNotNull { (id, rect) -> ProviderListAction.entries.firstOrNull { it.name == id }?.let { it to rect } }.toMap() } From 30407a3e12561d8d89d05b83ee320d03199a5d36 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sat, 4 Jul 2026 12:07:00 -0400 Subject: [PATCH 08/19] fix(jetbrains): improve prompt picker interactions --- .changeset/jetbrains-picker-popups.md | 5 +++++ .../kotlin/ai/kilocode/client/session/ui/ReasoningPicker.kt | 5 +++-- .../ai/kilocode/client/session/ui/model/ModelPicker.kt | 5 ----- 3 files changed, 8 insertions(+), 7 deletions(-) create mode 100644 .changeset/jetbrains-picker-popups.md diff --git a/.changeset/jetbrains-picker-popups.md b/.changeset/jetbrains-picker-popups.md new file mode 100644 index 00000000000..fb7f2eebea3 --- /dev/null +++ b/.changeset/jetbrains-picker-popups.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Fix JetBrains prompt pickers so reasoning effort opens above the button and expanded model details still allow one-click model selection. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ReasoningPicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ReasoningPicker.kt index b3db2c7d410..cd1ac3e39b9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ReasoningPicker.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ReasoningPicker.kt @@ -6,6 +6,7 @@ import com.intellij.icons.AllIcons import com.intellij.openapi.ui.popup.JBPopupFactory import com.intellij.openapi.ui.popup.ListPopup import com.intellij.openapi.ui.popup.PopupStep +import com.intellij.openapi.ui.popup.PopupShowOptions import com.intellij.openapi.ui.popup.util.BaseListPopupStep import com.intellij.util.ui.EmptyIcon import java.awt.Cursor @@ -17,7 +18,7 @@ import javax.swing.Icon * Clickable label-style dropdown picker with a native filled background. * * Shows the selected item's display text with a down-arrow. On click, - * opens a list popup below the picker. Disabled (greyed out, not + * opens a list popup above the picker. Disabled (greyed out, not * clickable) when no items are loaded. */ class ReasoningPicker : PickerButton() { @@ -100,7 +101,7 @@ class ReasoningPicker : PickerButton() { } val popup: ListPopup = JBPopupFactory.getInstance().createListPopup(step) - popup.showUnderneathOf(this) + popup.show(PopupShowOptions.aboveComponent(this)) } private fun icon(item: Item): Icon = if (item.id == selected?.id) checked else empty diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt index 19edae0d41f..b9d467c36bc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt @@ -379,11 +379,6 @@ class ModelPicker : PickerButton() { e.consume() return } - if (expanded && e.clickCount < 2) { - list.selectedIndex = row - syncDetails() - return - } activate(value) } }) From d14412943b62ca4442223c6e5649387e93574c73 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sat, 4 Jul 2026 13:39:42 -0400 Subject: [PATCH 09/19] fix(jetbrains): tighten task tool coverage --- ...5747000-jetbrains-subagent-review-fixes.md | 165 ++++++++++++++++++ .../client/session/views/tool/TaskToolView.kt | 70 +++----- .../client/session/model/SessionModelTest.kt | 60 +++++++ .../session/ui/SessionMessageListPanelTest.kt | 22 ++- .../session/views/TaskToolViewStressTest.kt | 89 ++++++++++ .../client/session/views/TaskToolViewTest.kt | 95 +++++++--- 6 files changed, 423 insertions(+), 78 deletions(-) create mode 100644 .kilo/plans/1783185747000-jetbrains-subagent-review-fixes.md create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewStressTest.kt diff --git a/.kilo/plans/1783185747000-jetbrains-subagent-review-fixes.md b/.kilo/plans/1783185747000-jetbrains-subagent-review-fixes.md new file mode 100644 index 00000000000..c3f4c6ef995 --- /dev/null +++ b/.kilo/plans/1783185747000-jetbrains-subagent-review-fixes.md @@ -0,0 +1,165 @@ +# TaskToolView / subagent review fixes + +## Goal + +Close the review findings on the `massive-fontina` branch's JetBrains subagent +(`TaskToolView`) work: remove dead code, add the missing streaming stress/leak test, shrink +test-only production seams, fix a theme-in-constructor border, and add direct `SessionModel` +child-tool bookkeeping tests. These are behavior-preserving cleanups plus new tests — the +inline-subagent feature itself already works and is controller-tested. + +## Scope + +All work is confined to Kilo-owned JetBrains paths (path contains `kilo`, so **no +`kilocode_change` markers needed**): + +- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt` +- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt` + (tests only; no production change unless a bug surfaces) +- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt` + (optional, item 7) +- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt` + (optional, item 8) +- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/**` +- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/**` + +Out of scope: the `MdViewHybrid`/`MdProjector` refactor (already landed and well-tested), the +six shipped UI fixes' runtime behavior, and any change to the `AbstractSessionPartView` base +or sibling tool views. Do not touch `SessionModel`'s child-tool logic unless item 6 tests +reveal a real defect. + +## Constraints + +- Swing on the IntelliJ platform: all component creation/mutation stays on the EDT; keep the + existing `@RequiresEdt` intent. No background-thread UI mutation. +- Tests extend `BasePlatformTestCase` for a real Application + EDT. **No mocks** of the EDT, + threading, or platform types — assert against the real Swing tree, as existing tests do. +- Prefer single-word names; avoid `let`/`else`/`try-catch` where the style guide says so. +- Do not add new test-only production accessors. When a test needs state, prefer walking the + real component tree from the test (as `SessionMessageListPanelTest` / `ShellToolViewTest` + already do) over exposing a new seam. +- Implementation requires source edits: hand off to an implementation-capable agent. Validate + with `./gradlew typecheck test --tests "ai.kilocode.client.session.views.*"` and + `--tests "ai.kilocode.client.session.model.*"` from `packages/kilo-jetbrains/` (Java 21). + +## Decisions (recommended; change before implementing if you disagree) + +1. **`TaskToolView.controlCount()` is dead** — the only caller of any `controlCount()` is + `ToolViewTest` against `ToolView`. **Recommendation: delete it.** +2. **Test-only accessors**: many `TaskToolView` methods are used only by tests + (`rowLabels`, `bodyScrollValue/bodyScrollBottom/setBodyScrollValue`, `horizontalPolicy`, + `verticalPolicy`, `bodyInsets`, `rowTitleColor(id)`, and the `public` `rowCount`/ + `bodyCreated`). **Recommendation: (a) delete the ones a test can replace by walking the + real tree; (b) for the few that are awkward to derive from the tree, keep them but make + them `internal` to match sibling views (`ToolView`, `ReadToolView`, `ReasoningView`).** Do + not expand the public surface beyond the sibling pattern. `labelText()` (used by + `dumpLabel()`) and `bodyVisible()`/`bodyMaxRows()` (used internally) stay — they have + product use. +3. **`TaskToolViewStressTest` is required** (AGENTS.md "Stress and Leak Tests for Streaming + UI"). **Recommendation: add it**, mirroring `ReasoningViewStressTest`. +4. **`TaskBody` glyph-derived left inset** is captured once in the constructor + (`glyph.preferredSize.width`). **Recommendation: recompute on `updateUI()`** (or drop the + glyph-width dependency) so the indent tracks LaF/DPI changes. Keep it minimal. +5. **Items 7 (PromptPanel default scope) and 8 (padPopup direct-children scan) are optional + hardening.** **Recommendation: do 7 if cheap, defer 8** unless the popup tree changes — + both are low risk today. + +## Task list (ordered) + +### Phase 1 — Dead code + +1. Delete `TaskToolView.controlCount()` (`TaskToolView.kt:100-101`). Grep-confirm zero + references in `frontend/src` and `frontend/src/test` before removing. + +### Phase 2 — Stress + leak test (safety net for later trimming) + +2. Add `frontend/src/test/.../session/views/TaskToolViewStressTest.kt` modeled on + `ReasoningViewStressTest`. It must, through the public `update(content)` API: + - Drive hundreds of child-tool updates (append child tools 1..N, and interleave + remove/re-add) via `Tool.childTools` snapshots. + - `assertSame` that retained `Row` panels for unchanged child ids stay identical across + updates (read them from the body's `Stack` component tree, not a new accessor). + - Assert the body row `componentCount` stays bounded (equals visible child count, no + per-update growth). + - Assert no editor leak is trivially satisfied (rows are `JBLabel`s, no editors) — still + capture `EditorFactory.getInstance().allEditors.size` before/after churn + a `collapse()` + cycle to prove nothing spawns editors. + Confirm this test passes against current `TaskToolView` before Phase 3. + +### Phase 3 — Shrink test-only seams + +3. Rewrite `TaskToolViewTest` assertions that currently call test-only accessors to instead + walk the real Swing tree (helper that recurses `component.components`, as + `ShellToolViewTest.popupScrollPanes` and `SessionMessageListPanelTest` do). Specifically + replace usage of `rowLabels`, `bodyInsets`, `rowTitleColor(id)`, `horizontalPolicy`, + `verticalPolicy`, `bodyScrollValue/bodyScrollBottom/setBodyScrollValue` where a tree walk + is clean. +4. In `TaskToolView`, delete accessors that no longer have any caller after step 3; make the + remaining test-facing ones `internal` (match `ToolView`/`ReadToolView`). Keep `labelText`, + `bodyVisible`, `bodyMaxRows` (real internal/product callers). +5. Resolve the duplicate `rowTitleColor` name: the member accessor `rowTitleColor(id: String)` + (`:119`) and the top-level `rowTitleColor(tool: Tool)` (`:342`) share a name for unrelated + jobs. If the member survives step 4, rename it (e.g. `rowColor`) or fold the assertion into + a tree walk so only the state→color helper keeps the name. + +### Phase 4 — Theme-in-constructor border + +6. Fix `TaskBody.panel` left inset (`TaskToolView.kt:299-304`) so the glyph-width-derived + indent is re-evaluated on Look-and-Feel / DPI change instead of frozen at construction. + Options: (a) override `updateUI()` on the body panel to recompute the border, or (b) + derive the indent from a `JBUI`/style token rather than the live `glyph.preferredSize`. + Prefer (b) if a suitable `SessionUiStyle`/`UiStyle` value exists; otherwise (a). While here, + drop the redundant `isOpaque = true` on `TaskBody.panel` and `TaskBodyScroll` (JPanel/scroll + default is opaque) per the "Before Returning UI Code" checklist — only if it does not change + rendering. + +### Phase 5 — SessionModel child-tool unit tests + +7. Add direct model coverage in `frontend/src/test/.../session/model/` (extend the existing + `SessionModel` test if present, else add one) for: + - **Re-keying**: a `task` part whose `metadata["sessionId"]` changes must move tracking — + old `childRefs`/`childTools` entry dropped, new one created (`SessionModel.kt:451-456`). + - **Untracking on removal**: `removeMessage` / `removeContent` of a parent `task` part must + clear its `childRefs`/`childTools` entries (`SessionModel.kt:147,159` → + `untrackChild`), and a later `upsertChildTool` for that child becomes a no-op. + These currently only run incidentally through `PromptLifecycleTest`. + +### Phase 6 — Optional hardening (only if cheap) + +8. `PromptPanel` (`:107`): consider replacing the default + `cs: CoroutineScope = CoroutineScope(Dispatchers.Default)` with a required parameter or a + disposable-bound scope so no uncancelled global scope is created. Production already passes + `SessionUi`'s scope; this only affects defaults/tests. Skip if it ripples into constructors. +9. `ShellToolView.padPopup`: it scans only direct children + (`root.components.filterIsInstance()`). Consider the same recursive walk the + settings-list fix uses, for robustness if the popup tree ever nests the pane deeper. Skip + unless the popup layout changes. + +## Risks + +- **Trimming accessors could reduce assertion fidelity.** Mitigation: land the + `TaskToolViewStressTest` and the tree-walk helper (Phases 2-3) *before* deleting accessors, + so every removed accessor has an equivalent tree-based assertion first; keep the full + `session.views` suite green after each phase. +- **`updateUI()` override ordering.** `updateUI()` runs during construction; guard against + NPEs on fields not yet initialized (e.g. read `glyph`/style lazily or null-check) and avoid + triggering a layout storm. Verify `test task body is indented beyond header padding` + (`TaskToolViewTest`) still passes. +- **Dropping `isOpaque = true`** must not change the surface fill. Verify against the existing + body-background assertions; revert that sub-step if any rendering test regresses. + +## Validation + +- Targeted: `./gradlew test --tests "ai.kilocode.client.session.views.TaskToolView*"`, + `--tests "ai.kilocode.client.session.ui.SessionMessageListPanelTest"`, and + `--tests "ai.kilocode.client.session.model.*"` from `packages/kilo-jetbrains/`. +- Full guardrails: `./gradlew typecheck test` from `packages/kilo-jetbrains/` (Java 21). +- Grep-confirm `controlCount` and any deleted accessors have zero references after removal. +- Sanity: the new stress test should fail against a deliberately broken `syncRows` (e.g. + rebuild-all) and pass against current retained-row behavior. + +## Handoff + +This plan is implementation-ready. Switch to an implementation-capable agent to make the +source and test edits; do all changes in this worktree only. No `kilocode_change` markers are +required (all paths are Kilo-owned). diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt index 1bbe202f1cb..f4623020747 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt @@ -81,42 +81,15 @@ class TaskToolView( } @RequiresEdt - fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text) + private fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text) .filter { it.isNotBlank() } .joinToString(" ") @RequiresEdt - fun rowCount(): Int = rows.size + private fun bodyVisible(): Boolean = isExpanded() @RequiresEdt - fun rowLabels(): List = rows.values.map { row -> row.text() } - - @RequiresEdt - fun bodyCreated(): Boolean = hasBody() - - @RequiresEdt - fun bodyVisible(): Boolean = isExpanded() - - @RequiresEdt - fun controlCount(): Int = if (arrow.isVisible) 1 else 0 - @RequiresEdt - internal fun bodyMaxRows() = SessionUiStyle.View.Tool.TASK_LINES - @RequiresEdt - internal fun bodyScrollValue() = taskBodyOrNull()?.verticalScrollBar?.value ?: 0 - @RequiresEdt - internal fun bodyScrollBottom() = taskBodyOrNull()?.let(::bottom) ?: 0 - @RequiresEdt - internal fun setBodyScrollValue(value: Int) { - taskBodyOrNull()?.verticalScrollBar?.value = value - } - @RequiresEdt - internal fun horizontalPolicy() = taskBodyOrNull()?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - @RequiresEdt - internal fun verticalPolicy() = taskBodyOrNull()?.verticalScrollBarPolicy ?: ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER - @RequiresEdt - internal fun bodyInsets() = taskBody().panel.border.getBorderInsets(taskBody().panel) - @RequiresEdt - internal fun rowTitleColor(id: String) = rows[id]?.title?.foreground + private fun bodyMaxRows() = SessionUiStyle.View.Tool.TASK_LINES @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { @@ -279,9 +252,6 @@ class TaskToolView( changed = setFont(sub, style.smallEditorFont) || changed return update(item) || changed } - - @RequiresEdt - fun text(): String = listOf(title.text, sub.text).filter { it.isNotBlank() }.joinToString(" ") } override fun dumpLabel() = "TaskToolView#$contentId(${labelText()})" @@ -293,15 +263,13 @@ class TaskToolView( private class TaskBody(glyph: JBLabel) { val rows = TaskRows() - val panel = JPanel(BorderLayout()).apply { - isOpaque = true - background = SessionUiStyle.View.Surface.bgColor() - border = JBUI.Borders.empty( - UiStyle.Gap.sm(), - glyph.preferredSize.width + JBUI.scale(SessionUiStyle.View.Layout.GAP) + UiStyle.Gap.md(), - UiStyle.Gap.sm(), - UiStyle.Gap.md(), - ) + val panel = object : JPanel(BorderLayout()) { + override fun updateUI() { + super.updateUI() + background = SessionUiStyle.View.Surface.bgColor() + border = taskBodyBorder(glyph) + } + }.apply { add(rows, BorderLayout.CENTER) } val scroll = TaskBodyScroll(this) @@ -312,13 +280,16 @@ private class TaskBodyScroll(val body: TaskBody) : JBScrollPane(body.panel) { val panel: JPanel get() = body.panel init { - border = JBUI.Borders.empty() - isOpaque = true - background = SessionUiStyle.View.Surface.bgColor() - viewport.background = SessionUiStyle.View.Surface.bgColor() horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED } + + override fun updateUI() { + super.updateUI() + border = JBUI.Borders.empty() + background = SessionUiStyle.View.Surface.bgColor() + viewport?.background = SessionUiStyle.View.Surface.bgColor() + } } private class TaskRows : Stack(StackAxis.VERTICAL, UiStyle.Gap.sm()), Scrollable { @@ -345,6 +316,13 @@ private fun rowTitleColor(tool: Tool) = if (tool.state == ToolExecState.ERROR) { UiStyle.Colors.weak() } +private fun taskBodyBorder(glyph: JBLabel) = JBUI.Borders.empty( + UiStyle.Gap.sm(), + glyph.preferredSize.width + JBUI.scale(SessionUiStyle.View.Layout.GAP) + UiStyle.Gap.md(), + UiStyle.Gap.sm(), + UiStyle.Gap.md(), +) + private fun agentTitle(tool: Tool): String { val type = tool.input["subagent_type"]?.takeIf { it.isNotBlank() } ?: tool.name return KiloBundle.message("session.part.tool.agent", type.replaceFirstChar { it.titlecase() }) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt index 075c26fc9a2..67c5f9d2c0b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt @@ -358,6 +358,48 @@ class SessionModelTest : BasePlatformTestCase() { assertTrue(events.single() is SessionModelEvent.ContentUpdated) } + fun `test updateContent task rekeys child tracking when session id changes`() { + model.addMessage(msg("m1", "assistant")) + model.updateContent("m1", taskPart("task", "m1", "child_old")) + model.upsertChildTool("child_old", childPart("read_old")) + assertEquals("read_old", task("m1", "task").childTools.single().id) + + model.updateContent("m1", taskPart("task", "m1", "child_new")) + assertTrue(task("m1", "task").childTools.isEmpty()) + + model.upsertChildTool("child_old", childPart("read_stale")) + assertTrue(task("m1", "task").childTools.isEmpty()) + + model.upsertChildTool("child_new", childPart("read_new")) + assertEquals("read_new", task("m1", "task").childTools.single().id) + } + + fun `test removeContent untracks child tools`() { + model.addMessage(msg("m1", "assistant")) + model.updateContent("m1", taskPart("task", "m1", "child")) + model.upsertChildTool("child", childPart("read_old")) + model.removeContent("m1", "task") + events.clear() + + model.upsertChildTool("child", childPart("read_new")) + + assertNull(model.content("m1", "task")) + assertTrue(events.isEmpty()) + } + + fun `test removeMessage untracks child tools`() { + model.addMessage(msg("m1", "assistant")) + model.updateContent("m1", taskPart("task", "m1", "child")) + model.upsertChildTool("child", childPart("read_old")) + model.removeMessage("m1") + events.clear() + + model.upsertChildTool("child", childPart("read_new")) + + assertNull(model.message("m1")) + assertTrue(events.isEmpty()) + } + fun `test updateContent tool updates rich fields`() { model.addMessage(msg("m1", "assistant")) model.updateContent("m1", part("p1", "m1", "tool", tool = "bash", state = "pending")) @@ -1056,6 +1098,24 @@ class SessionModelTest : BasePlatformTestCase() { source = source, ) + private fun taskPart(id: String, mid: String, child: String) = part( + id = id, + mid = mid, + type = "tool", + tool = "task", + metadata = mapOf("sessionId" to child), + ) + + private fun childPart(id: String) = part( + id = id, + mid = "child_msg", + type = "tool", + tool = "read", + input = mapOf("filePath" to "src/Main.kt"), + ) + + private fun task(mid: String, id: String) = model.content(mid, id) as Tool + private fun question(id: String) = Question( id = id, items = listOf( 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 7e725241cb1..13b9740f41a 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 @@ -23,6 +23,7 @@ import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.session.views.tool.TaskToolView import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.session.views.todo.TodoWriteView +import ai.kilocode.client.ui.layout.Stack import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto @@ -31,6 +32,8 @@ import ai.kilocode.rpc.dto.TodoDto import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane import java.awt.BorderLayout import java.awt.Color import java.awt.Component @@ -393,15 +396,16 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { model.upsertChildTool("ses_child", childTool("child_read", "read")) val view = panel.findMessage("a1")!!.part("part_task") as TaskToolView - assertTrue(view.bodyVisible()) + assertTrue(view.isExpanded()) view.collapse() model.upsertChildTool("ses_child", childTool("child_read", "grep")) val updated = panel.findMessage("a1")!!.part("part_task") as TaskToolView assertSame(view, updated) - assertFalse(updated.bodyVisible()) - assertTrue(updated.rowLabels().single().contains("Grep")) - assertTrue(updated.rowLabels().single().contains("pattern=query")) + assertFalse(updated.isExpanded()) + updated.expand() + assertTrue(taskText(updated).single().contains("Grep")) + assertTrue(taskText(updated).single().contains("pattern=query")) } // ------ HistoryLoaded ------ @@ -895,4 +899,14 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { visit(root) return out } + + private fun taskText(view: TaskToolView): List { + val scroll = components(view).filterIsInstance().single() + val stack = components(scroll.viewport.view).filterIsInstance().single() + return stack.components.map { row -> + components(row).filterIsInstance() + .mapNotNull { label -> label.text.takeIf { it.isNotBlank() } } + .joinToString(" ") + } + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewStressTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewStressTest.kt new file mode 100644 index 00000000000..e894b0ec910 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewStressTest.kt @@ -0,0 +1,89 @@ +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.TaskToolView +import ai.kilocode.client.ui.layout.Stack +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.UIUtil +import java.awt.Component +import java.awt.Container + +@Suppress("UnstableApiUsage") +class TaskToolViewStressTest : BasePlatformTestCase() { + private val views = mutableListOf() + + override fun tearDown() { + try { + views.forEach(Disposer::dispose) + views.clear() + } finally { + super.tearDown() + } + } + + fun `test child tool churn retains rows and stays bounded`() { + val base = EditorFactory.getInstance().allEditors.size + val view = view(task(children = children(3))) + val first = rows(view)[0] + val second = rows(view)[1] + + repeat(120) { i -> + val count = 4 + i % 25 + view.update(task(children = children(count))) + assertSame(first, rows(view)[0]) + assertSame(second, rows(view)[1]) + assertEquals(count, rows(view).size) + } + + repeat(80) { i -> + val ids = listOf("c1", "c2") + (4..(8 + i % 10)).map { "c$it" } + view.update(task(children = ids.map { child(it, if (it == "c2" && i % 2 == 0) "grep" else "read") })) + assertSame(first, rows(view)[0]) + assertSame(second, rows(view)[1]) + assertEquals(ids.size, rows(view).size) + } + + view.collapse() + drainEdt() + + assertFalse(view.isExpanded()) + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + + private fun view(tool: Tool): TaskToolView = TaskToolView(tool).also { views.add(it) } + + private fun task(children: List = emptyList()) = Tool("part_task", "task", toolKind("task")).also { + it.state = ToolExecState.COMPLETED + it.input = mapOf("subagent_type" to "explore", "description" to "Find files") + it.metadata = mapOf("sessionId" to "ses_child") + it.childSessionId = "ses_child" + it.childTools = children + } + + private fun child(id: String, name: String) = Tool(id, name, toolKind(name)).also { + it.state = ToolExecState.COMPLETED + it.input = mapOf("filePath" to "src/Main.kt", "pattern" to "query") + } + + private fun children(count: Int) = (1..count).map { child("c$it", "read") } + + private fun rows(view: TaskToolView): List { + val scroll = descendants(view).filterIsInstance().single() + val stack = descendants(scroll.viewport.view).filterIsInstance().single() + return stack.components.toList() + } + + private fun descendants(root: Component): List { + if (root !is Container) return emptyList() + return root.components.flatMap { child -> listOf(child) + descendants(child) } + } + + private fun drainEdt() { + UIUtil.dispatchAllInvocationEvents() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt index eb6d0beb058..8b70bcf35a4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt @@ -7,11 +7,17 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.session.views.tool.TaskToolView import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBLabel +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 java.awt.Color +import javax.swing.JComponent import javax.swing.ScrollPaneConstants @Suppress("UnstableApiUsage") @@ -36,21 +42,21 @@ class TaskToolViewTest : BasePlatformTestCase() { fun `test task header shows agent description and count`() { val view = view(task(children = listOf(child("c1", "read"), child("c2", "grep")))) - assertTrue(view.labelText().contains("Explore Agent")) - assertTrue(view.labelText().contains("Find files (2)")) - assertEquals(2, view.rowCount()) - assertTrue(view.bodyVisible()) + assertTrue(view.dumpLabel().contains("Explore Agent")) + assertTrue(view.dumpLabel().contains("Find files (2)")) + assertEquals(2, rows(view).size) + assertTrue(view.isExpanded()) } fun `test update adds child row without replacing existing rows`() { val view = view(task(children = listOf(child("c1", "read")))) - val before = view.rowLabels().first() + val before = rowText(view).first() view.update(task(children = listOf(child("c1", "read"), child("c2", "grep")))) - assertEquals(2, view.rowCount()) - assertEquals(before, view.rowLabels().first()) - assertTrue(view.rowLabels().any { it.contains("Grep") }) + assertEquals(2, rows(view).size) + assertEquals(before, rowText(view).first()) + assertTrue(rowText(view).any { it.contains("Grep") }) } fun `test removing child rows collapses body`() { @@ -58,18 +64,18 @@ class TaskToolViewTest : BasePlatformTestCase() { view.update(task(children = emptyList())) - assertEquals(0, view.rowCount()) - assertFalse(view.bodyVisible()) + assertFalse(view.isExpanded()) + assertNull(scroll(view)) } fun `test body is lazy until child tools arrive`() { val view = view(task(children = emptyList())) - assertFalse(view.bodyCreated()) + assertNull(scroll(view)) view.update(task(children = listOf(child("c1", "read")))) - assertTrue(view.bodyCreated()) - assertTrue(view.bodyVisible()) + assertNotNull(scroll(view)) + assertTrue(view.isExpanded()) } fun `test collapsed task body stays collapsed on child update`() { @@ -78,40 +84,43 @@ class TaskToolViewTest : BasePlatformTestCase() { view.collapse() view.update(task(children = listOf(child("c1", "grep")))) - assertFalse(view.bodyVisible()) - assertTrue(view.rowLabels().single().contains("Grep")) - assertTrue(view.rowLabels().single().contains("pattern=query")) + assertFalse(view.isExpanded()) + view.expand() + assertTrue(rowText(view).single().contains("Grep")) + assertTrue(rowText(view).single().contains("pattern=query")) } fun `test expanded task body is capped to ten rows`() { val view = view(task(children = children(20))) val taller = view(task(children = children(80))) - assertEquals(10, view.bodyMaxRows()) + assertEquals(10, SessionUiStyle.View.Tool.TASK_LINES) assertTrue(view.preferredSize.height > 0) assertEquals(view.preferredSize.height, taller.preferredSize.height) } fun `test task body uses nested vertical scroll`() { val view = view(task(children = children(20))) + val scroll = scroll(view)!! - assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, view.horizontalPolicy()) - assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, view.verticalPolicy()) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, scroll.horizontalScrollBarPolicy) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, scroll.verticalScrollBarPolicy) } fun `test child tool titles use target color`() { val view = view(task(children = listOf(child("c1", "read"), child("c2", "grep", ToolExecState.ERROR)))) - assertColor(UiStyle.Colors.weak(), view.rowTitleColor("c1")) - assertColor(UiStyle.Colors.errorLabelForeground(), view.rowTitleColor("c2")) + assertColor(UiStyle.Colors.weak(), titleColor(view, 0)) + assertColor(UiStyle.Colors.errorLabelForeground(), titleColor(view, 1)) } fun `test task body is indented beyond header padding`() { val view = view(task(children = listOf(child("c1", "read")))) + val insets = body(view).border.getBorderInsets(body(view)) - assertTrue(view.bodyInsets().left > JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING)) - assertEquals(UiStyle.Gap.sm(), view.bodyInsets().top) - assertEquals(UiStyle.Gap.sm(), view.bodyInsets().bottom) + assertTrue(insets.left > JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING)) + assertEquals(UiStyle.Gap.sm(), insets.top) + assertEquals(UiStyle.Gap.sm(), insets.bottom) } fun `test appended child tools scroll nested body to bottom`() { @@ -119,14 +128,15 @@ class TaskToolViewTest : BasePlatformTestCase() { view.setSize(300, view.preferredSize.height) view.doLayout() UIUtil.dispatchAllInvocationEvents() - view.setBodyScrollValue(view.bodyScrollBottom() - 1) + val scroll = scroll(view)!! + scroll.verticalScrollBar.value = bottom(scroll) - 1 view.update(task(children = children(70))) UIUtil.dispatchAllInvocationEvents() UIUtil.dispatchAllInvocationEvents() UIUtil.dispatchAllInvocationEvents() - assertEquals(view.bodyScrollBottom(), view.bodyScrollValue()) + assertEquals(bottom(scroll), scroll.verticalScrollBar.value) } fun `test appended child tools do not yank nested body above tail`() { @@ -134,12 +144,13 @@ class TaskToolViewTest : BasePlatformTestCase() { view.setSize(300, view.preferredSize.height) view.doLayout() UIUtil.dispatchAllInvocationEvents() - view.setBodyScrollValue(0) + val scroll = scroll(view)!! + scroll.verticalScrollBar.value = 0 view.update(task(children = children(70))) UIUtil.dispatchAllInvocationEvents() - assertEquals(0, view.bodyScrollValue()) + assertEquals(0, scroll.verticalScrollBar.value) } private fun view(tool: Tool): TaskToolView = TaskToolView(tool).also { views.add(it) } @@ -159,6 +170,34 @@ class TaskToolViewTest : BasePlatformTestCase() { private fun children(count: Int) = (1..count).map { child("c$it", "read") } + private fun scroll(view: TaskToolView): JBScrollPane? = descendants(view).filterIsInstance().singleOrNull() + + private fun body(view: TaskToolView) = scroll(view)!!.viewport.view as JComponent + + private fun rows(view: TaskToolView): List { + val stack = descendants(body(view)).filterIsInstance().singleOrNull() ?: return emptyList() + return stack.components.toList() + } + + private fun rowText(view: TaskToolView) = rows(view).map { row -> + descendants(row).filterIsInstance().mapNotNull { label -> label.text.takeIf { it.isNotBlank() } }.joinToString(" ") + } + + private fun titleColor(view: TaskToolView, index: Int) = descendants(rows(view)[index]) + .filterIsInstance() + .first { it.text.isNotBlank() } + .foreground + + private fun descendants(root: Component): List { + if (root !is Container) return emptyList() + return root.components.flatMap { child -> listOf(child) + descendants(child) } + } + + private fun bottom(scroll: JBScrollPane): Int { + val view = scroll.viewport.view ?: return 0 + return maxOf(0, view.height - scroll.viewport.extentSize.height) + } + private fun assertColor(expected: Color, actual: Color?) { assertNotNull(actual) assertEquals(expected.rgb, actual!!.rgb) From 59baa02340df12742062b0432c47f74e1be7d5f3 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sat, 4 Jul 2026 14:03:44 -0400 Subject: [PATCH 10/19] fix(jetbrains): use transcript font for prompt text --- .changeset/jetbrains-transcript-prompt-font.md | 5 +++++ .../kilocode/client/session/ui/prompt/PromptPanel.kt | 7 +++---- .../client/session/ui/style/SessionEditorStyle.kt | 7 +++++++ .../ai/kilocode/client/session/views/PromptView.kt | 2 +- .../client/session/views/question/QuestionView.kt | 9 ++++----- .../ai/kilocode/client/session/ui/PromptPanelTest.kt | 10 +++++----- .../ai/kilocode/client/session/views/TextViewTest.kt | 4 ++-- 7 files changed, 27 insertions(+), 17 deletions(-) create mode 100644 .changeset/jetbrains-transcript-prompt-font.md diff --git a/.changeset/jetbrains-transcript-prompt-font.md b/.changeset/jetbrains-transcript-prompt-font.md new file mode 100644 index 00000000000..21212ab9f4b --- /dev/null +++ b/.changeset/jetbrains-transcript-prompt-font.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Use the standard transcript font for JetBrains prompt text and custom question responses. 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 f95c7a28660..384f94fe620 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 @@ -158,12 +158,11 @@ class PromptPanel( private val editor = PromptEditorTextField(project, this, completion, selection).apply { border = JBUI.Borders.empty() - setFontInheritedFromLAF(false) setPlaceholder(placeholder()) setShowPlaceholderWhenFocused(true) setOneLineMode(false) addSettingsProvider { ed -> - style.applyToEditor(ed) + style.applyTranscriptToEditor(ed) ed.setBorder(JBUI.Borders.empty()) ed.scrollPane.border = JBUI.Borders.empty() ed.scrollPane.viewportBorder = JBUI.Borders.empty() @@ -374,8 +373,8 @@ class PromptPanel( this.style = style background = style.editorScheme.defaultBackground shell.background = style.editorScheme.defaultBackground - editor.font = style.editorFont - editor.getEditor(false)?.let(style::applyToEditor) + editor.font = style.transcriptFont + editor.getEditor(false)?.let(style::applyTranscriptToEditor) editor.background = style.editorScheme.defaultBackground syncEditorHeight() syncAutoApprove() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt index 19c76b825d3..f10c713da79 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt @@ -49,6 +49,13 @@ data class SessionEditorStyle( } } + /** Apply editor colors while using standard transcript typography for the embedded editor text. */ + fun applyTranscriptToEditor(editor: EditorEx) { + applyToEditor(editor) + editor.colorsScheme.setEditorFontName(transcriptFont.fontName) + editor.colorsScheme.setEditorFontSize(transcriptFont.size) + } + companion object { /** Builds a style snapshot from the current global editor color scheme. */ fun current(): SessionEditorStyle { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptView.kt index 588c8d266de..7d3f7e267e9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptView.kt @@ -72,7 +72,7 @@ class PromptView( md.linkColor = color } - override fun styleFont(style: SessionEditorStyle) = style.editorFont + override fun styleFont(style: SessionEditorStyle) = style.transcriptFont override fun styleBackground(style: SessionEditorStyle) = style.editorBackground 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 d4e04515aae..eb6d0b329a0 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 @@ -164,8 +164,8 @@ class QuestionView( this.style = style card.applyStyle(style) customEditor?.let { ed -> - ed.font = style.editorFont - ed.getEditor(false)?.let(style::applyToEditor) + ed.font = style.transcriptFont + ed.getEditor(false)?.let(style::applyTranscriptToEditor) ed.background = style.editorScheme.defaultBackground } val changed = texts.fold(false) { acc, item -> setFont(item.first, item.second) || acc } @@ -490,12 +490,11 @@ class QuestionView( private fun buildCustomEditor(): SessionEditorTextField { val ed = SessionEditorTextField(project, selection = selection) ed.border = JBUI.Borders.empty() - ed.setFontInheritedFromLAF(false) ed.setPlaceholder(KiloBundle.message("session.question.custom.placeholder")) ed.setShowPlaceholderWhenFocused(true) ed.setOneLineMode(false) ed.addSettingsProvider { ex -> - style.applyToEditor(ex) + style.applyTranscriptToEditor(ex) ex.setBorder(JBUI.Borders.empty()) ex.scrollPane.border = JBUI.Borders.empty() ex.scrollPane.viewportBorder = JBUI.Borders.empty() @@ -507,7 +506,7 @@ class QuestionView( ex.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER } selection?.register(ed)?.let(regs::add) - ed.font = style.editorFont + ed.font = style.transcriptFont ed.background = style.editorScheme.defaultBackground // Pre-fill with saved text. This call also forces lazy document creation so diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index bac03d4c9ca..b08658a855a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -108,13 +108,13 @@ class PromptPanelTest : BasePlatformTestCase() { } } - fun `test prompt input uses editor font settings`() { + fun `test prompt input uses transcript font settings`() { val style = SessionEditorStyle.current() val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) val font = panel.inputFont() - assertEquals(style.editorFamily, font.name) - assertEquals(style.editorSize, font.size) + assertEquals(style.transcriptFont.name, font.name) + assertEquals(style.transcriptFont.size, font.size) } fun `test prompt input uses editor background`() { @@ -130,8 +130,8 @@ class PromptPanelTest : BasePlatformTestCase() { panel.applyStyle(style) - assertEquals("Courier New", panel.inputFont().name) - assertEquals(26, panel.inputFont().size) + assertEquals(style.transcriptFont.name, panel.inputFont().name) + assertEquals(style.transcriptFont.size, panel.inputFont().size) assertTrue(panel.preferredSize.height >= 26) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt index 3fcd18f6e98..b7849b5b7cf 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt @@ -210,13 +210,13 @@ class TextViewTest : BasePlatformTestCase() { assertEquals(style.editorForeground, view.md.foreground) } - fun `test prompt view uses editor font and background`() { + fun `test prompt view uses transcript font and editor background`() { val style = SessionEditorStyle.create(family = "Courier New", size = 23) val view = PromptView(Text("p1")) view.applyStyle(style) - assertEquals(style.editorFont, view.md.font) + assertEquals(style.transcriptFont, view.md.font) assertEquals(style.editorBackground, view.md.background) assertFalse(view.contentOpaque()) } From 8361d246eea332f89c84542543a9e44d276cb92f Mon Sep 17 00:00:00 2001 From: kirillk Date: Sat, 4 Jul 2026 15:36:43 -0400 Subject: [PATCH 11/19] fix(jetbrains): render compaction marker without prompt chrome --- .../client/session/views/CompactionView.kt | 1 + .../client/session/views/MessageView.kt | 12 ++++++++++-- .../client/session/ui/SessionUiUpdateTest.kt | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) 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 ece0854420a..b736a08bca3 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 @@ -37,6 +37,7 @@ class CompactionView(@Suppress("UNUSED_PARAMETER") compaction: Compaction) : Par init { layout = BorderLayout() isOpaque = false + border = JBUI.Borders.empty(UiStyle.Gap.md(), 0) applyStyle(SessionEditorStyle.current()) val line = { JPanel().apply { 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 a16579f6fc8..b5dbac40be1 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 @@ -1,6 +1,7 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.SessionFileOpener +import ai.kilocode.client.session.model.Compaction import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.FileAttachment import ai.kilocode.client.session.model.Message @@ -59,7 +60,14 @@ class MessageView( val role: String get() = msg.info.role override val sessionViewKind: SessionView.Kind - get() = if (role == SessionUiStyle.View.Message.USER_ROLE) SessionView.Kind.UserPrompt else SessionView.Kind.Default + get() = if (role == SessionUiStyle.View.Message.USER_ROLE && !compaction) { + SessionView.Kind.UserPrompt + } else { + SessionView.Kind.Default + } + + private val compaction: Boolean + get() = role == SessionUiStyle.View.Message.USER_ROLE && msg.parts.values.any { it is Compaction } private val parts = LinkedHashMap() // Adjacent reasoning parts render through the first ReasoningView. aliases maps each @@ -411,7 +419,7 @@ class MessageView( } override fun paintComponent(g: Graphics) { - if (msg.info.role != SessionUiStyle.View.Message.USER_ROLE) { + if (msg.info.role != SessionUiStyle.View.Message.USER_ROLE || compaction) { super.paintComponent(g) return } 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 1705776fac7..dee8f91c9ff 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 @@ -160,6 +160,24 @@ class SessionUiUpdateTest : BasePlatformTestCase() { assertTrue(mv.part("cp1") is ai.kilocode.client.session.views.CompactionView) } + fun `test user compaction marker renders without prompt chrome`() { + model.upsertMessage(msg("u1", "user")) + model.updateContent("u1", PartDto("cp1", "ses", "u1", "compaction")) + + val mv = panel.findMessage("u1")!! + assertEquals(SessionView.Kind.Default, mv.sessionViewKind) + assertEquals(listOf("cp1"), mv.partIds()) + assertTrue(mv.part("cp1") is ai.kilocode.client.session.views.CompactionView) + } + + fun `test user text message keeps prompt chrome`() { + model.upsertMessage(msg("u1", "user")) + model.updateContent("u1", part("p1", "u1", "text", text = "hello")) + + val mv = panel.findMessage("u1")!! + assertEquals(SessionView.Kind.UserPrompt, mv.sessionViewKind) + } + // ------ generic fallback ------ fun `test unknown part type falls back to GenericView`() { From dafe38fbd0f7d7b0bf0b75ac5de13a92423d79ad Mon Sep 17 00:00:00 2001 From: kirillk Date: Sat, 4 Jul 2026 15:59:50 -0400 Subject: [PATCH 12/19] fix(jetbrains): stabilize task tool body indent --- .../client/session/views/tool/TaskToolView.kt | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt index f4623020747..51f080b24a7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt @@ -316,12 +316,19 @@ private fun rowTitleColor(tool: Tool) = if (tool.state == ToolExecState.ERROR) { UiStyle.Colors.weak() } -private fun taskBodyBorder(glyph: JBLabel) = JBUI.Borders.empty( - UiStyle.Gap.sm(), - glyph.preferredSize.width + JBUI.scale(SessionUiStyle.View.Layout.GAP) + UiStyle.Gap.md(), - UiStyle.Gap.sm(), - UiStyle.Gap.md(), -) +private fun taskBodyBorder(glyph: JBLabel) = run { + val width = maxOf( + glyph.preferredSize.width, + glyph.icon?.iconWidth ?: 0, + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), + ) + JBUI.Borders.empty( + UiStyle.Gap.sm(), + width + JBUI.scale(SessionUiStyle.View.Layout.GAP) + UiStyle.Gap.md(), + UiStyle.Gap.sm(), + UiStyle.Gap.md(), + ) +} private fun agentTitle(tool: Tool): String { val type = tool.input["subagent_type"]?.takeIf { it.isNotBlank() } ?: tool.name From 6138b102fb01a486901f654ec94e00e73f3dd6f1 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 5 Jul 2026 12:09:33 -0400 Subject: [PATCH 13/19] fix(jetbrains): address transcript review feedback --- .../session/controller/SessionController.kt | 60 ++++++++++++++++--- .../client/session/model/SessionModel.kt | 8 +++ .../client/session/ui/prompt/PromptPanel.kt | 4 +- .../session/ui/style/SessionEditorStyle.kt | 12 +++- .../session/views/tool/ShellToolView.kt | 13 ++-- .../client/settings/base/SettingsListModel.kt | 4 +- .../session/controller/PromptLifecycleTest.kt | 49 ++++++++++++++- .../client/session/model/SessionModelTest.kt | 14 +++++ .../client/session/ui/PromptPanelTest.kt | 28 +++++++++ .../session/ui/SessionEditorStyleTest.kt | 11 ++++ .../client/session/views/ShellToolViewTest.kt | 5 ++ .../settings/base/SettingsListViewTest.kt | 11 ++++ 12 files changed, 200 insertions(+), 19 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index b6639dc6bca..f7b3e9a5400 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -147,6 +147,7 @@ class SessionController( private var creating: CompletableDeferred? = null private val childJobs: MutableMap = mutableMapOf() private val childIds: MutableSet = mutableSetOf() + private val childParts: MutableMap = mutableMapOf() private var sessionLoadState: SessionLoadState = SessionLoadState.Idle private var recentsState: RecentsState = RecentsState.Idle private var viewState: SessionControllerEvent.ViewChanged? = null @@ -166,8 +167,6 @@ class SessionController( private var modelTime: Double? = null private val snapshots = mutableMapOf() - private data class PartKey(val messageId: String, val partId: String) - val ready: Boolean get() = model.isReady() val autoApprove: Boolean get() = KiloPluginSettings.getAutoApprove() internal val blank: Boolean get() = ref == null && model.isEmpty() && !model.showSession @@ -763,12 +762,14 @@ class SessionController( val session = target.session ?: runCatching { sessions.get(id, directory) }.getOrNull() val items = sessions.messages(id, directory) LOG.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(items)}" } - val discovered = items.flatMap { it.parts }.mapNotNull { childID(it) }.toSet() + val discovered = children(items) runEdt { if (disposed) return@runEdt if (sid != id) return@runEdt updateModel { snapshots.clear() + childParts.clear() + childParts.putAll(discovered) this@SessionController.model.loadHistory(items) syncHistoryAgent(items) if (session != null) this@SessionController.model.setSession(session) @@ -778,7 +779,7 @@ class SessionController( runEdt { if (disposed) return@runEdt if (sid != id) return@runEdt - for (child in discovered) trackChild(child) + for (child in discovered.values.toSet()) trackChild(child) showSession() loaded(!model.isEmpty()) } @@ -811,7 +812,7 @@ class SessionController( val session = sessions.importCloudSession(id, directory) val items = sessions.messages(session.id, directory) LOG.debug { "${ChatLogSummary.sid(session.id)} ${ChatLogSummary.history(items)}" } - val discovered = items.flatMap { it.parts }.mapNotNull { childID(it) }.toSet() + val discovered = children(items) runEdt { if (disposed) return@runEdt ref = SessionRef.Local(session) @@ -826,8 +827,10 @@ class SessionController( recoverPending(session.id) runEdt { if (disposed) return@runEdt - for (child in discovered) trackChild(child) subscribeEvents() + childParts.clear() + childParts.putAll(discovered) + for (child in discovered.values.toSet()) trackChild(child) showSession() loaded(!model.isEmpty()) } @@ -918,6 +921,28 @@ class SessionController( cs.launch { recoverChildPermissions(child) } } + @RequiresEdt + private fun trackChild(key: PartKey, child: String) { + assertEdt() + childParts[key] = child + trackChild(child) + } + + @RequiresEdt + private fun untrackChild(key: PartKey) { + assertEdt() + val child = childParts.remove(key) ?: return + if (child in childParts.values) return + childIds.remove(child) + childJobs.remove(child)?.cancel() + } + + @RequiresEdt + private fun untrackChildren(messageId: String) { + assertEdt() + childParts.keys.filter { it.messageId == messageId }.forEach(::untrackChild) + } + @RequiresEdt private fun cancelSubscriptions() { assertEdt() @@ -926,6 +951,7 @@ class SessionController( childJobs.values.forEach { it.cancel() } childJobs.clear() childIds.clear() + childParts.clear() } private suspend fun recoverChildPermissions(child: String) { @@ -940,6 +966,7 @@ class SessionController( val last = toPermission(permissions.last()) runEdt { if (disposed) return@runEdt + if (child !in childIds) return@runEdt // Do not overwrite an existing root or other child AwaitingPermission state if (model.state is SessionState.AwaitingPermission) return@runEdt updateModel { model.setState(SessionState.AwaitingPermission(last)) } @@ -954,6 +981,7 @@ class SessionController( val items = sessions.messages(child, directory) runEdt { if (disposed) return@runEdt + if (child !in childIds) return@runEdt updateModel { for (msg in items) { if (msg.info.role != "assistant") continue @@ -1056,6 +1084,9 @@ class SessionController( tool = event.part.tool val key = PartKey(event.part.messageID, event.part.id) val prev = content(event.part.messageID, event.part.id) + val child = childID(event.part) + val old = childParts[key] + if (old != null && old != child) untrackChild(key) model.updateContent(event.part.messageID, event.part) val next = content(event.part.messageID, event.part.id) if (next != null && next != prev) { @@ -1066,7 +1097,7 @@ class SessionController( if (model.state is SessionState.Busy) { model.setState(SessionState.Busy(status())) } - childID(event.part)?.let { child -> trackChild(child) } + if (child != null) trackChild(key, child) } is ChatEventDto.PartDelta -> { @@ -1081,7 +1112,9 @@ class SessionController( model.removeChildTool(event.sessionID, event.partID) return } - snapshots.remove(PartKey(event.messageID, event.partID)) + val key = PartKey(event.messageID, event.partID) + snapshots.remove(key) + untrackChild(key) model.removeContent(event.messageID, event.partID) } @@ -1116,6 +1149,7 @@ class SessionController( is ChatEventDto.MessageRemoved -> { snapshots.keys.removeAll { it.messageId == event.messageID } + untrackChildren(event.messageID) model.removeMessage(event.messageID) } @@ -1948,6 +1982,16 @@ private fun childID(part: PartDto): String? { return part.metadata["sessionId"] } +private data class PartKey(val messageId: String, val partId: String) + +private fun children(items: List): Map = buildMap { + for (msg in items) { + for (part in msg.parts) { + childID(part)?.let { put(PartKey(msg.info.id, part.id), it) } + } + } +} + /** Returns true when [event] should be routed from a child subscription. */ private fun isChildEvent(event: ChatEventDto, child: String): Boolean = when (event) { is ChatEventDto.PartUpdated -> event.sessionID == child diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index f483c1983d6..6e06ad4bccd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -49,6 +49,7 @@ class SessionModel { private val hiddenText = mutableSetOf>() private val childRefs = HashMap() private val childTools = HashMap>() + private val childRemoved = HashMap>() var app: KiloAppStateDto = KiloAppStateDto(KiloAppStatusDto.DISCONNECTED) var version: String? = null @@ -203,6 +204,8 @@ class SessionModel { val parent = msg.parts[ref.partId] as? Tool ?: return val tool = fromDto(dto) as? Tool ?: return val tools = childTools.getOrPut(child) { LinkedHashMap() } + if (replace) childRemoved[child]?.remove(dto.id) + if (!replace && childRemoved[child]?.contains(dto.id) == true) return if (!replace && tools.containsKey(dto.id)) return tools[dto.id] = tool parent.childTools = tools.values.toList() @@ -212,6 +215,7 @@ class SessionModel { @RequiresEdt fun removeChildTool(child: String, partId: String) { + childRemoved.getOrPut(child) { mutableSetOf() }.add(partId) val ref = childRefs[child] ?: return val tools = childTools[child] ?: return if (tools.remove(partId) == null) return @@ -291,6 +295,7 @@ class SessionModel { entries.clear() childRefs.clear() childTools.clear() + childRemoved.clear() hiddenText.clear() session = null state = SessionState.Idle @@ -323,6 +328,7 @@ class SessionModel { turnEntries.clear() childRefs.clear() childTools.clear() + childRemoved.clear() hiddenText.clear() session = null state = SessionState.Idle @@ -458,6 +464,7 @@ class SessionModel { if (old != null && old != existing.childSessionId) { childRefs.remove(old) childTools.remove(old) + childRemoved.remove(old) } existing.output = dto.output existing.error = dto.error @@ -538,6 +545,7 @@ class SessionModel { val child = tool.childSessionId ?: return childRefs.remove(child) childTools.remove(child) + childRemoved.remove(child) } private fun updateHeader() { 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 384f94fe620..cb91a8ed647 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 @@ -68,6 +68,7 @@ import com.intellij.util.messages.MessageBusConnection import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.awt.BorderLayout @@ -158,6 +159,7 @@ class PromptPanel( private val editor = PromptEditorTextField(project, this, completion, selection).apply { border = JBUI.Borders.empty() + setFontInheritedFromLAF(false) setPlaceholder(placeholder()) setShowPlaceholderWhenFocused(true) setOneLineMode(false) @@ -533,7 +535,7 @@ class PromptPanel( onSend(txt, parts) } } catch (e: CancellationException) { - withContext(Dispatchers.Main) { + withContext(NonCancellable + Dispatchers.Main) { submitting = false } throw e diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt index f10c713da79..16f40d3449e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt @@ -51,9 +51,15 @@ data class SessionEditorStyle( /** Apply editor colors while using standard transcript typography for the embedded editor text. */ fun applyTranscriptToEditor(editor: EditorEx) { - applyToEditor(editor) - editor.colorsScheme.setEditorFontName(transcriptFont.fontName) - editor.colorsScheme.setEditorFontSize(transcriptFont.size) + try { + if (editor.isDisposed) return + applyToEditor(editor) + if (editor.isDisposed) return + editor.colorsScheme.setEditorFontName(transcriptFont.fontName) + editor.colorsScheme.setEditorFontSize(transcriptFont.size) + } catch (err: RuntimeException) { + if (err.javaClass.name != "com.intellij.openapi.util.TraceableDisposable\$DisposalException") throw err + } } companion object { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt index de306bacded..fd1db6ec170 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt @@ -225,13 +225,18 @@ private fun padPopup(root: JComponent) { root.components.filterIsInstance().forEach { pane -> val field = pane.viewport.view as? EditorTextField ?: return@forEach field.border = JBUI.Borders.empty(SessionUiStyle.View.Code.SCROLLBAR_HEIGHT, 0, 0, 0) - val pad = JBUI.scale(SessionUiStyle.View.Code.SCROLLBAR_HEIGHT) - pane.preferredSize = Dimension(pane.preferredSize.width, pane.preferredSize.height + pad) - pane.minimumSize = Dimension(pane.minimumSize.width, pane.minimumSize.height + pad) - pane.maximumSize = Dimension(pane.maximumSize.width, pane.maximumSize.height + pad) + val pad = field.border.getBorderInsets(field).top + field.preferredSize = grow(field.preferredSize, pad) + field.minimumSize = grow(field.minimumSize, pad) + field.maximumSize = grow(field.maximumSize, pad) + pane.preferredSize = grow(pane.preferredSize, pad) + pane.minimumSize = grow(pane.minimumSize, pad) + pane.maximumSize = grow(pane.maximumSize, pad) } } +private fun grow(size: Dimension, pad: Int) = Dimension(size.width, size.height + pad) + private class PopupPanel(child: JComponent) : JPanel(BorderLayout()) { init { // Transparent so the balloon fill (editor background) shows uniformly behind the content. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListModel.kt index 7e6a57389f6..81cac63adc8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListModel.kt @@ -97,7 +97,9 @@ internal fun settingsListCellAt( point: Point, selected: Boolean, ): String? { - val item = list.model.getElementAt(index) as? SettingsListItem ?: return null + val model = list.model + if (index < 0 || index >= model.size) return null + val item = model.getElementAt(index) as? SettingsListItem ?: return null val cells = settingsListCellBounds(list, index, selected) return settingsListVisibleCells(item, selected) .firstOrNull { cell -> cell.enabled && cells[cell.id]?.contains(point) == true } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt index b7c80275fef..8449703a982 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt @@ -29,6 +29,7 @@ import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.ToolRefDto import java.util.concurrent.CopyOnWriteArrayList import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.onCompletion class PromptLifecycleTest : SessionControllerTestBase() { @@ -704,6 +705,50 @@ class PromptLifecycleTest : SessionControllerTestBase() { assertEquals(listOf("read"), task.childTools.map { it.name }) } + fun `test stale child history does not resurrect live removed child tool`() { + rpc.historyGate = CompletableDeferred() + rpc.histories["ses_child"] = mutableListOf( + MessageWithPartsDto( + msg("child_msg", "ses_child", "assistant"), + listOf(childTool("child_read", "read")), + ), + ) + val (m, _, _) = prompted() + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false) + emit(taskPart("ses_child"), flush = false) + emit(ChatEventDto.PartUpdated("ses_child", childTool("child_read", "read")), flush = false) + emit(ChatEventDto.PartRemoved("ses_child", "child_msg", "child_read")) + + var task = m.model.content("msg1", "part_task") as Tool + assertTrue(task.childTools.isEmpty()) + + rpc.historyGate!!.complete(Unit) + flush() + + task = m.model.content("msg1", "part_task") as Tool + assertTrue(task.childTools.isEmpty()) + } + + fun `test task child rekey cancels old child subscription`() { + val closed = CopyOnWriteArrayList() + rpc.eventFlow = { id, _ -> rpc.events.onCompletion { closed.add(id) } } + val (m, _, _) = prompted() + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false) + emit(taskPart("ses_child")) + emit(taskPart("ses_new")) + settle() + + assertTrue("closed=$closed", closed.contains("ses_child")) + + emit(ChatEventDto.PermissionAsked("ses_child", childPermission("old_perm", "ses_child")), flush = false) + emit(ChatEventDto.PermissionAsked("ses_new", childPermission("new_perm", "ses_new"))) + + assertTrue(m.model.state is SessionState.AwaitingPermission) + val perm = (m.model.state as SessionState.AwaitingPermission).permission + assertEquals("new_perm", perm.id) + assertEquals("ses_new", perm.sessionId) + } + fun `test root permission event is not processed as child permission`() { val (m, _, _) = prompted() @@ -738,9 +783,9 @@ class PromptLifecycleTest : SessionControllerTestBase() { input = mapOf("filePath" to "src/Main.kt", "pattern" to "query"), ) - private fun childPermission(id: String) = PermissionRequestDto( + private fun childPermission(id: String, sid: String = "ses_child") = PermissionRequestDto( id = id, - sessionID = "ses_child", + sessionID = sid, permission = "edit", patterns = listOf("*.kt"), always = emptyList(), diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt index 67c5f9d2c0b..fe17e9a103d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt @@ -374,6 +374,20 @@ class SessionModelTest : BasePlatformTestCase() { assertEquals("read_new", task("m1", "task").childTools.single().id) } + fun `test stale child history cannot resurrect removed child tool`() { + model.addMessage(msg("m1", "assistant")) + model.updateContent("m1", taskPart("task", "m1", "child")) + + model.removeChildTool("child", "read_old") + model.upsertChildTool("child", childPart("read_old"), replace = false) + + assertTrue(task("m1", "task").childTools.isEmpty()) + + model.upsertChildTool("child", childPart("read_old")) + + assertEquals("read_old", task("m1", "task").childTools.single().id) + } + fun `test removeContent untracks child tools`() { model.addMessage(msg("m1", "assistant")) model.updateContent("m1", taskPart("task", "m1", "child")) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index b08658a855a..e68213033a8 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -62,6 +62,7 @@ import com.intellij.util.Producer import com.intellij.util.ui.EmptyIcon import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -570,6 +571,33 @@ class PromptPanelTest : BasePlatformTestCase() { assertEquals(listOf(part), sent) } + fun `test cancelling submit mention resolution re-enables send`() { + val entered = CompletableDeferred() + val gate = CompletableDeferred() + val panel = PromptPanel( + project = project, + onSend = { _, _ -> }, + onAbort = {}, + onEnhance = { _, _ -> }, + onMentions = { + entered.complete(Unit) + gate.await() + emptyList() + }, + cs = scope, + ) + val editor = panel.defaultFocusedComponent as EditorTextField + panel.setReady(true) + editor.text = "send @file" + + panel.send() + waitForSend { entered.isCompleted && !panel.isSendEnabled } + scope.cancel(CancellationException("test cancellation")) + waitForSend { panel.isSendEnabled } + + assertTrue(panel.isSendEnabled) + } + fun `test clear removes attachments`() { val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> }) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt index 46fc602efa2..37ff1cfae56 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt @@ -2,6 +2,8 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.ui.UiStyle +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.testFramework.fixtures.BasePlatformTestCase import java.awt.Font @@ -98,4 +100,13 @@ class SessionEditorStyleTest : BasePlatformTestCase() { assertFalse("boldFont should not use editor font family", style.boldFont.name == "Courier New") assertFalse("smallFont should not use editor font family", style.smallFont.name == "Courier New") } + + fun `test transcript editor styling ignores disposed editor`() { + val factory = EditorFactory.getInstance() + val editor = factory.createEditor(factory.createDocument(""), project) as EditorEx + + factory.releaseEditor(editor) + + SessionEditorStyle.current().applyTranscriptToEditor(editor) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt index 6c602afca31..b7817c62ab6 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt @@ -405,6 +405,8 @@ class ShellToolViewTest : BasePlatformTestCase() { val pad = pane.viewportBorder.getBorderInsets(pane) val field = editors.single() val border = field.border.getBorderInsets(field) + val editor = field.getEditor(true)!! + val lines = field.text.lines().size assertEquals( SessionUiStyle.View.Code.VIEWPORT_TOP_PADDING, pad.top, @@ -412,6 +414,9 @@ class ShellToolViewTest : BasePlatformTestCase() { assertEquals(SessionUiStyle.View.Code.VIEWPORT_BOTTOM_PADDING, pad.bottom) assertEquals(SessionUiStyle.View.Code.SCROLLBAR_HEIGHT, border.top) assertEquals(0, border.bottom) + assertTrue(field.preferredSize.height - border.top >= editor.lineHeight * lines) + assertTrue(field.minimumSize.height - border.top >= editor.lineHeight * lines) + assertTrue(pane.preferredSize.height >= field.preferredSize.height + pad.top + pad.bottom) assertTrue(body.component.preferredSize.width in 1..JBUI.scale(SessionUiStyle.View.Popup.MAX_WIDTH)) assertTrue(body.component.preferredSize.height > 0) } finally { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt index 1dffba4081c..ca7f5cd7c8e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt @@ -185,6 +185,17 @@ class SettingsListViewTest : BasePlatformTestCase() { } } + fun `test action hit test ignores stale indexes`() { + edt { + val view = SettingsListView("Empty") { _, _ -> } + view.update(listOf(item("with", "Alpha", null, SettingsListCell("edit", "Edit")))) + layout(view) + + assertNull(settingsListCellAt(view.list, -1, Point(0, 0), selected = true)) + assertNull(settingsListCellAt(view.list, view.list.model.size, Point(0, 0), selected = true)) + } + } + fun `test double click invokes primary cell instead of first visual cell`() { edt { val calls = mutableListOf() From 6e388ea23ba54b00e343ec8df461a1d6f4ccf275 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 5 Jul 2026 13:13:01 -0400 Subject: [PATCH 14/19] fix(jetbrains): hide prompt floating toolbar --- .../jetbrains-prompt-floating-toolbar.md | 5 +++ .../ui/editor/SessionEditorTextField.kt | 34 +++++++++++++++++++ .../client/session/ui/PromptPanelTest.kt | 22 +++++++++++- 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 .changeset/jetbrains-prompt-floating-toolbar.md diff --git a/.changeset/jetbrains-prompt-floating-toolbar.md b/.changeset/jetbrains-prompt-floating-toolbar.md new file mode 100644 index 00000000000..2480cdf891b --- /dev/null +++ b/.changeset/jetbrains-prompt-floating-toolbar.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Hide the JetBrains editor floating toolbar from the Kilo prompt input. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt index f42a02439ab..b47aa0529b1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt @@ -5,6 +5,7 @@ import ai.kilocode.client.session.ui.prompt.PromptDataKeys import ai.kilocode.client.session.ui.prompt.SendPromptContext import ai.kilocode.client.session.ui.selection.SessionSelection import com.intellij.ide.actions.UndoRedoAction +import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DataSink @@ -12,16 +13,25 @@ import com.intellij.openapi.actionSystem.IdeActions import com.intellij.openapi.actionSystem.PlatformCoreDataKeys import com.intellij.openapi.command.undo.UndoManager import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.fileEditor.TextEditor import com.intellij.openapi.fileEditor.impl.text.TextEditorProvider import com.intellij.openapi.fileTypes.PlainTextFileType import com.intellij.openapi.fileTypes.PlainTextLanguage import com.intellij.openapi.project.DumbAwareAction import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer import com.intellij.ui.EditorTextField import com.intellij.ui.LanguageTextField import com.intellij.util.textCompletion.TextCompletionProvider import com.intellij.util.textCompletion.TextCompletionUtil +import java.awt.Component +import java.awt.Container +import java.awt.event.HierarchyEvent +import javax.swing.SwingUtilities + +// The toolbar class is internal; match by name to avoid linking against internal API. +private const val TOOLBAR = "com.intellij.openapi.editor.toolbar.floating.EditorFloatingToolbar" /** * A session-scoped [EditorTextField] for plain-text input. @@ -69,6 +79,15 @@ internal open class SessionEditorTextField( } private fun install(editor: Editor) { + (editor as? EditorEx)?.setEmbeddedIntoDialogWrapper(true) + hide(editor.component) + editor.component.addHierarchyListener { event -> + if ((event.changeFlags and HierarchyEvent.SHOWING_CHANGED.toLong()) == 0L) return@addHierarchyListener + if (!editor.component.isShowing) return@addHierarchyListener + SwingUtilities.invokeLater { + SwingUtilities.invokeLater { hide(editor.component) } + } + } editor.contentComponent.putClientProperty(UndoRedoAction.IGNORE_SWING_UNDO_MANAGER, true) // Workaround: global $Undo/$Redo can miss the synthetic FileEditor for this embedded // EditorTextField. Bind the shortcuts locally until the platform data context targets it reliably. @@ -108,4 +127,19 @@ internal open class SessionEditorTextField( private fun file(): TextEditor? { return getEditor(false)?.let(TextEditorProvider.getInstance()::getTextEditor) } + + private fun hide(component: Component): Boolean { + if (component.javaClass.name == TOOLBAR) { + (component as? Disposable)?.let(Disposer::dispose) + component.parent?.remove(component) + return true + } + if (component !is Container) return false + val hidden = component.components.fold(false) { removed, child -> hide(child) || removed } + if (hidden) { + component.revalidate() + component.repaint() + } + return hidden + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index e68213033a8..4d2e79f69c9 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -68,8 +68,9 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import java.awt.Container import java.awt.BorderLayout +import java.awt.Component +import java.awt.Container import java.awt.datatransfer.DataFlavor import java.awt.datatransfer.StringSelection import java.awt.datatransfer.Transferable @@ -85,6 +86,8 @@ import javax.swing.ImageIcon import javax.swing.ScrollPaneConstants import javax.swing.SwingUtilities +private const val FLOATING = "com.intellij.openapi.editor.toolbar.floating.EditorFloatingToolbar" + @Suppress("UnstableApiUsage") class PromptPanelTest : BasePlatformTestCase() { private val roots = mutableListOf() @@ -125,6 +128,17 @@ class PromptPanelTest : BasePlatformTestCase() { assertEquals(style.editorScheme.defaultBackground, panel.defaultFocusedComponent.background) } + fun `test prompt editor hides floating toolbar`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + + realize(panel, 260, 400) + UIUtil.dispatchAllInvocationEvents() + UIUtil.dispatchAllInvocationEvents() + val editor = (panel.defaultFocusedComponent as EditorTextField).getEditor(false)!! + + assertFalse(hasFloatingToolbar(editor.component)) + } + fun `test applyStyle updates prompt input and height`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) val style = SessionEditorStyle.create(family = "Courier New", size = 26) @@ -1182,6 +1196,12 @@ class PromptPanelTest : BasePlatformTestCase() { } } + private fun hasFloatingToolbar(component: Component): Boolean { + if (component.javaClass.name == FLOATING) return true + if (component !is Container) return false + return component.components.any(::hasFloatingToolbar) + } + private fun createEditor(): Editor { val factory = EditorFactory.getInstance() return factory.createEditor(factory.createDocument(""), project) From bf41013402653b86db19f5068f77789981a44965 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 5 Jul 2026 20:46:37 -0400 Subject: [PATCH 15/19] fix(jetbrains): simplify prompt toolbar suppression --- .../ui/editor/SessionEditorTextField.kt | 14 ++++-------- .../client/session/ui/PromptPanelTest.kt | 22 +++++++++++++++++-- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt index b47aa0529b1..7847bade604 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt @@ -25,10 +25,9 @@ import com.intellij.ui.EditorTextField import com.intellij.ui.LanguageTextField import com.intellij.util.textCompletion.TextCompletionProvider import com.intellij.util.textCompletion.TextCompletionUtil +import com.intellij.util.ui.update.UiNotifyConnector import java.awt.Component import java.awt.Container -import java.awt.event.HierarchyEvent -import javax.swing.SwingUtilities // The toolbar class is internal; match by name to avoid linking against internal API. private const val TOOLBAR = "com.intellij.openapi.editor.toolbar.floating.EditorFloatingToolbar" @@ -80,14 +79,9 @@ internal open class SessionEditorTextField( private fun install(editor: Editor) { (editor as? EditorEx)?.setEmbeddedIntoDialogWrapper(true) - hide(editor.component) - editor.component.addHierarchyListener { event -> - if ((event.changeFlags and HierarchyEvent.SHOWING_CHANGED.toLong()) == 0L) return@addHierarchyListener - if (!editor.component.isShowing) return@addHierarchyListener - SwingUtilities.invokeLater { - SwingUtilities.invokeLater { hide(editor.component) } - } - } + // EditorImpl lazily creates EditorFloatingToolbar with the same first-show hook. + // Settings providers run later, so this callback runs immediately after toolbar creation. + UiNotifyConnector.doWhenFirstShown(editor.component) { hide(editor.component) } editor.contentComponent.putClientProperty(UndoRedoAction.IGNORE_SWING_UNDO_MANAGER, true) // Workaround: global $Undo/$Redo can miss the synthetic FileEditor for this embedded // EditorTextField. Bind the shortcuts locally until the platform data context targets it reliably. diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index 4d2e79f69c9..4abd8716dfc 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -52,11 +52,14 @@ import com.intellij.openapi.editor.actions.PasteAction import com.intellij.openapi.editor.colors.CodeInsightColors import com.intellij.openapi.fileEditor.TextEditor import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.openapi.fileTypes.PlainTextLanguage import com.intellij.openapi.keymap.KeymapUtil import com.intellij.testFramework.PlatformTestUtil import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.AnimatedIcon import com.intellij.ui.EditorTextField +import com.intellij.ui.LanguageTextField import com.intellij.ui.components.JBLabel import com.intellij.util.Producer import com.intellij.util.ui.EmptyIcon @@ -129,11 +132,16 @@ class PromptPanelTest : BasePlatformTestCase() { } fun `test prompt editor hides floating toolbar`() { + val control = toolbarControl() + realize(control, 260, 400) + UIUtil.dispatchAllInvocationEvents() + + assertTrue(hasFloatingToolbar(control.getEditor(false)!!.component)) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) realize(panel, 260, 400) UIUtil.dispatchAllInvocationEvents() - UIUtil.dispatchAllInvocationEvents() val editor = (panel.defaultFocusedComponent as EditorTextField).getEditor(false)!! assertFalse(hasFloatingToolbar(editor.component)) @@ -1093,7 +1101,7 @@ class PromptPanelTest : BasePlatformTestCase() { return out } - private fun realize(panel: PromptPanel, width: Int, height: Int): SessionRootPanel { + private fun realize(panel: Component, width: Int, height: Int): SessionRootPanel { val root = SessionRootPanel() root.setSize(width, height) root.content.add(JPanel(BorderLayout()).apply { add(panel, BorderLayout.SOUTH) }, BorderLayout.CENTER) @@ -1105,6 +1113,16 @@ class PromptPanelTest : BasePlatformTestCase() { return root } + private fun toolbarControl(): EditorTextField { + val doc = LanguageTextField.createDocument( + "", + PlainTextLanguage.INSTANCE, + project, + LanguageTextField.SimpleDocumentCreator(), + ) + return EditorTextField(doc, project, PlainTextFileType.INSTANCE, false, false) + } + private fun completion() = KiloPromptCompletionProvider( workspace = workspaces.workspace("/test"), service = workspaces, From dd0a6323fb25e6533fd8dcf133f447c7de7a5478 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 5 Jul 2026 21:12:21 -0400 Subject: [PATCH 16/19] feat(jetbrains): highlight focused prompt input --- .changeset/focused-jetbrains-prompt.md | 5 +++++ .../client/session/ui/prompt/PromptPanel.kt | 13 +++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 .changeset/focused-jetbrains-prompt.md diff --git a/.changeset/focused-jetbrains-prompt.md b/.changeset/focused-jetbrains-prompt.md new file mode 100644 index 00000000000..6fd3826dc68 --- /dev/null +++ b/.changeset/focused-jetbrains-prompt.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show a focused accent line above the JetBrains prompt input. 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 cb91a8ed647..5e18c1e01d0 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 @@ -304,6 +304,19 @@ class PromptPanel( ) } + override fun paintChildren(g: Graphics) { + super.paintChildren(g) + if (!editorFocused()) return + val h = JBUI.scale(SessionUiStyle.View.Prompt.FOCUS_WIDTH) + g.color = JBUI.CurrentTheme.Focus.focusColor() + g.fillRect(0, JBUI.scale(1), width, h) + } + + private fun editorFocused(): Boolean { + val ed = editor.getEditor(false) ?: return editor.hasFocus() + return editor.hasFocus() || ed.contentComponent.hasFocus() + } + @RequiresEdt fun setReady(value: Boolean) { ready = value From 819cef526dcb2738fd4fd2d0420368ab1b4fbc04 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 5 Jul 2026 21:26:45 -0400 Subject: [PATCH 17/19] feat(jetbrains): outline focused prompt input --- .changeset/focused-jetbrains-prompt.md | 2 +- .../client/session/ui/prompt/PromptPanel.kt | 46 +++++++++++++++++-- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/.changeset/focused-jetbrains-prompt.md b/.changeset/focused-jetbrains-prompt.md index 6fd3826dc68..17d01ce6f67 100644 --- a/.changeset/focused-jetbrains-prompt.md +++ b/.changeset/focused-jetbrains-prompt.md @@ -2,4 +2,4 @@ "@kilocode/kilo-jetbrains": patch --- -Show a focused accent line above the JetBrains prompt input. +Show a focus outline around the JetBrains prompt input. 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 5e18c1e01d0..19db08ab224 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 @@ -54,10 +54,11 @@ import com.intellij.openapi.editor.markup.RangeHighlighter import com.intellij.openapi.keymap.Keymap import com.intellij.openapi.keymap.KeymapManagerListener import com.intellij.openapi.keymap.KeymapUtil -import com.intellij.openapi.project.Project import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.project.Project import com.intellij.openapi.util.IconLoader import com.intellij.ui.AnimatedIcon +import com.intellij.ui.IslandsState import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.xml.util.XmlStringUtil import com.intellij.util.ui.JBDimension @@ -71,6 +72,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.awt.BasicStroke import java.awt.BorderLayout import java.awt.Cursor import java.awt.Graphics @@ -84,6 +86,7 @@ import java.awt.event.ComponentAdapter import java.awt.event.ComponentEvent import java.awt.event.MouseAdapter import java.awt.event.MouseEvent +import java.awt.geom.Path2D import java.util.concurrent.Future import javax.swing.Box import javax.swing.BoxLayout @@ -307,9 +310,44 @@ class PromptPanel( override fun paintChildren(g: Graphics) { super.paintChildren(g) if (!editorFocused()) return - val h = JBUI.scale(SessionUiStyle.View.Prompt.FOCUS_WIDTH) - g.color = JBUI.CurrentTheme.Focus.focusColor() - g.fillRect(0, JBUI.scale(1), width, h) + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + val line = JBUI.scale(SessionUiStyle.View.Prompt.FOCUS_WIDTH) + val half = line / 2f + val top = half + val left = half + val right = width - half + val bottom = height - half + val arc = if (IslandsState.isEnabled()) { + JBUI.scale(JBUI.getInt("Island.arc", SessionUiStyle.View.Prompt.CORNER_ARC)) / 2f + } else { + 0f + } + val radius = arc + .coerceAtMost((right - left) / 2f) + .coerceAtMost(bottom - top) + .coerceAtLeast(0f) + val path = Path2D.Float().apply { + moveTo(left, top) + lineTo(right, top) + lineTo(right, bottom - radius) + if (radius > 0f) { + quadTo(right, bottom, right - radius, bottom) + lineTo(left + radius, bottom) + quadTo(left, bottom, left, bottom - radius) + } else { + lineTo(right, bottom) + lineTo(left, bottom) + } + closePath() + } + g2.color = JBUI.CurrentTheme.Focus.focusColor() + g2.stroke = BasicStroke(line.toFloat(), BasicStroke.CAP_BUTT, BasicStroke.JOIN_ROUND) + g2.draw(path) + } finally { + g2.dispose() + } } private fun editorFocused(): Boolean { From 5a60c9c0fbfef39c54904169f87e85531309b8a4 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 5 Jul 2026 23:22:15 -0400 Subject: [PATCH 18/19] test(jetbrains): cover prompt focus outline --- .../client/session/ui/PromptPanelTest.kt | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index 4abd8716dfc..a4838926a4f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -72,8 +72,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import java.awt.BorderLayout +import java.awt.Color import java.awt.Component import java.awt.Container +import java.awt.DefaultKeyboardFocusManager +import java.awt.KeyboardFocusManager import java.awt.datatransfer.DataFlavor import java.awt.datatransfer.StringSelection import java.awt.datatransfer.Transferable @@ -147,6 +150,27 @@ class PromptPanelTest : BasePlatformTestCase() { assertFalse(hasFloatingToolbar(editor.component)) } + fun `test prompt focus outline follows editor focus`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + realize(panel, 260, 400) + panel.setBounds(0, 0, 260, panel.preferredSize.height) + panel.doLayout() + + val editor = (panel.defaultFocusedComponent as EditorTextField).getEditor(false)!! + val current = KeyboardFocusManager.getCurrentKeyboardFocusManager() + val focus = TestFocusManager() + KeyboardFocusManager.setCurrentKeyboardFocusManager(focus) + try { + assertTrue(JBUI.CurrentTheme.Focus.focusColor().rgb != paint(panel, panel.width / 2, 1).rgb) + + focus.focus(editor.contentComponent) + + assertEquals(JBUI.CurrentTheme.Focus.focusColor().rgb, paint(panel, panel.width / 2, 1).rgb) + } finally { + KeyboardFocusManager.setCurrentKeyboardFocusManager(current) + } + } + fun `test applyStyle updates prompt input and height`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) val style = SessionEditorStyle.create(family = "Courier New", size = 26) @@ -1123,6 +1147,17 @@ class PromptPanelTest : BasePlatformTestCase() { return EditorTextField(doc, project, PlainTextFileType.INSTANCE, false, false) } + private fun paint(component: Component, x: Int, y: Int): Color { + val image = BufferedImage(component.width, component.height, BufferedImage.TYPE_INT_ARGB) + val g = image.createGraphics() + try { + component.paint(g) + } finally { + g.dispose() + } + return Color(image.getRGB(x, y), true) + } + private fun completion() = KiloPromptCompletionProvider( workspace = workspaces.workspace("/test"), service = workspaces, @@ -1283,6 +1318,12 @@ class PromptPanelTest : BasePlatformTestCase() { } } + private class TestFocusManager : DefaultKeyboardFocusManager() { + fun focus(component: Component) { + setGlobalFocusOwner(component) + } + } + private class TestSink : CopyProviderSink() { var send: Any? = null var file: Any? = null From 04600b63c29686357832e09851e93b00f2a3db5a Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 6 Jul 2026 08:57:04 -0400 Subject: [PATCH 19/19] chore: remove plan file from pr --- ...5747000-jetbrains-subagent-review-fixes.md | 165 ------------------ 1 file changed, 165 deletions(-) delete mode 100644 .kilo/plans/1783185747000-jetbrains-subagent-review-fixes.md diff --git a/.kilo/plans/1783185747000-jetbrains-subagent-review-fixes.md b/.kilo/plans/1783185747000-jetbrains-subagent-review-fixes.md deleted file mode 100644 index c3f4c6ef995..00000000000 --- a/.kilo/plans/1783185747000-jetbrains-subagent-review-fixes.md +++ /dev/null @@ -1,165 +0,0 @@ -# TaskToolView / subagent review fixes - -## Goal - -Close the review findings on the `massive-fontina` branch's JetBrains subagent -(`TaskToolView`) work: remove dead code, add the missing streaming stress/leak test, shrink -test-only production seams, fix a theme-in-constructor border, and add direct `SessionModel` -child-tool bookkeeping tests. These are behavior-preserving cleanups plus new tests — the -inline-subagent feature itself already works and is controller-tested. - -## Scope - -All work is confined to Kilo-owned JetBrains paths (path contains `kilo`, so **no -`kilocode_change` markers needed**): - -- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt` -- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt` - (tests only; no production change unless a bug surfaces) -- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt` - (optional, item 7) -- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt` - (optional, item 8) -- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/**` -- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/**` - -Out of scope: the `MdViewHybrid`/`MdProjector` refactor (already landed and well-tested), the -six shipped UI fixes' runtime behavior, and any change to the `AbstractSessionPartView` base -or sibling tool views. Do not touch `SessionModel`'s child-tool logic unless item 6 tests -reveal a real defect. - -## Constraints - -- Swing on the IntelliJ platform: all component creation/mutation stays on the EDT; keep the - existing `@RequiresEdt` intent. No background-thread UI mutation. -- Tests extend `BasePlatformTestCase` for a real Application + EDT. **No mocks** of the EDT, - threading, or platform types — assert against the real Swing tree, as existing tests do. -- Prefer single-word names; avoid `let`/`else`/`try-catch` where the style guide says so. -- Do not add new test-only production accessors. When a test needs state, prefer walking the - real component tree from the test (as `SessionMessageListPanelTest` / `ShellToolViewTest` - already do) over exposing a new seam. -- Implementation requires source edits: hand off to an implementation-capable agent. Validate - with `./gradlew typecheck test --tests "ai.kilocode.client.session.views.*"` and - `--tests "ai.kilocode.client.session.model.*"` from `packages/kilo-jetbrains/` (Java 21). - -## Decisions (recommended; change before implementing if you disagree) - -1. **`TaskToolView.controlCount()` is dead** — the only caller of any `controlCount()` is - `ToolViewTest` against `ToolView`. **Recommendation: delete it.** -2. **Test-only accessors**: many `TaskToolView` methods are used only by tests - (`rowLabels`, `bodyScrollValue/bodyScrollBottom/setBodyScrollValue`, `horizontalPolicy`, - `verticalPolicy`, `bodyInsets`, `rowTitleColor(id)`, and the `public` `rowCount`/ - `bodyCreated`). **Recommendation: (a) delete the ones a test can replace by walking the - real tree; (b) for the few that are awkward to derive from the tree, keep them but make - them `internal` to match sibling views (`ToolView`, `ReadToolView`, `ReasoningView`).** Do - not expand the public surface beyond the sibling pattern. `labelText()` (used by - `dumpLabel()`) and `bodyVisible()`/`bodyMaxRows()` (used internally) stay — they have - product use. -3. **`TaskToolViewStressTest` is required** (AGENTS.md "Stress and Leak Tests for Streaming - UI"). **Recommendation: add it**, mirroring `ReasoningViewStressTest`. -4. **`TaskBody` glyph-derived left inset** is captured once in the constructor - (`glyph.preferredSize.width`). **Recommendation: recompute on `updateUI()`** (or drop the - glyph-width dependency) so the indent tracks LaF/DPI changes. Keep it minimal. -5. **Items 7 (PromptPanel default scope) and 8 (padPopup direct-children scan) are optional - hardening.** **Recommendation: do 7 if cheap, defer 8** unless the popup tree changes — - both are low risk today. - -## Task list (ordered) - -### Phase 1 — Dead code - -1. Delete `TaskToolView.controlCount()` (`TaskToolView.kt:100-101`). Grep-confirm zero - references in `frontend/src` and `frontend/src/test` before removing. - -### Phase 2 — Stress + leak test (safety net for later trimming) - -2. Add `frontend/src/test/.../session/views/TaskToolViewStressTest.kt` modeled on - `ReasoningViewStressTest`. It must, through the public `update(content)` API: - - Drive hundreds of child-tool updates (append child tools 1..N, and interleave - remove/re-add) via `Tool.childTools` snapshots. - - `assertSame` that retained `Row` panels for unchanged child ids stay identical across - updates (read them from the body's `Stack` component tree, not a new accessor). - - Assert the body row `componentCount` stays bounded (equals visible child count, no - per-update growth). - - Assert no editor leak is trivially satisfied (rows are `JBLabel`s, no editors) — still - capture `EditorFactory.getInstance().allEditors.size` before/after churn + a `collapse()` - cycle to prove nothing spawns editors. - Confirm this test passes against current `TaskToolView` before Phase 3. - -### Phase 3 — Shrink test-only seams - -3. Rewrite `TaskToolViewTest` assertions that currently call test-only accessors to instead - walk the real Swing tree (helper that recurses `component.components`, as - `ShellToolViewTest.popupScrollPanes` and `SessionMessageListPanelTest` do). Specifically - replace usage of `rowLabels`, `bodyInsets`, `rowTitleColor(id)`, `horizontalPolicy`, - `verticalPolicy`, `bodyScrollValue/bodyScrollBottom/setBodyScrollValue` where a tree walk - is clean. -4. In `TaskToolView`, delete accessors that no longer have any caller after step 3; make the - remaining test-facing ones `internal` (match `ToolView`/`ReadToolView`). Keep `labelText`, - `bodyVisible`, `bodyMaxRows` (real internal/product callers). -5. Resolve the duplicate `rowTitleColor` name: the member accessor `rowTitleColor(id: String)` - (`:119`) and the top-level `rowTitleColor(tool: Tool)` (`:342`) share a name for unrelated - jobs. If the member survives step 4, rename it (e.g. `rowColor`) or fold the assertion into - a tree walk so only the state→color helper keeps the name. - -### Phase 4 — Theme-in-constructor border - -6. Fix `TaskBody.panel` left inset (`TaskToolView.kt:299-304`) so the glyph-width-derived - indent is re-evaluated on Look-and-Feel / DPI change instead of frozen at construction. - Options: (a) override `updateUI()` on the body panel to recompute the border, or (b) - derive the indent from a `JBUI`/style token rather than the live `glyph.preferredSize`. - Prefer (b) if a suitable `SessionUiStyle`/`UiStyle` value exists; otherwise (a). While here, - drop the redundant `isOpaque = true` on `TaskBody.panel` and `TaskBodyScroll` (JPanel/scroll - default is opaque) per the "Before Returning UI Code" checklist — only if it does not change - rendering. - -### Phase 5 — SessionModel child-tool unit tests - -7. Add direct model coverage in `frontend/src/test/.../session/model/` (extend the existing - `SessionModel` test if present, else add one) for: - - **Re-keying**: a `task` part whose `metadata["sessionId"]` changes must move tracking — - old `childRefs`/`childTools` entry dropped, new one created (`SessionModel.kt:451-456`). - - **Untracking on removal**: `removeMessage` / `removeContent` of a parent `task` part must - clear its `childRefs`/`childTools` entries (`SessionModel.kt:147,159` → - `untrackChild`), and a later `upsertChildTool` for that child becomes a no-op. - These currently only run incidentally through `PromptLifecycleTest`. - -### Phase 6 — Optional hardening (only if cheap) - -8. `PromptPanel` (`:107`): consider replacing the default - `cs: CoroutineScope = CoroutineScope(Dispatchers.Default)` with a required parameter or a - disposable-bound scope so no uncancelled global scope is created. Production already passes - `SessionUi`'s scope; this only affects defaults/tests. Skip if it ripples into constructors. -9. `ShellToolView.padPopup`: it scans only direct children - (`root.components.filterIsInstance()`). Consider the same recursive walk the - settings-list fix uses, for robustness if the popup tree ever nests the pane deeper. Skip - unless the popup layout changes. - -## Risks - -- **Trimming accessors could reduce assertion fidelity.** Mitigation: land the - `TaskToolViewStressTest` and the tree-walk helper (Phases 2-3) *before* deleting accessors, - so every removed accessor has an equivalent tree-based assertion first; keep the full - `session.views` suite green after each phase. -- **`updateUI()` override ordering.** `updateUI()` runs during construction; guard against - NPEs on fields not yet initialized (e.g. read `glyph`/style lazily or null-check) and avoid - triggering a layout storm. Verify `test task body is indented beyond header padding` - (`TaskToolViewTest`) still passes. -- **Dropping `isOpaque = true`** must not change the surface fill. Verify against the existing - body-background assertions; revert that sub-step if any rendering test regresses. - -## Validation - -- Targeted: `./gradlew test --tests "ai.kilocode.client.session.views.TaskToolView*"`, - `--tests "ai.kilocode.client.session.ui.SessionMessageListPanelTest"`, and - `--tests "ai.kilocode.client.session.model.*"` from `packages/kilo-jetbrains/`. -- Full guardrails: `./gradlew typecheck test` from `packages/kilo-jetbrains/` (Java 21). -- Grep-confirm `controlCount` and any deleted accessors have zero references after removal. -- Sanity: the new stress test should fail against a deliberately broken `syncRows` (e.g. - rebuild-all) and pass against current retained-row behavior. - -## Handoff - -This plan is implementation-ready. Switch to an implementation-capable agent to make the -source and test edits; do all changes in this worktree only. No `kilocode_change` markers are -required (all paths are Kilo-owned).