diff --git a/.changeset/jetbrains-session-hover.md b/.changeset/jetbrains-session-hover.md index 3ed93f93be5..0a1b8d81b27 100644 --- a/.changeset/jetbrains-session-hover.md +++ b/.changeset/jetbrains-session-hover.md @@ -2,4 +2,4 @@ "@kilocode/kilo-jetbrains": patch --- -Use a subtler platform hover color for JetBrains session cards and match hovered card borders to it. +Refine JetBrains session card hover styling and link completed read file rows to files in the IDE. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt index 9bf12cdd003..0c16a68112c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt @@ -13,12 +13,14 @@ import ai.kilocode.client.session.views.base.PrimarySessionPartView import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.ui.UiStyle import com.intellij.icons.AllIcons +import com.intellij.ui.components.ActionLink import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane import com.intellij.ui.components.JBTextArea import com.intellij.util.ui.JBUI import com.intellij.xml.util.XmlStringUtil import java.awt.BorderLayout +import java.awt.CardLayout import java.awt.Color import java.awt.Dimension import java.awt.Font @@ -38,7 +40,7 @@ class ToolView(tool: Tool, private val parts: ToolParts = toolParts(tool)) : private var style = SessionEditorStyle.current() init { - bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls) + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) parts.text.text = preview(item) applyStyle(style) sync() @@ -62,7 +64,9 @@ class ToolView(tool: Tool, private val parts: ToolParts = toolParts(tool)) : if (changed) refresh() } - fun labelText(): String = listOf(parts.title.text, parts.sub.text, parts.state.text).filter { it.isNotBlank() }.joinToString(" ") + fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text) + .filter { it.isNotBlank() } + .joinToString(" ") fun commandText(): String = command(item) @@ -88,6 +92,7 @@ class ToolView(tool: Tool, private val parts: ToolParts = toolParts(tool)) : var changed = false changed = setFont(parts.title, style.boldEditorFont) || changed changed = setFont(parts.sub, style.smallEditorFont) || changed + changed = setFont(parts.link, style.smallEditorFont) || changed changed = setFont(parts.state, style.smallEditorFont) || changed changed = setFont(parts.text, style.transcriptFont) || changed if (changed) refresh() @@ -138,8 +143,11 @@ class ToolView(tool: Tool, private val parts: ToolParts = toolParts(tool)) : } /** Renders read calls with secondary, borderless chrome. */ -class ReadToolView(tool: Tool, private val parts: ToolParts = toolParts(tool)) : - SecondarySessionPartView(parts.header, parts.scroll) { +class ReadToolView( + tool: Tool, + openFile: (String) -> Unit = {}, + private val parts: ToolParts = toolParts(tool, openFile), +) : SecondarySessionPartView(parts.header, parts.scroll, expandable = false) { companion object { fun canRender(tool: Tool): Boolean = tool.kind == ToolKind.READ @@ -151,7 +159,7 @@ class ReadToolView(tool: Tool, private val parts: ToolParts = toolParts(tool)) : private var style = SessionEditorStyle.current() init { - bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls) + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) parts.text.text = preview(item) applyStyle(style) sync() @@ -172,32 +180,38 @@ class ReadToolView(tool: Tool, private val parts: ToolParts = toolParts(tool)) : if (changed) refresh() } - fun labelText(): String = listOf(parts.title.text, parts.sub.text, parts.state.text).filter { it.isNotBlank() }.joinToString(" ") + fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text) + .filter { it.isNotBlank() } + .joinToString(" ") fun bodyText(): String = body(item) internal fun bodyVisible() = parts.scroll.parent === this internal fun hasToggle() = arrow.isVisible internal fun horizontalPolicy() = parts.scroll.horizontalScrollBarPolicy internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES + internal fun linkVisible() = parts.link.isVisible + internal fun linkText() = parts.link.text ?: "" + internal fun linkHref() = parts.href + internal fun openLink() = parts.openLink() override fun applyStyle(style: SessionEditorStyle) { this.style = style var changed = false changed = setFont(parts.title, style.boldEditorFont) || changed changed = setFont(parts.sub, style.smallEditorFont) || changed + changed = setFont(parts.link, style.smallEditorFont) || changed changed = setFont(parts.state, style.smallEditorFont) || changed changed = setFont(parts.text, style.transcriptFont) || changed if (changed) refresh() } private fun sync(): Boolean { - val expand = canExpand(item) var changed = false - changed = syncExpandable(expand) || changed - changed = setVisible(parts.state, !expand) || changed + changed = syncExpandable(false) || changed + changed = setVisible(parts.state, true) || changed changed = setIcon(parts.glyph, icon(item)) || changed changed = setForeground(parts.glyph, color(item)) || changed changed = setText(parts.title, title(item)) || changed - changed = setText(parts.sub, subtitle(item)) || changed + changed = syncSubtitle() || changed changed = setForeground(parts.title, titleColor(item)) || changed changed = setText(parts.state, stateText(item)) || changed changed = setForeground(parts.state, color(item)) || changed @@ -205,6 +219,29 @@ class ReadToolView(tool: Tool, private val parts: ToolParts = toolParts(tool)) : return changed } + private fun syncSubtitle(): Boolean { + val target = target(item)?.takeIf { it.type == "file" } + if (target != null) { + var changed = false + if (parts.href != target.path) { + parts.href = target.path + changed = true + } + changed = setText(parts.link, tail(target.path).ifBlank { target.path }) || changed + changed = show(parts, true) || changed + return changed + } + + var changed = false + if (parts.href != null) { + parts.href = null + changed = true + } + changed = setText(parts.sub, subtitle(item)) || changed + changed = show(parts, false) || changed + return changed + } + private fun syncBody(): Boolean { val value = preview(item) if (parts.text.text == value) return false @@ -228,17 +265,41 @@ class ToolParts( val glyph: JBLabel, val title: JBLabel, val sub: JBLabel, + val link: ActionLink, + val slot: JPanel, val state: JBLabel, val center: JPanel, val controls: JComponent, val text: JBTextArea, val scroll: JBScrollPane, -) + private val open: ((String) -> Unit)? = null, +) { + var href: String? = null -private fun toolParts(tool: Tool): ToolParts { + fun openLink() { + val value = href ?: return + open?.invoke(value) + } +} + +private const val SUB_CARD = "sub" +private const val LINK_CARD = "link" + +private fun toolParts(tool: Tool, openFile: ((String) -> Unit)? = null): ToolParts { + lateinit var parts: ToolParts val glyph = JBLabel() val title = JBLabel() val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } + val link = ActionLink("") { parts.openLink() }.apply { + isVisible = false + isFocusable = false + setRequestFocusEnabled(false) + } + val slot = JPanel(CardLayout()).apply { + isOpaque = false + add(sub, SUB_CARD) + add(link, LINK_CARD) + } val state = JBLabel().apply { foreground = UiStyle.Colors.weak() } val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply { isOpaque = false } val text = JBTextArea().apply { @@ -266,12 +327,13 @@ private fun toolParts(tool: Tool): ToolParts { val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply { isOpaque = false center.add(title, BorderLayout.WEST) - center.add(sub, BorderLayout.CENTER) + center.add(slot, BorderLayout.CENTER) add(glyph, BorderLayout.WEST) add(center, BorderLayout.CENTER) add(controls, BorderLayout.EAST) } - return ToolParts(header, glyph, title, sub, state, center, controls, text, scroll).also { + parts = ToolParts(header, glyph, title, sub, link, slot, state, center, controls, text, scroll, openFile) + return parts.also { controls.add(it.state) } } @@ -306,6 +368,20 @@ private fun setText(label: JBLabel, text: String): Boolean { return true } +private fun setText(link: ActionLink, text: String): Boolean { + if (link.text == text) return false + link.text = text + return true +} + +private fun show(parts: ToolParts, link: Boolean): Boolean { + if (parts.link.isVisible == link && parts.sub.isVisible != link) return false + (parts.slot.layout as CardLayout).show(parts.slot, if (link) LINK_CARD else SUB_CARD) + return true +} + +private fun subtitleText(parts: ToolParts): String = if (parts.link.isVisible) parts.link.text ?: "" else parts.sub.text + private fun setIcon(label: JBLabel, icon: Icon): Boolean { if (label.icon === icon) return false label.icon = icon @@ -353,10 +429,36 @@ private fun stateText(tool: Tool) = when (tool.state) { } private fun readPath(tool: Tool): String { + val target = target(tool) + if (target != null) { + if (target.type == "file") return tail(target.path).ifBlank { target.path } + return target.path + } val path = tool.input["filePath"] ?: tool.input["path"] ?: tool.title ?: return tool.name return tail(path).ifBlank { path } } +private data class Target( + val path: String, + val type: String, +) + +private fun target(tool: Tool): Target? { + val out = output(tool) + if (out.isBlank()) return null + val path = tag(out, "path") ?: return null + val type = tag(out, "type") ?: return null + return Target(path, type.lowercase()) +} + +private fun tag(text: String, name: String): String? = + Regex("<$name>\\s*([\\s\\S]*?)\\s*") + .find(text) + ?.groupValues + ?.getOrNull(1) + ?.trim() + ?.takeIf { it.isNotBlank() } + private fun shellTitle(tool: Tool): String = tool.input["description"]?.takeIf { it.isNotBlank() } ?: tool.metadata["description"]?.takeIf { it.isNotBlank() } 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 5074a4cbf5d..9c430651d74 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 @@ -28,7 +28,7 @@ object ViewFactory { TodoWriteView.canRender(content) -> TodoWriteView(content) PlanExitView.canRender(content) -> PlanExitView(content, openFile) QuestionResultView.canRender(content) -> QuestionResultView(content) - ReadToolView.canRender(content) -> ReadToolView(content) + ReadToolView.canRender(content) -> ReadToolView(content, openFile) else -> ToolView(content) } is Compaction -> CompactionView(content) 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 f62b2f08313..86dfe55498c 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 @@ -18,6 +18,7 @@ abstract class AbstractSessionPartView( header: JComponent, protected val body: JComponent, expanded: Boolean = false, + private val expandable: Boolean = true, ) : PartView() { protected val arrow = JBLabel() @@ -48,14 +49,14 @@ abstract class AbstractSessionPartView( row.add(arrow, BorderLayout.EAST) add(row, BorderLayout.NORTH) bindHeader(row, header, arrow) - if (expanded) add(body, BorderLayout.CENTER) - syncArrow() + if (expanded && expandable) add(body, BorderLayout.CENTER) + if (!expandable) syncExpandable(false) else syncArrow() } fun isExpanded(): Boolean = body.parent === this fun toggle() { - if (!arrow.isVisible) return + if (!expandable || !arrow.isVisible) return val changed = if (isExpanded()) collapse() else expand() if (!changed) return syncArrow() @@ -63,6 +64,7 @@ abstract class AbstractSessionPartView( } fun expand(): Boolean { + if (!expandable) return false if (isExpanded()) return false add(body, BorderLayout.CENTER) return true @@ -75,9 +77,10 @@ abstract class AbstractSessionPartView( } fun syncExpandable(expandable: Boolean): Boolean { - val changed = setVisible(arrow, expandable) - val detached = if (expandable) false else collapse() - val cursor = if (expandable) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) else Cursor.getDefaultCursor() + val active = this.expandable && expandable + val changed = setVisible(arrow, active) + val detached = if (active) false else collapse() + val cursor = if (active) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) else Cursor.getDefaultCursor() val moved = syncCursor(cursor) val icon = syncArrow() return changed || detached || moved || icon diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt index a3f2266f85a..cd43bccd7f3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt @@ -9,7 +9,8 @@ abstract class PrimarySessionPartView( header: JComponent, content: JComponent, expanded: Boolean = false, -) : AbstractSessionPartView(header, content, expanded) { + expandable: Boolean = true, +) : AbstractSessionPartView(header, content, expanded, expandable) { init { isOpaque = true background = SessionUiStyle.View.surface() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt index af191dc100b..ac99946eedc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt @@ -8,7 +8,8 @@ abstract class SecondarySessionPartView( header: JComponent, content: JComponent, expanded: Boolean = false, -) : AbstractSessionPartView(header, content, expanded) { + expandable: Boolean = true, +) : AbstractSessionPartView(header, content, expanded, expandable) { init { row.isOpaque = true row.background = SessionUiStyle.View.header() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt index 2265a838690..c31d4e65ab9 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt @@ -29,11 +29,53 @@ class ReadToolViewTest : BasePlatformTestCase() { assertTrue(view.labelText().contains("README.MD")) } - fun `test read output is secondary collapsible body`() { + fun `test read file output renders filename hyperlink`() { + val opened = mutableListOf() + val path = "/Users/kirillk/work/kilocode/.kilo/worktrees/agreeable-marlin/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt" + val t = tool().also { + it.output = """ + $path + file + + content + + """.trimIndent() + } + + val view = ReadToolView(t, openFile = { opened.add(it) }) + + assertTrue(view.linkVisible()) + assertEquals("SessionUiLayoutTest.kt", view.linkText()) + assertEquals(path, view.linkHref()) + assertTrue(view.labelText().contains("SessionUiLayoutTest.kt")) + + view.openLink() + + assertEquals(listOf(path), opened) + } + + fun `test read directory output remains plain text`() { + val path = "/Users/kirillk/work/kilocode/packages/kilo-jetbrains" + val t = tool().also { + it.output = """ + $path + directory + + """.trimIndent() + } + + val view = ReadToolView(t) + + assertFalse(view.linkVisible()) + assertNull(view.linkHref()) + assertTrue(view.labelText().contains(path)) + } + + fun `test read output is secondary non expandable summary`() { val t = tool().also { it.output = "file contents" } val view = ReadToolView(t) - assertTrue(view.hasToggle()) + assertFalse(view.hasToggle()) assertFalse(view.isExpanded()) assertFalse(view.bodyVisible()) assertEquals("file contents", view.bodyText()) @@ -41,8 +83,8 @@ class ReadToolViewTest : BasePlatformTestCase() { view.toggle() - assertTrue(view.isExpanded()) - assertTrue(view.bodyVisible()) + assertFalse(view.isExpanded()) + assertFalse(view.bodyVisible()) } fun `test view factory routes read kind tools to read tool view`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt index 430e34bd8ae..523087c7a22 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt @@ -53,6 +53,22 @@ class AbstractSessionPartViewTest : BasePlatformTestCase() { assertNull(content.parent) } + fun `test fixed non expandable ignores expansion`() { + val content = JLabel("body") + val view = TestView(content = content, expanded = true, expandable = false) + + assertFalse(view.isExpanded()) + assertFalse(view.arrowVisible()) + assertNull(content.parent) + + view.syncExpandable(true) + view.toggle() + + assertFalse(view.isExpanded()) + assertFalse(view.arrowVisible()) + assertNull(content.parent) + } + fun `test header hover is subtler than hover outline`() { assertNotSameColor(SessionUiStyle.View.headerHover(), SessionUiStyle.View.hoverLine()) assertNotSameColor(SessionUiStyle.View.headerHover(), SessionUiStyle.View.line()) @@ -70,11 +86,12 @@ class AbstractSessionPartViewTest : BasePlatformTestCase() { assertEquals(SessionUiStyle.View.line().rgb, paint(view.border).rgb) } - private class TestView(content: JLabel, expanded: Boolean = false) : - PrimarySessionPartView(JLabel("header"), content, expanded) { + private class TestView(content: JLabel, expanded: Boolean = false, expandable: Boolean = true) : + PrimarySessionPartView(JLabel("header"), content, expanded, expandable) { override val contentId = "test" override fun update(content: Content) {} + fun arrowVisible() = arrow.isVisible } private fun TestView.component(index: Int): Component = components[index]