From 1500843a342d66b56bc64ac26fa6f9d60e7d255f Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 14 Aug 2026 13:00:48 -0400 Subject: [PATCH] feat(jetbrains): add hover preview popup to grep, glob, other tools, and todos --- .../jetbrains-expandable-hover-popups.md | 5 ++ .../client/session/ui/ModifiedFilesView.kt | 9 +-- .../client/session/views/ReasoningView.kt | 10 +-- .../views/base/AbstractSessionPartView.kt | 65 +++++++++++++++++++ .../session/views/todo/TodoWriteView.kt | 12 ++++ .../session/views/tool/BaseSearchToolView.kt | 9 +++ .../client/session/views/tool/EditToolView.kt | 10 +-- .../session/views/tool/ShellToolView.kt | 46 +++++-------- .../client/session/views/tool/ToolSupport.kt | 13 ++++ .../client/session/views/tool/ToolView.kt | 9 +++ .../client/session/views/GlobToolViewTest.kt | 35 ++++++++++ .../client/session/views/PopupTestSupport.kt | 15 +++++ .../session/views/SearchToolViewTest.kt | 37 +++++++++++ .../client/session/views/ToolViewTest.kt | 41 ++++++++++++ .../session/views/todo/TodoWriteViewTest.kt | 38 +++++++++++ 15 files changed, 301 insertions(+), 53 deletions(-) create mode 100644 .changeset/jetbrains-expandable-hover-popups.md create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PopupTestSupport.kt diff --git a/.changeset/jetbrains-expandable-hover-popups.md b/.changeset/jetbrains-expandable-hover-popups.md new file mode 100644 index 00000000000..0c425a30f1e --- /dev/null +++ b/.changeset/jetbrains-expandable-hover-popups.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Show the collapsed hover preview on more transcript cards: grep, glob, other tool calls, and to-dos now open the same popup that shell and diffs use. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt index 09a61fdf159..dcae262e864 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt @@ -20,7 +20,6 @@ import ai.kilocode.client.session.views.tool.PatchBody import ai.kilocode.client.session.views.tool.setFont import ai.kilocode.client.session.views.tool.setForeground import ai.kilocode.client.session.views.tool.setIcon -import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.client.ui.DiffBars import ai.kilocode.client.ui.ToolbarButtonAction import ai.kilocode.client.ui.UiStyle @@ -112,12 +111,8 @@ class ModifiedFilesView private constructor( override fun copyText(): String? = null @RequiresEdt - override fun headerPopup(): HeaderPopupRequest? { - if (isExpanded() || files.isEmpty()) return null - return HeaderPopupRequest(row, build = { buildPopup(files) }) { - Telemetry.send("Header Popup Shown", mapOf("surface" to "session", "tool" to "changes")) - } - } + override fun headerPopup(): HeaderPopupRequest? = + popup("tool", "changes", files.isNotEmpty()) { buildPopup(files) } @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt index 106b8bef646..a48d89c0225 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt @@ -14,7 +14,6 @@ import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.AbstractSessionPartView import ai.kilocode.client.session.views.base.PartHeader -import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.md.MdView import ai.kilocode.client.ui.md.MdViewFactory @@ -163,13 +162,8 @@ class ReasoningView( internal fun bodyScrollBottom() = parts.scrollOrNull?.verticalScrollBar?.let { it.maximum - it.visibleAmount } ?: 0 @RequiresEdt - override fun headerPopup(): HeaderPopupRequest? { - if (isExpanded()) return null - val text = source.takeIf { it.isNotBlank() } ?: return null - return HeaderPopupRequest(row, build = { buildPopupBody(text) }) { - Telemetry.send("Header Popup Shown", mapOf("surface" to "session", "part" to "reasoning")) - } - } + override fun headerPopup(): HeaderPopupRequest? = + popup("part", "reasoning", source.isNotBlank()) { buildPopupBody(source) } @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt index 16897606d57..950227739ef 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt @@ -1,14 +1,25 @@ package ai.kilocode.client.session.views.base +import ai.kilocode.client.session.ui.popup.HeaderPopupBody +import ai.kilocode.client.session.ui.popup.HeaderPopupRequest +import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.SessionViewIcons +import ai.kilocode.client.telemetry.Telemetry +import ai.kilocode.client.ui.md.MdCodeBlockFactory +import ai.kilocode.client.ui.md.MdCodeBlockOptions +import ai.kilocode.client.ui.md.MdView +import ai.kilocode.client.ui.md.MdViewFactory +import com.intellij.openapi.util.Disposer import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI import java.awt.BorderLayout import java.awt.Color import java.awt.Component import java.awt.Container import java.awt.Cursor +import java.awt.Font import java.awt.Graphics import java.awt.Graphics2D import java.awt.RenderingHints @@ -161,6 +172,60 @@ abstract class AbstractSessionPartView( repaint() } + /** + * Standard collapsed hover-preview request anchored to the card header, or null when the card is + * not expandable, is already expanded, or has no [present] preview content. [kind]/[name] are the + * telemetry attributes (e.g. `"tool"`/`"bash"`, `"part"`/`"reasoning"`). [body] is built lazily + * when the popup actually shows; its disposable is owned by the popup controller and disposed on + * hide, so subclasses just build fresh, self-contained content. + */ + @RequiresEdt + protected fun popup(kind: String, name: String, present: Boolean, body: () -> HeaderPopupBody): HeaderPopupRequest? { + if (!expandable || isExpanded() || !present) return null + return HeaderPopupRequest(row, body) { + Telemetry.send("Header Popup Shown", mapOf("surface" to "session", kind to name)) + } + } + + /** + * Builds a markdown-backed [HeaderPopupBody] from [markdown]. The created [MdView] is owned by a + * fresh disposable that the popup controller disposes when the popup hides, so its editor is always + * released. [options] renders through an editor-only code block (shell/diff style); null renders + * prose. Height is bounded centrally by the popup panel to the same cap every popup shares; width + * uses the wide or normal popup cap. + */ + @RequiresEdt + protected fun markdownPopupBody( + style: SessionEditorStyle, + markdown: String, + wide: Boolean = true, + options: MdCodeBlockOptions? = null, + font: Font = style.editorFont, + foreground: Color = style.editorForeground, + link: ((String) -> Unit)? = null, + afterSet: (MdView) -> Unit = {}, + ): HeaderPopupBody { + val owner = Disposer.newDisposable("Header popup body") + val md = if (options != null) { + MdViewFactory.create(style, null, MdCodeBlockFactory.default(options)) + } else { + MdViewFactory.create(style, null) + } + Disposer.register(owner, md) + link?.let { l -> md.addLinkListener { l(it.href) } } + md.applyStyle(style) + md.font = font + md.foreground = foreground + md.background = SessionUiStyle.Colors.codeBlockBackground() + md.preBg = SessionUiStyle.Colors.codeBlockBackground() + md.codeFont = style.editorFamily + md.component.border = JBUI.Borders.empty() + md.set(markdown) + afterSet(md) + val width = if (wide) SessionUiStyle.View.Popup.WIDE_MAX_WIDTH else SessionUiStyle.View.Popup.MAX_WIDTH + return HeaderPopupBody(md.component, owner, SessionUiStyle.Colors.codeBlockBackground(), width) + } + /** * Header background, hovered or not. The header keeps the same rounded fill whether the card is * collapsed or expanded — only the arrow toggles — because the card no longer draws an outline 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 da649455ab9..d3972fc8117 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 @@ -4,6 +4,7 @@ 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.popup.HeaderPopupRequest import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.SessionViewIcons @@ -11,7 +12,9 @@ import ai.kilocode.client.session.views.base.AbstractSessionPartView import ai.kilocode.client.session.views.base.PartHeader import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.rpc.dto.TodoDto import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI import java.awt.Font import javax.swing.JComponent @@ -38,6 +41,15 @@ class TodoWriteView(tool: Tool, private val parts: TodoParts = todoParts()) : sync() } + @RequiresEdt + override fun headerPopup(): HeaderPopupRequest? { + val md = todoMarkdown(item.todos) + return popup("part", "todo", md.isNotBlank()) { markdownPopupBody(style, md) } + } + + private fun todoMarkdown(todos: List): String = + todos.joinToString("\n") { "- [${if (it.status == "completed") "x" else " "}] ${it.content}" } + override fun applyStyle(style: SessionEditorStyle) { this.style = style var changed = false diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt index d356620b26c..7a1d858c5bf 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.views.tool import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.ui.popup.HeaderPopupRequest import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle @@ -64,6 +65,14 @@ abstract class BaseSearchToolView( if (changed) refresh() } + @RequiresEdt + override fun headerPopup(): HeaderPopupRequest? { + val md = toolBodyMarkdown(item) + return popup("tool", item.name, md.isNotBlank()) { + markdownPopupBody(style, md, options = POPUP_OPTS, foreground = bodyColor()) + } + } + @RequiresEdt fun labelText(): String = listOf(parts.title.text).plus(targetTexts()).plus(parts.state.text) .filter { it.isNotBlank() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt index 3f4ceec9d24..6ef6e318682 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt @@ -17,7 +17,6 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.session.views.base.PartHeader import ai.kilocode.client.session.views.base.AbstractSessionPartView -import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.client.ui.DiffStatBadge import ai.kilocode.client.ui.ToolbarButtonAction import ai.kilocode.client.ui.UiStyle @@ -196,13 +195,8 @@ class EditToolView( internal fun codeEditors(): List = body.codeEditors() @RequiresEdt - override fun headerPopup(): HeaderPopupRequest? { - if (isExpanded()) return null - if (editDiff(item).isBlank()) return null - return HeaderPopupRequest(row, build = { buildPopupBody() }) { - Telemetry.send("Header Popup Shown", mapOf("surface" to "session", "tool" to "edit")) - } - } + override fun headerPopup(): HeaderPopupRequest? = + popup("tool", "edit", editDiff(item).isNotBlank()) { buildPopupBody() } @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { 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 0f9d40ce151..86a72222e60 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 @@ -2,7 +2,6 @@ package ai.kilocode.client.session.views.tool import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Tool -import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.client.session.ui.SessionContentPanel import ai.kilocode.client.session.ui.SessionSurfacePanel import ai.kilocode.client.session.ui.popup.HeaderPopupBody @@ -12,9 +11,7 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.AbstractSessionPartView import ai.kilocode.client.ui.md.MdCodeBlockBorder -import ai.kilocode.client.ui.md.MdCodeBlockFactory import ai.kilocode.client.ui.md.MdCodeBlockOptions -import ai.kilocode.client.ui.md.MdViewFactory import ai.kilocode.client.ui.md.hybrid.MdTerminal import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.DataSink @@ -147,11 +144,8 @@ class ShellToolView( @RequiresEdt override fun headerPopup(): HeaderPopupRequest? { - if (isExpanded()) return null - val cmd = command(item).takeIf { it.isNotBlank() } ?: return null - return HeaderPopupRequest(row, build = { buildPopupBody(cmd) }) { - Telemetry.send("Header Popup Shown", mapOf("surface" to "session", "tool" to "bash")) - } + val cmd = command(item) + return popup("tool", "bash", cmd.isNotBlank()) { buildPopupBody(cmd) } } @RequiresEdt @@ -185,29 +179,14 @@ class ShellToolView( private fun syncBody(): Boolean = body.update(item) @RequiresEdt - private fun buildPopupBody(cmd: String): HeaderPopupBody { - val md = MdViewFactory.create( + private fun buildPopupBody(cmd: String): HeaderPopupBody = + markdownPopupBody( style, - null, - MdCodeBlockFactory.default( - MdCodeBlockOptions( - border = MdCodeBlockBorder.None, - verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, - editorOnly = true, - ), - ), - ) - md.applyStyle(style) - md.font = style.transcriptFont - md.foreground = style.editorForeground - md.background = SessionUiStyle.Colors.codeBlockBackground() - md.preBg = SessionUiStyle.Colors.codeBlockBackground() - md.codeFont = style.editorFamily - md.component.border = JBUI.Borders.empty() - md.set(popupShellMarkdown(item, cmd)) - padPopup(md.component) - return HeaderPopupBody(md.component, md, SessionUiStyle.Colors.codeBlockBackground(), SessionUiStyle.View.Popup.WIDE_MAX_WIDTH) - } + popupShellMarkdown(item, cmd), + options = SHELL_POPUP_OPTS, + font = style.transcriptFont, + foreground = style.editorForeground, + ) { padPopup(it.component) } override fun dumpLabel() = "ShellToolView#$contentId(${labelText()})" @@ -299,6 +278,13 @@ class ShellBody(selection: SessionSelection?) { } } +/** Editor-only code block used by the collapsed shell hover popup (uncapped; the popup scrolls). */ +private val SHELL_POPUP_OPTS = MdCodeBlockOptions( + border = MdCodeBlockBorder.None, + verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, + editorOnly = true, +) + private fun shellSection(selection: SessionSelection?, render: (Tool) -> String) = ToolMarkdownBody( MdCodeBlockOptions( border = MdCodeBlockBorder.None, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt index 2eb10a23aef..9706e8ef69a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt @@ -647,6 +647,19 @@ internal fun preview(tool: Tool): String = if (tool.name == "bash") shellPreview internal fun body(tool: Tool): String = if (tool.name == "bash") shellBody(tool) else plainBody(tool) +/** Fenced markdown of a tool's body text for the collapsed hover popup; empty when nothing to show. */ +internal fun toolBodyMarkdown(tool: Tool): String { + val text = body(tool) + if (text.isBlank()) return "" + val fence = fence(text) + return buildString { + append(fence).append('\n') + append(text) + if (!text.endsWith('\n')) append('\n') + append(fence) + } +} + private fun shellPreview(tool: Tool): String { val cmd = command(tool) val out = output(tool) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt index aac1ce30db8..ec225239a30 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.views.tool import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.ui.popup.HeaderPopupRequest import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle @@ -70,6 +71,14 @@ class ToolView( if (changed) refresh() } + @RequiresEdt + override fun headerPopup(): HeaderPopupRequest? { + val md = toolBodyMarkdown(item) + return popup("tool", item.name, md.isNotBlank()) { + markdownPopupBody(style, md, options = POPUP_OPTS, foreground = bodyColor()) + } + } + @RequiresEdt fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text) .filter { it.isNotBlank() } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt index 9f3f0e2869a..921d95fa965 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt @@ -8,8 +8,12 @@ import ai.kilocode.client.session.views.tool.GlobToolView import ai.kilocode.client.session.views.tool.ReadToolView import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import com.intellij.openapi.editor.EditorFactory 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 javax.swing.ScrollPaneConstants @Suppress("UnstableApiUsage") @@ -155,6 +159,37 @@ class GlobToolViewTest : BasePlatformTestCase() { assertFalse(ViewFactory.shouldReplace(GlobToolView(glob), glob)) } + fun `test glob header popup previews matches when collapsed`() { + val view = track(GlobToolView(tool().also { + it.input = mapOf("pattern" to "**/*.kt") + it.output = "src/A.kt\nsrc/B.kt" + })) + val body = view.headerPopup()!!.build() + try { + val editors = popupEditors(body.component) + editors.forEach { it.getEditor(true) } + assertEquals(listOf("src/A.kt\nsrc/B.kt"), editors.map { it.text }) + assertTrue(body.component.preferredSize.height in 1..JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT)) + } finally { + Disposer.dispose(body.disposable) + } + } + + fun `test glob header popup is absent when expanded and leaks no editors`() { + val base = EditorFactory.getInstance().allEditors.size + val view = track(GlobToolView(tool().also { it.output = "src/A.kt" })) + assertNotNull(view.headerPopup()) + repeat(20) { + val body = view.headerPopup()!!.build() + popupEditors(body.component).forEach { it.getEditor(true) } + Disposer.dispose(body.disposable) + } + UIUtil.dispatchAllInvocationEvents() + assertEquals(base, EditorFactory.getInstance().allEditors.size) + view.toggle() + assertNull(view.headerPopup()) + } + private fun tool() = Tool("p1", "glob", toolKind("glob")).also { it.state = ToolExecState.COMPLETED } private fun track(view: GlobToolView): GlobToolView { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PopupTestSupport.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PopupTestSupport.kt new file mode 100644 index 00000000000..faf6ede7419 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PopupTestSupport.kt @@ -0,0 +1,15 @@ +package ai.kilocode.client.session.views + +import com.intellij.ui.EditorTextField +import javax.swing.JComponent + +/** Collects every code editor nested anywhere inside a built header popup body. */ +internal fun popupEditors(root: JComponent): List { + val found = mutableListOf() + fun visit(component: JComponent) { + if (component is EditorTextField) found.add(component) + component.components.filterIsInstance().forEach(::visit) + } + visit(root) + return found +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt index d22e553a34a..e3e9e1a6e76 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt @@ -10,9 +10,11 @@ import ai.kilocode.client.session.views.tool.ReadToolView import ai.kilocode.client.session.views.tool.SearchToolView import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.session.ui.style.SessionUiStyle +import com.intellij.openapi.editor.EditorFactory 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.BorderLayout import java.awt.Container import java.awt.Dimension @@ -205,6 +207,41 @@ class SearchToolViewTest : BasePlatformTestCase() { assertFalse(ViewFactory.shouldReplace(SearchToolView(search), search)) } + fun `test search header popup previews results when collapsed`() { + val view = track(SearchToolView(tool().also { + it.input = mapOf("pattern" to "foo") + it.output = "a.kt:1: foo\nb.kt:2: foo" + })) + val body = view.headerPopup()!!.build() + try { + val editors = popupEditors(body.component) + editors.forEach { it.getEditor(true) } + assertEquals(listOf("a.kt:1: foo\nb.kt:2: foo"), editors.map { it.text }) + assertTrue(body.component.preferredSize.height in 1..JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT)) + } finally { + Disposer.dispose(body.disposable) + } + } + + fun `test search header popup is absent when expanded`() { + val view = track(SearchToolView(tool().also { it.output = "hit" })) + assertNotNull(view.headerPopup()) + view.toggle() + assertNull(view.headerPopup()) + } + + fun `test search header popup leaks no editors after churn`() { + val base = EditorFactory.getInstance().allEditors.size + val view = track(SearchToolView(tool().also { it.output = "hit" })) + repeat(20) { + val body = view.headerPopup()!!.build() + popupEditors(body.component).forEach { it.getEditor(true) } + Disposer.dispose(body.disposable) + } + UIUtil.dispatchAllInvocationEvents() + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + private fun layout(root: Container) { root.doLayout() root.components.filterIsInstance().forEach { layout(it) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt index 80139210963..0448d542f4b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt @@ -10,9 +10,11 @@ import ai.kilocode.client.session.views.base.AbstractSessionPartView import ai.kilocode.client.session.views.tool.ToolView import com.intellij.openapi.editor.DefaultLanguageHighlighterColors import com.intellij.openapi.util.Disposer +import com.intellij.openapi.editor.EditorFactory import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.scale.JBUIScale import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil import java.awt.BorderLayout import javax.swing.JPanel import javax.swing.ScrollPaneConstants @@ -407,6 +409,45 @@ class ToolViewTest : BasePlatformTestCase() { assertEquals("part99", view.contentId) } + // ---- header popup ------ + + fun `test tool header popup previews output when collapsed`() { + val view = track(ToolView(tool("g1", "grep", ToolExecState.COMPLETED).also { it.output = "match one\nmatch two" })) + val req = view.headerPopup() + assertNotNull(req) + val body = req!!.build() + try { + val editors = popupEditors(body.component) + editors.forEach { it.getEditor(true) } + assertEquals(listOf("match one\nmatch two"), editors.map { it.text }) + assertTrue(body.component.preferredSize.height in 1..JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT)) + } finally { + Disposer.dispose(body.disposable) + } + } + + fun `test tool header popup is absent when empty or expanded`() { + val empty = track(ToolView(tool("g2", "grep", ToolExecState.COMPLETED))) + assertNull(empty.headerPopup()) + + val view = track(ToolView(tool("g3", "grep", ToolExecState.COMPLETED).also { it.output = "hit" })) + assertNotNull(view.headerPopup()) + view.toggle() + assertNull(view.headerPopup()) + } + + fun `test tool header popup disposes editor after hide and churn`() { + val base = EditorFactory.getInstance().allEditors.size + val view = track(ToolView(tool("g4", "grep", ToolExecState.COMPLETED).also { it.output = "hit" })) + repeat(20) { + val body = view.headerPopup()!!.build() + popupEditors(body.component).forEach { it.getEditor(true) } + Disposer.dispose(body.disposable) + } + UIUtil.dispatchAllInvocationEvents() + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + // ---- helpers ------ private fun tool(id: String, name: String, state: ToolExecState, title: String? = null): Tool = 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 2c088b5e09e..0cbe0f633be 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 @@ -10,11 +10,14 @@ import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import ai.kilocode.rpc.dto.TodoDto import ai.kilocode.rpc.dto.TodoViewDto +import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.util.ui.JBUI import java.awt.BorderLayout import java.awt.Color import java.awt.image.BufferedImage +import javax.swing.JComponent +import javax.swing.JEditorPane import javax.swing.JPanel @Suppress("UnstableApiUsage") @@ -185,5 +188,40 @@ class TodoWriteViewTest : BasePlatformTestCase() { return (header.layout as BorderLayout).hgap } + fun `test todo header popup shows when collapsed and lists todos`() { + val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also { + it.todos = listOf(TodoDto("Done", "completed", "high"), TodoDto("Next", "pending", "medium")) + }) + assertTrue(view.isExpanded()) + assertNull(view.headerPopup()) + + view.toggle() + assertFalse(view.isExpanded()) + val body = view.headerPopup()!!.build() + try { + val html = popupHtml(body.component) + assertTrue(html.contains("Done")) + assertTrue(html.contains("Next")) + } finally { + Disposer.dispose(body.disposable) + } + } + + fun `test todo header popup is absent without todos`() { + val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED)) + view.toggle() + assertNull(view.headerPopup()) + } + + private fun popupHtml(root: JComponent): String { + val out = StringBuilder() + fun visit(component: JComponent) { + if (component is JEditorPane) out.append(component.text) + component.components.filterIsInstance().forEach(::visit) + } + visit(root) + return out.toString() + } + private fun tool(name: String, state: ToolExecState) = Tool("p1", name, toolKind(name)).also { it.state = state } }