fix(jetbrains): link read file rows

This commit is contained in:
kirillk
2026-05-27 14:08:07 -04:00
parent da47b98de3
commit 01cc964196
8 changed files with 196 additions and 30 deletions
+1 -1
View File
@@ -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.
@@ -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*</$name>")
.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() }
@@ -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)
@@ -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
@@ -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()
@@ -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()
@@ -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<String>()
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>$path</path>
<type>file</type>
<content>
content
</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>$path</path>
<type>directory</type>
<content></content>
""".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`() {
@@ -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]